Files
dewemoji/app/app/Http/Controllers/Web/SiteController.php
2026-02-21 21:28:40 +07:00

562 lines
20 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Payment;
use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\UserKeyword;
use App\Services\System\SettingsService;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class SiteController extends Controller
{
/** @var array<string,string> */
private const CATEGORY_TO_SLUG = [
'Smileys & Emotion' => 'smileys',
'People & Body' => 'people',
'Animals & Nature' => 'animals',
'Food & Drink' => 'food',
'Travel & Places' => 'travel',
'Activities' => 'activities',
'Objects' => 'objects',
'Symbols' => 'symbols',
'Flags' => 'flags',
];
private function billingMode(): string
{
$settings = app(SettingsService::class);
$preferred = (string) ($settings->get('billing_mode', config('dewemoji.billing.mode', 'sandbox')) ?: 'sandbox');
if ($this->paypalConfiguredMode($preferred)) {
return $preferred;
}
$fallback = $preferred === 'live' ? 'sandbox' : 'live';
if ($this->paypalConfiguredMode($fallback)) {
return $fallback;
}
return $preferred;
}
public function home(Request $request): View
{
return view('site.home', [
'initialQuery' => trim((string) $request->query('q', '')),
'initialCategory' => trim((string) $request->query('category', '')),
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
'canonicalPath' => '/',
'userTier' => $request->user()?->tier,
]);
}
public function browse(Request $request): RedirectResponse|View
{
$cat = strtolower(trim((string) $request->query('cat', 'all')));
if ($cat !== '' && $cat !== 'all' && array_key_exists($cat, $this->categorySlugMap())) {
return redirect('/'.$cat, 301);
}
return view('site.home', [
'initialQuery' => trim((string) $request->query('q', '')),
'initialCategory' => trim((string) $request->query('category', '')),
'initialSubcategory' => trim((string) $request->query('subcategory', '')),
'canonicalPath' => '/browse',
'userTier' => $request->user()?->tier,
]);
}
public function category(string $categorySlug): View
{
if ($categorySlug === 'all') {
return view('site.home', [
'initialQuery' => '',
'initialCategory' => '',
'initialSubcategory' => '',
'canonicalPath' => '/',
'userTier' => request()->user()?->tier,
]);
}
$categoryLabel = $this->categorySlugMap()[$categorySlug] ?? '';
abort_if($categoryLabel === '', 404);
return view('site.home', [
'initialQuery' => '',
'initialCategory' => $categoryLabel,
'initialSubcategory' => '',
'canonicalPath' => '/'.$categorySlug,
'userTier' => request()->user()?->tier,
]);
}
public function categorySubcategory(string $categorySlug, string $subcategorySlug): View
{
if ($categorySlug === 'all') {
abort(404);
}
$categoryLabel = $this->categorySlugMap()[$categorySlug] ?? '';
abort_if($categoryLabel === '', 404);
return view('site.home', [
'initialQuery' => '',
'initialCategory' => $categoryLabel,
'initialSubcategory' => $subcategorySlug,
'canonicalPath' => '/'.$categorySlug.'/'.$subcategorySlug,
'userTier' => request()->user()?->tier,
]);
}
public function apiDocs(): View
{
return view('site.api-docs');
}
public function pricing(): View
{
$user = request()->user();
$currencyPref = strtoupper((string) session('pricing_currency', ''));
if (!in_array($currencyPref, ['IDR', 'USD'], true)) {
$currencyPref = $this->detectPricingCurrency(request());
session(['pricing_currency' => $currencyPref]);
}
$rate = (int) config('dewemoji.pricing.usd_rate', 15000);
$plans = PricingPlan::where('status', 'active')->get()->keyBy('code');
$defaults = config('dewemoji.pricing.defaults', []);
$fallback = collect($defaults)->keyBy('code');
$getPlanAmount = function (string $code) use ($plans, $fallback): int {
$plan = $plans->get($code) ?? $fallback->get($code);
return (int) ($plan['amount'] ?? $plan->amount ?? 0);
};
$pricing = [
'personal_monthly' => [
'idr' => $getPlanAmount('personal_monthly'),
],
'personal_annual' => [
'idr' => $getPlanAmount('personal_annual'),
],
'personal_lifetime' => [
'idr' => $getPlanAmount('personal_lifetime'),
],
];
foreach ($pricing as $key => $row) {
$pricing[$key]['usd'] = $rate > 0 ? round($row['idr'] / $rate, 2) : 0;
}
$hasActiveLifetime = false;
$hasPendingPayment = false;
$pendingCooldownRemaining = 0;
if ($user) {
$hasActiveLifetime = Subscription::query()
->where('user_id', $user->id)
->where('plan', 'personal_lifetime')
->where('status', 'active')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
$hasPendingPayment = Payment::query()
->where('user_id', $user->id)
->where('status', 'pending')
->exists();
$cooldown = (int) config('dewemoji.billing.pending_cooldown_seconds', 120);
if ($cooldown > 0) {
$latestPending = Payment::query()
->where('user_id', $user->id)
->where('status', 'pending')
->orderByDesc('id')
->first();
if ($latestPending && $latestPending->created_at) {
$age = max(0, now()->getTimestamp() - $latestPending->created_at->getTimestamp());
$pendingCooldownRemaining = max(0, $cooldown - $age);
}
}
}
return view('site.pricing', [
'currencyPref' => $currencyPref,
'usdRate' => $rate,
'pricing' => $pricing,
'payments' => [
'qris_url' => (string) config('dewemoji.payments.qris_url', ''),
'paypal_url' => (string) config('dewemoji.payments.paypal_url', ''),
],
'pakasirEnabled' => (bool) config('dewemoji.billing.providers.pakasir.enabled', false)
&& (string) config('dewemoji.billing.providers.pakasir.api_base', '') !== ''
&& (string) config('dewemoji.billing.providers.pakasir.api_key', '') !== ''
&& (string) config('dewemoji.billing.providers.pakasir.project', '') !== '',
'paypalEnabled' => $this->paypalEnabled($this->billingMode()),
'paypalPlans' => $this->paypalPlanAvailability($this->billingMode()),
'hasActiveLifetime' => $hasActiveLifetime,
'hasPendingPayment' => $hasPendingPayment,
'pendingCooldownRemaining' => $pendingCooldownRemaining,
]);
}
private function paypalEnabled(string $mode): bool
{
if ($this->paypalConfiguredMode($mode)) {
return true;
}
$fallback = $mode === 'live' ? 'sandbox' : 'live';
return $this->paypalConfiguredMode($fallback);
}
private function paypalConfiguredMode(string $mode): bool
{
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
$clientId = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_id", '');
$clientSecret = (string) config("dewemoji.billing.providers.paypal.{$mode}.client_secret", '');
$apiBase = (string) config("dewemoji.billing.providers.paypal.{$mode}.api_base", '');
return $enabled && $clientId !== '' && $clientSecret !== '' && $apiBase !== '';
}
private function paypalPlanAvailability(string $mode): array
{
$plans = PricingPlan::whereIn('code', ['personal_monthly', 'personal_annual'])->get()->keyBy('code');
$fromDb = function (string $code) use ($plans, $mode): bool {
$plan = $plans->get($code);
if (!$plan) {
return false;
}
$meta = $plan->meta ?? [];
return (string) ($meta['paypal'][$mode]['plan']['id'] ?? '') !== '';
};
$fromEnv = function (string $code) use ($mode): bool {
return (string) config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$code}", '') !== '';
};
return [
'personal_monthly' => $fromDb('personal_monthly') || $fromEnv('personal_monthly'),
'personal_annual' => $fromDb('personal_annual') || $fromEnv('personal_annual'),
];
}
public function setPricingCurrency(Request $request): RedirectResponse
{
$data = $request->validate([
'currency' => 'required|string|in:IDR,USD',
]);
session(['pricing_currency' => $data['currency']]);
return back();
}
public function support(): View
{
return view('site.support');
}
public function download(): View
{
$downloadBaseUrl = rtrim((string) config('dewemoji.apk_release.public_base_url', ''), '/');
$androidEnabled = (bool) config('dewemoji.apk_release.enabled', false) && $downloadBaseUrl !== '';
return view('site.download', [
'androidEnabled' => $androidEnabled,
'androidVersionJsonUrl' => $androidEnabled ? $downloadBaseUrl.'/version.json' : '',
'androidLatestApkUrl' => $androidEnabled ? $downloadBaseUrl.'/dewemoji-latest.apk' : '',
]);
}
public function downloadVersionJson(Request $request): RedirectResponse|JsonResponse
{
$target = $this->apkReleaseTargetUrl('version_json');
if ($target === '') {
return response()->json(['ok' => false, 'error' => 'apk_release_not_configured'], 404);
}
return redirect()->away($target, 302, [
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
]);
}
public function downloadLatestApk(Request $request): RedirectResponse|JsonResponse
{
$target = $this->apkReleaseTargetUrl('latest_apk');
if ($target === '') {
return response()->json(['ok' => false, 'error' => 'apk_release_not_configured'], 404);
}
return redirect()->away($target, 302, [
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
]);
}
public function privacy(): View
{
return view('site.privacy');
}
public function terms(): View
{
return view('site.terms');
}
private function detectPricingCurrency(Request $request): string
{
$country = strtoupper((string) ($request->header('CF-IPCountry')
?? $request->header('X-Country-Code')
?? $request->header('X-Geo-Country')
?? $request->header('X-Appengine-Country')
?? $request->header('CloudFront-Viewer-Country')
?? ''));
return $country === 'ID' ? 'IDR' : 'USD';
}
public function emojiDetail(string $slug): View|Response
{
$dataPath = $this->datasetPath();
if (!is_file($dataPath)) {
abort(500, 'Emoji dataset file not found.');
}
$raw = file_get_contents($dataPath);
if ($raw === false) {
abort(500, 'Emoji dataset file could not be read.');
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
abort(500, 'Emoji dataset JSON is invalid.');
}
$items = $decoded['emojis'] ?? [];
$match = null;
$byEmoji = [];
foreach ($items as $item) {
$char = (string) ($item['emoji'] ?? '');
if ($char !== '' && !isset($byEmoji[$char])) {
$byEmoji[$char] = $item;
}
if (($item['slug'] ?? '') === $slug) {
$match = $item;
}
}
if (!$match) {
abort(404);
}
$relatedDetails = [];
foreach (array_slice($match['related'] ?? [], 0, 8) as $relatedEmoji) {
$relatedEmoji = (string) $relatedEmoji;
$ref = $byEmoji[$relatedEmoji] ?? null;
$relatedDetails[] = [
'emoji' => $relatedEmoji,
'slug' => (string) ($ref['slug'] ?? ''),
'name' => (string) ($ref['name'] ?? $relatedEmoji),
];
}
$user = request()->user();
$canManageKeywords = (bool) $user;
$isPersonal = $user && (string) $user->tier === 'personal';
$freeLimit = (int) config('dewemoji.pagination.free_max_limit', 20);
$keywordLimit = $isPersonal ? null : $freeLimit;
$userKeywords = [];
$activeKeywordCount = 0;
if ($canManageKeywords) {
$activeKeywordCount = UserKeyword::where('user_id', $user->id)
->where('is_active', true)
->count();
$userKeywords = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $slug)
->orderByDesc('id')
->get();
}
$limitReached = $keywordLimit !== null && $activeKeywordCount >= $keywordLimit;
return view('site.emoji-detail', [
'emoji' => $match,
'relatedDetails' => $relatedDetails,
'canonicalPath' => '/emoji/'.$slug,
'userKeywords' => $userKeywords,
'canManageKeywords' => $canManageKeywords,
'keywordLimit' => $keywordLimit,
'limitReached' => $limitReached,
'activeKeywordCount' => $activeKeywordCount,
'userTier' => $user?->tier,
]);
}
public function robotsTxt(): Response
{
$base = rtrim(config('app.url', request()->getSchemeAndHttpHost()), '/');
$body = "User-agent: *\nAllow: /\n\nSitemap: ".$base."/sitemap.xml\n";
return response($body, 200)->header('Content-Type', 'text/plain; charset=UTF-8');
}
public function sitemapXml(): Response
{
$data = $this->loadDataset();
$items = is_array($data['emojis'] ?? null) ? $data['emojis'] : [];
$base = rtrim(config('app.url', request()->getSchemeAndHttpHost()), '/');
$lastUpdatedTs = isset($data['last_updated_ts']) ? (int) $data['last_updated_ts'] : time();
$lastUpdated = gmdate('Y-m-d\TH:i:s\Z', $lastUpdatedTs);
$urls = [
['loc' => $base.'/', 'priority' => '0.8', 'changefreq' => 'daily'],
['loc' => $base.'/api-docs', 'priority' => '0.5', 'changefreq' => 'weekly'],
['loc' => $base.'/pricing', 'priority' => '0.7', 'changefreq' => 'weekly'],
['loc' => $base.'/privacy', 'priority' => '0.3', 'changefreq' => 'monthly'],
['loc' => $base.'/terms', 'priority' => '0.3', 'changefreq' => 'monthly'],
['loc' => $base.'/support', 'priority' => '0.4', 'changefreq' => 'weekly'],
];
foreach ($items as $item) {
$slug = trim((string) ($item['slug'] ?? ''));
if ($slug === '' || $this->shouldHideForSitemap($item)) {
continue;
}
$urls[] = [
'loc' => $base.'/emoji/'.$slug,
'priority' => '0.6',
'changefreq' => 'weekly',
];
}
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
foreach ($urls as $url) {
$xml .= " <url>\n";
$xml .= ' <loc>'.htmlspecialchars((string) $url['loc'], ENT_XML1)."</loc>\n";
$xml .= ' <lastmod>'.$lastUpdated."</lastmod>\n";
$xml .= ' <changefreq>'.$url['changefreq']."</changefreq>\n";
$xml .= ' <priority>'.$url['priority']."</priority>\n";
$xml .= " </url>\n";
}
$xml .= '</urlset>'."\n";
return response($xml, 200)->header('Content-Type', 'application/xml; charset=UTF-8');
}
/**
* @return array<string,string>
*/
private function categorySlugMap(): array
{
$out = [];
foreach (self::CATEGORY_TO_SLUG as $label => $slug) {
$out[$slug] = $label;
}
return $out;
}
/**
* @return array<string,mixed>
*/
private function loadDataset(): array
{
$dataPath = $this->datasetPath();
if (!is_file($dataPath)) {
return ['emojis' => []];
}
$raw = file_get_contents($dataPath);
if ($raw === false) {
return ['emojis' => []];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return ['emojis' => []];
}
return $decoded;
}
private function datasetPath(): string
{
$settings = app(SettingsService::class);
$activePath = (string) $settings->get('emoji_dataset_active_path', '');
if ($activePath !== '' && is_file($activePath)) {
return $activePath;
}
return (string) config('dewemoji.data_path');
}
private function apkReleaseTargetUrl(string $key): string
{
if (!(bool) config('dewemoji.apk_release.enabled', false)) {
return '';
}
$base = trim((string) config('dewemoji.apk_release.r2_public_base_url', ''));
$objectKey = trim((string) config("dewemoji.apk_release.r2_keys.{$key}", ''));
if ($base === '' || $objectKey === '') {
return '';
}
return rtrim($base, '/').'/'.ltrim($objectKey, '/');
}
/**
* @param array<string,mixed> $emoji
*/
private function shouldHideForSitemap(array $emoji): bool
{
$name = strtolower(trim((string) ($emoji['name'] ?? '')));
$category = strtolower(trim((string) ($emoji['category'] ?? '')));
$subcategory = strtolower(trim((string) ($emoji['subcategory'] ?? '')));
if ($subcategory === 'family' || str_starts_with($name, 'family:')) {
return true;
}
if (preg_match('~\bwoman: beard\b~i', $name)) {
return true;
}
if (preg_match('~\bmen with bunny ears\b~i', $name)) {
return true;
}
if (preg_match('~\bpregnant man\b~i', $name)) {
return true;
}
if ($category === 'people & body') {
if (preg_match('~\bmen holding hands\b~i', $name)) {
return true;
}
if (preg_match('~\bwomen holding hands\b~i', $name)) {
return true;
}
if (preg_match('~kiss:.*\bman,\s*man\b~i', $name)) {
return true;
}
if (preg_match('~kiss:.*\bwoman,\s*woman\b~i', $name)) {
return true;
}
if (preg_match('~couple.*\bman,\s*man\b~i', $name)) {
return true;
}
if (preg_match('~couple.*\bwoman,\s*woman\b~i', $name)) {
return true;
}
}
return false;
}
}