Implement catalog CRUD overhaul, snapshot fallback activation, and billing/UX hardening

This commit is contained in:
Dwindi Ramadhana
2026-02-17 00:03:35 +07:00
parent e6aef31dd1
commit 2726b6c312
37 changed files with 2936 additions and 204 deletions

View File

@@ -73,6 +73,7 @@ DEWEMOJI_MAX_LIMIT=50
DEWEMOJI_FREE_MAX_LIMIT=20
DEWEMOJI_PRO_MAX_LIMIT=50
DEWEMOJI_BILLING_MODE=sandbox
DEWEMOJI_CHECKOUT_PENDING_COOLDOWN=120
DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com
DEWEMOJI_FRONTEND_HEADER=web-v1
DEWEMOJI_METRICS_ENABLED=true

View File

@@ -6,12 +6,18 @@ use App\Http\Controllers\Controller;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Services\Keywords\KeywordQuotaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class AdminSubscriptionController extends Controller
{
public function __construct(
private readonly KeywordQuotaService $keywordQuota
) {
}
private function authorizeAdmin(Request $request): ?JsonResponse
{
$token = (string) config('dewemoji.admin.token', '');
@@ -76,6 +82,7 @@ class AdminSubscriptionController extends Controller
if ($status === 'active') {
$user->update(['tier' => 'personal']);
$this->keywordQuota->enforceForUser((int) $user->id, 'personal');
}
return response()->json(['ok' => true, 'subscription' => $sub]);
@@ -151,6 +158,7 @@ class AdminSubscriptionController extends Controller
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
$this->keywordQuota->enforceForUser($userId, $active ? 'personal' : 'free');
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}

View File

@@ -4,11 +4,17 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Keywords\KeywordQuotaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
public function __construct(
private readonly KeywordQuotaService $keywordQuota
) {
}
private function authorizeAdmin(Request $request): ?JsonResponse
{
$adminToken = (string) config('dewemoji.admin.token', '');
@@ -110,6 +116,7 @@ class AdminUserController extends Controller
$user->tier = $data['tier'];
$user->save();
$this->keywordQuota->enforceForUser((int) $user->id, (string) $user->tier);
return response()->json([
'ok' => true,

View File

@@ -117,10 +117,76 @@ class EmojiApiController extends Controller
$filtered = $this->filterItems($items, $q, $category, $subSlug);
// Signed-in users can enrich search with their active private keywords.
$user = $this->apiKeys->resolveUser($request) ?? $request->user();
if ($user && $q !== '') {
$bySlug = [];
foreach ($filtered as $row) {
$slug = (string) ($row['slug'] ?? '');
if ($slug !== '') {
$bySlug[$slug] = $row;
}
}
$itemsBySlug = [];
foreach ($items as $row) {
$slug = (string) ($row['slug'] ?? '');
if ($slug !== '') {
$itemsBySlug[$slug] = $row;
}
}
$privateRows = DB::table('user_keywords')
->where('user_id', $user->id)
->where('is_active', true)
->whereRaw('LOWER(keyword) LIKE ?', ['%'.strtolower($q).'%'])
->get(['id', 'emoji_slug', 'keyword', 'lang']);
foreach ($privateRows as $privateRow) {
$slug = (string) $privateRow->emoji_slug;
if ($slug === '' || isset($bySlug[$slug])) {
continue;
}
$sourceItem = $itemsBySlug[$slug] ?? null;
if (!$sourceItem) {
continue;
}
// Preserve current category/subcategory filters.
$itemCategory = trim((string) ($sourceItem['category'] ?? ''));
$itemSubcategory = trim((string) ($sourceItem['subcategory'] ?? ''));
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
continue;
}
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
continue;
}
$sourceItem['source'] = 'private';
$sourceItem['matched_keyword_id'] = (int) $privateRow->id;
$sourceItem['matched_keyword'] = (string) $privateRow->keyword;
$sourceItem['matched_lang'] = (string) $privateRow->lang;
$bySlug[$slug] = $sourceItem;
}
$filtered = array_values($bySlug);
}
$total = count($filtered);
$offset = ($page - 1) * $limit;
$pageItems = array_slice($filtered, $offset, $limit);
$outItems = array_map(fn (array $item): array => $this->transformItem($item, $tier), $pageItems);
$outItems = array_map(function (array $item) use ($tier): array {
$out = $this->transformItem($item, $tier);
if (isset($item['source'])) {
$out['source'] = (string) $item['source'];
$out['matched_keyword_id'] = $item['matched_keyword_id'] ?? null;
$out['matched_keyword'] = $item['matched_keyword'] ?? null;
$out['matched_lang'] = $item['matched_lang'] ?? null;
}
return $out;
}, $pageItems);
$responsePayload = [
'items' => $outItems,
@@ -155,10 +221,6 @@ class EmojiApiController extends Controller
if (!$user) {
return response()->json(['ok' => false, 'error' => 'unauthorized'], 401);
}
if ((string) $user->tier !== 'personal') {
return response()->json(['ok' => false, 'error' => 'personal_required'], 403);
}
$q = trim((string) ($request->query('q', $request->query('query', ''))));
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
@@ -193,6 +255,7 @@ class EmojiApiController extends Controller
if ($q !== '') {
$rows = DB::table('user_keywords')
->where('user_id', $user->id)
->where('is_active', true)
->whereRaw('LOWER(keyword) LIKE ?', ['%'.strtolower($q).'%'])
->get(['id', 'emoji_slug', 'keyword', 'lang']);
@@ -209,7 +272,7 @@ class EmojiApiController extends Controller
}
}
$tier = self::TIER_PRO;
$tier = $this->detectTier($request);
$merged = [];
foreach ($privateMatches as $slug => $meta) {
$sourceItem = $publicBySlug[$slug] ?? $itemsBySlug[$slug] ?? null;
@@ -245,7 +308,7 @@ class EmojiApiController extends Controller
'total' => $total,
'page' => $page,
'limit' => $limit,
'plan' => 'personal',
'plan' => $tier,
]);
}
@@ -297,6 +360,7 @@ class EmojiApiController extends Controller
$payload['user_keywords'] = DB::table('user_keywords')
->where('user_id', $user->id)
->where('emoji_slug', $slug)
->where('is_active', true)
->orderByDesc('id')
->get(['id', 'keyword', 'lang']);
} else {
@@ -361,7 +425,12 @@ class EmojiApiController extends Controller
return self::$dataset;
}
$path = (string) config('dewemoji.data_path');
$settings = app(SettingsService::class);
$activePath = (string) $settings->get('emoji_dataset_active_path', '');
$path = $activePath !== '' && is_file($activePath)
? $activePath
: (string) config('dewemoji.data_path');
if (!is_file($path)) {
throw new RuntimeException('Emoji dataset file was not found at: '.$path);
}

View File

@@ -5,13 +5,15 @@ namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\UserKeyword;
use App\Services\Auth\ApiKeyService;
use App\Services\Keywords\KeywordQuotaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserKeywordController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
private readonly ApiKeyService $keys,
private readonly KeywordQuotaService $keywordQuota
) {
}
@@ -35,7 +37,7 @@ class UserKeywordController extends Controller
$user = $check['user'];
$items = UserKeyword::where('user_id', $user->id)
->orderByDesc('id')
->get(['id', 'emoji_slug', 'keyword', 'lang', 'created_at', 'updated_at']);
->get(['id', 'emoji_slug', 'keyword', 'lang', 'is_active', 'created_at', 'updated_at']);
return response()->json(['ok' => true, 'items' => $items]);
}
@@ -55,30 +57,35 @@ class UserKeywordController extends Controller
$lang = $data['lang'] ?? 'und';
$user = $check['user'];
$existing = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->first();
$targetActive = $existing ? (bool) $existing->is_active : true;
$limit = $this->keywordLimitFor($user);
if ($limit !== null) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->exists();
if (!$exists) {
$count = UserKeyword::where('user_id', $user->id)->count();
if ($count >= $limit) {
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
if (!$existing && $targetActive) {
$activeCount = $this->keywordQuota->activeCount((int) $user->id);
if ($activeCount >= $limit) {
return response()->json(['ok' => false, 'error' => 'free_active_limit_reached', 'limit' => $limit], 403);
}
}
}
$item = UserKeyword::updateOrCreate(
[
if ($existing) {
$existing->update([
'lang' => $lang,
]);
$item = $existing;
} else {
$item = UserKeyword::create([
'user_id' => $user->id,
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
],
[
'lang' => $lang,
]
);
'is_active' => true,
]);
}
return response()->json(['ok' => true, 'item' => $item]);
}
@@ -127,11 +134,11 @@ class UserKeywordController extends Controller
$item->delete();
$item = $duplicate;
} else {
$item->update([
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
'lang' => $data['lang'] ?? 'und',
]);
$item->update([
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
'lang' => $data['lang'] ?? 'und',
]);
}
return response()->json(['ok' => true, 'item' => $item]);
@@ -146,7 +153,7 @@ class UserKeywordController extends Controller
$items = UserKeyword::where('user_id', $check['user']->id)
->orderByDesc('id')
->get(['emoji_slug', 'keyword', 'lang']);
->get(['emoji_slug', 'keyword', 'lang', 'is_active']);
return response()->json(['ok' => true, 'items' => $items]);
}
@@ -169,29 +176,34 @@ class UserKeywordController extends Controller
$skipped = 0;
$user = $check['user'];
$limit = $this->keywordLimitFor($user);
$current = UserKeyword::where('user_id', $user->id)->count();
$activeCount = $this->keywordQuota->activeCount((int) $user->id);
foreach ($data['items'] as $row) {
$exists = UserKeyword::where('user_id', $user->id)
$existing = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $row['emoji_slug'])
->where('keyword', $row['keyword'])
->exists();
->first();
$targetActive = filter_var($row['is_active'] ?? true, FILTER_VALIDATE_BOOL);
if (!$exists && $limit !== null && $current >= $limit) {
if (!$existing && $targetActive && $limit !== null && $activeCount >= $limit) {
$skipped += 1;
continue;
}
UserKeyword::updateOrCreate(
[
if ($existing) {
$existing->update([
'lang' => $row['lang'] ?? 'und',
]);
} else {
UserKeyword::create([
'user_id' => $user->id,
'emoji_slug' => $row['emoji_slug'],
'keyword' => $row['keyword'],
],
[
'lang' => $row['lang'] ?? 'und',
]
);
if (!$exists) {
$current += 1;
'is_active' => $targetActive,
]);
if ($targetActive) {
$activeCount += 1;
}
}
$count += 1;
}
@@ -199,6 +211,35 @@ class UserKeywordController extends Controller
return response()->json(['ok' => true, 'imported' => $count, 'skipped' => $skipped]);
}
public function toggleActive(Request $request, int $id): JsonResponse
{
$check = $this->ensureUser($request);
if (!isset($check['user'])) {
return response()->json(['ok' => false, 'error' => $check['error']], $check['status']);
}
$data = $request->validate([
'is_active' => 'required|boolean',
]);
$user = $check['user'];
$item = UserKeyword::where('user_id', $user->id)->where('id', $id)->first();
if (!$item) {
return response()->json(['ok' => false, 'error' => 'not_found'], 404);
}
if ((bool) $data['is_active'] && ($limit = $this->keywordLimitFor($user))) {
$activeCount = $this->keywordQuota->activeCount((int) $user->id);
if (!$item->is_active && $activeCount >= $limit) {
return response()->json(['ok' => false, 'error' => 'free_active_limit_reached', 'limit' => $limit], 403);
}
}
$item->update(['is_active' => (bool) $data['is_active']]);
return response()->json(['ok' => true, 'item' => $item]);
}
private function keywordLimitFor($user): ?int
{
if ((string) ($user->tier ?? 'free') === 'personal') {

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Billing;
use App\Http\Controllers\Controller;
use App\Models\Payment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BillingPaymentController extends Controller
{
public function resume(Request $request, Payment $payment): JsonResponse
{
$user = $request->user();
if (!$user || (int) $payment->user_id !== (int) $user->id) {
abort(403);
}
if ((string) $payment->status !== 'pending') {
return response()->json(['ok' => false, 'error' => 'payment_not_pending'], 409);
}
$provider = strtolower((string) $payment->provider);
return match ($provider) {
'paypal' => $this->resumePayPal($payment),
'pakasir' => $this->resumePakasir($payment),
default => response()->json(['ok' => false, 'error' => 'provider_unsupported'], 422),
};
}
private function resumePayPal(Payment $payment): JsonResponse
{
$raw = is_array($payment->raw_payload) ? $payment->raw_payload : [];
$links = is_array($raw['links'] ?? null) ? $raw['links'] : [];
$approveUrl = null;
foreach ($links as $link) {
if (!is_array($link)) {
continue;
}
if ((string) ($link['rel'] ?? '') === 'approve') {
$href = trim((string) ($link['href'] ?? ''));
if ($href !== '') {
$approveUrl = $href;
break;
}
}
}
if ($approveUrl === null) {
return response()->json(['ok' => false, 'error' => 'resume_data_missing'], 422);
}
return response()->json([
'ok' => true,
'mode' => 'redirect',
'provider' => 'paypal',
'approve_url' => $approveUrl,
'payment_id' => $payment->id,
]);
}
private function resumePakasir(Payment $payment): JsonResponse
{
$raw = is_array($payment->raw_payload) ? $payment->raw_payload : [];
$pay = is_array($raw['payment'] ?? null)
? $raw['payment']
: (is_array($raw['data'] ?? null) ? $raw['data'] : $raw);
$paymentNumber = trim((string) (
$pay['payment_number']
?? $pay['qris_string']
?? $pay['qr_string']
?? $pay['qr_value']
?? ''
));
$expiredAt = trim((string) (
$pay['expired_at']
?? $pay['expires_at']
?? $pay['expired']
?? ''
));
$totalPayment = (int) (
$pay['total_payment']
?? $pay['amount']
?? $raw['total_payment']
?? $raw['amount']
?? $payment->amount
);
$orderId = trim((string) (
$pay['order_id']
?? $raw['order_id']
?? $payment->order?->provider_ref
?? ''
));
if ($paymentNumber === '') {
return response()->json(['ok' => false, 'error' => 'resume_data_missing'], 422);
}
if ($expiredAt !== '') {
$ts = strtotime($expiredAt);
if ($ts !== false && $ts < time()) {
return response()->json(['ok' => false, 'error' => 'payment_expired'], 409);
}
}
return response()->json([
'ok' => true,
'mode' => 'qris',
'provider' => 'pakasir',
'payment_id' => $payment->id,
'payment_number' => $paymentNumber,
'expired_at' => $expiredAt,
'amount' => (int) $payment->amount,
'total_payment' => $totalPayment,
'order_id' => $orderId,
]);
}
}

View File

@@ -9,6 +9,8 @@ use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\WebhookEvent;
use App\Services\Billing\SubscriptionTransitionService;
use App\Services\Keywords\KeywordQuotaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
@@ -17,6 +19,12 @@ use Illuminate\Support\Str;
class PakasirController extends Controller
{
public function __construct(
private readonly SubscriptionTransitionService $subscriptionTransition,
private readonly KeywordQuotaService $keywordQuota
) {
}
public function createTransaction(Request $request): JsonResponse
{
$data = $request->validate([
@@ -28,6 +36,14 @@ class PakasirController extends Controller
return response()->json(['error' => 'auth_required'], 401);
}
if ($this->hasActiveLifetimeSubscription((int) $user->id)) {
return response()->json(['error' => 'lifetime_active'], 409);
}
if ($cooldownPayload = $this->pendingCheckoutCooldown((int) $user->id)) {
return response()->json($cooldownPayload, 409);
}
$config = config('dewemoji.billing.providers.pakasir', []);
$enabled = (bool) ($config['enabled'] ?? false);
$apiBase = rtrim((string) ($config['api_base'] ?? ''), '/');
@@ -44,18 +60,7 @@ class PakasirController extends Controller
return response()->json(['error' => 'invalid_plan_amount'], 422);
}
Subscription::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Order::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Payment::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
$this->subscriptionTransition->cancelPendingForUser((int) $user->id);
$order = Order::create([
'user_id' => $user->id,
@@ -190,6 +195,40 @@ class PakasirController extends Controller
return $orderRef;
}
/**
* @return array<string,mixed>|null
*/
private function pendingCheckoutCooldown(int $userId): ?array
{
$cooldown = (int) config('dewemoji.billing.pending_cooldown_seconds', 120);
if ($cooldown <= 0) {
return null;
}
$pending = Payment::query()
->where('user_id', $userId)
->where('status', 'pending')
->orderByDesc('id')
->first();
if (!$pending || !$pending->created_at) {
return null;
}
$age = max(0, now()->getTimestamp() - $pending->created_at->getTimestamp());
$remaining = $cooldown - $age;
if ($remaining <= 0) {
return null;
}
return [
'error' => 'pending_cooldown',
'retry_after' => (int) $remaining,
'pending_payment_id' => (int) $pending->id,
'provider' => (string) ($pending->provider ?? ''),
'created_at' => $pending->created_at->toIso8601String(),
];
}
public function cancelPending(Request $request): JsonResponse
{
$user = $request->user();
@@ -209,17 +248,17 @@ class PakasirController extends Controller
$order = $orderQuery->orderByDesc('id')->first();
if (!$order) {
return response()->json(['ok' => true, 'cancelled' => false]);
return response()->json(['ok' => true, 'canceled' => false, 'cancelled' => false]);
}
$order->update(['status' => 'cancelled']);
Payment::where('order_id', $order->id)->where('status', 'pending')->update(['status' => 'cancelled']);
$order->update(['status' => 'canceled']);
Payment::where('order_id', $order->id)->where('status', 'pending')->update(['status' => 'canceled']);
Subscription::where('user_id', $user->id)
->where('provider', 'pakasir')
->where('status', 'pending')
->update(['status' => 'cancelled']);
->update(['status' => 'canceled']);
return response()->json(['ok' => true, 'cancelled' => true]);
return response()->json(['ok' => true, 'canceled' => true, 'cancelled' => true]);
}
public function paymentStatus(Request $request): JsonResponse
@@ -343,6 +382,13 @@ class PakasirController extends Controller
);
User::where('id', $user->id)->update(['tier' => 'personal']);
$this->keywordQuota->enforceForUser((int) $user->id, 'personal');
$this->subscriptionTransition->settleForActivatedPlan(
(int) $user->id,
'pakasir',
$orderId,
(string) $order->plan_code
);
}
private function resolvePlanAmountIdr(string $planCode): int
@@ -360,4 +406,17 @@ class PakasirController extends Controller
return (int) ($fallback['amount'] ?? 0);
}
private function hasActiveLifetimeSubscription(int $userId): bool
{
return Subscription::query()
->where('user_id', $userId)
->where('plan', 'personal_lifetime')
->where('status', 'active')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
}
}

View File

@@ -9,6 +9,8 @@ use App\Models\PricingPlan;
use App\Models\Subscription;
use App\Models\User;
use App\Models\WebhookEvent;
use App\Services\Billing\SubscriptionTransitionService;
use App\Services\Keywords\KeywordQuotaService;
use App\Services\System\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -20,6 +22,12 @@ use Illuminate\Support\Str;
class PayPalController extends Controller
{
public function __construct(
private readonly SubscriptionTransitionService $subscriptionTransition,
private readonly KeywordQuotaService $keywordQuota
) {
}
public function createSubscription(Request $request): RedirectResponse|JsonResponse
{
$data = $request->validate([
@@ -31,6 +39,14 @@ class PayPalController extends Controller
return response()->json(['error' => 'auth_required'], 401);
}
if ($this->hasActiveLifetimeSubscription((int) $user->id)) {
return response()->json(['error' => 'lifetime_active'], 409);
}
if ($cooldownPayload = $this->pendingCheckoutCooldown((int) $user->id)) {
return response()->json($cooldownPayload, 409);
}
$mode = $this->resolvePaypalMode($this->billingMode());
if (!$this->paypalConfigured($mode)) {
return response()->json(['error' => 'paypal_not_configured'], 422);
@@ -95,18 +111,7 @@ class PayPalController extends Controller
$amountUsd = $this->resolvePlanAmountUsd($data['plan_code']);
Subscription::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Order::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Payment::where('user_id', $user->id)
->where('provider', 'paypal')
->where('status', 'pending')
->update(['status' => 'cancelled']);
$this->subscriptionTransition->cancelPendingForUser((int) $user->id);
$order = Order::create([
'user_id' => $user->id,
@@ -213,6 +218,7 @@ class PayPalController extends Controller
if ($subscriptionId === '') {
return false;
}
$resolvedPlan = 'personal_monthly';
$sub = Subscription::firstOrNew([
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
@@ -221,8 +227,13 @@ class PayPalController extends Controller
$order = Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->first();
if ($order) {
$sub->user_id = $order->user_id;
$resolvedPlan = (string) ($order->plan_code ?: $resolvedPlan);
}
}
if (!empty($sub->plan)) {
$resolvedPlan = (string) $sub->plan;
}
$sub->plan = $resolvedPlan;
$sub->status = 'active';
$sub->started_at = $sub->started_at ?? now();
$sub->next_renewal_at = $resource['billing_info']['next_billing_time'] ?? null;
@@ -230,6 +241,13 @@ class PayPalController extends Controller
if ($sub->user_id) {
User::where('id', $sub->user_id)->update(['tier' => 'personal']);
$this->keywordQuota->enforceForUser((int) $sub->user_id, 'personal');
$this->subscriptionTransition->settleForActivatedPlan(
(int) $sub->user_id,
'paypal',
$subscriptionId,
$resolvedPlan
);
}
Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
@@ -278,6 +296,40 @@ class PayPalController extends Controller
return config("dewemoji.billing.providers.paypal.plan_ids.{$mode}.{$planCode}") ?: null;
}
/**
* @return array<string,mixed>|null
*/
private function pendingCheckoutCooldown(int $userId): ?array
{
$cooldown = (int) config('dewemoji.billing.pending_cooldown_seconds', 120);
if ($cooldown <= 0) {
return null;
}
$pending = Payment::query()
->where('user_id', $userId)
->where('status', 'pending')
->orderByDesc('id')
->first();
if (!$pending || !$pending->created_at) {
return null;
}
$age = max(0, now()->getTimestamp() - $pending->created_at->getTimestamp());
$remaining = $cooldown - $age;
if ($remaining <= 0) {
return null;
}
return [
'error' => 'pending_cooldown',
'retry_after' => (int) $remaining,
'pending_payment_id' => (int) $pending->id,
'provider' => (string) ($pending->provider ?? ''),
'created_at' => $pending->created_at->toIso8601String(),
];
}
private function resolvePlanAmountUsd(string $planCode): int
{
$plan = PricingPlan::where('code', $planCode)->first();
@@ -444,16 +496,7 @@ class PayPalController extends Controller
return response()->json(['error' => 'paypal_invalid_response'], 502);
}
Order::where('user_id', $user->id)
->where('provider', 'paypal')
->where('type', 'one_time')
->where('status', 'pending')
->update(['status' => 'cancelled']);
Payment::where('user_id', $user->id)
->where('provider', 'paypal')
->where('type', 'one_time')
->where('status', 'pending')
->update(['status' => 'cancelled']);
$this->subscriptionTransition->cancelPendingForUser((int) $user->id);
$order = Order::create([
'user_id' => $user->id,
@@ -571,7 +614,27 @@ class PayPalController extends Controller
]);
User::where('id', $order->user_id)->update(['tier' => 'personal']);
$this->keywordQuota->enforceForUser((int) $order->user_id, 'personal');
$this->subscriptionTransition->settleForActivatedPlan(
(int) $order->user_id,
'paypal',
$orderId,
'personal_lifetime'
);
return true;
}
private function hasActiveLifetimeSubscription(int $userId): bool
{
return Subscription::query()
->where('user_id', $userId)
->where('plan', 'personal_lifetime')
->where('status', 'active')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
}
}

View File

@@ -14,6 +14,7 @@ use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use App\Models\AdminAuditLog;
use App\Services\Billing\PayPalPlanSyncService;
use App\Services\Keywords\KeywordQuotaService;
use App\Services\System\SettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -26,7 +27,10 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class AdminDashboardController extends Controller
{
public function __construct(private readonly SettingsService $settings)
public function __construct(
private readonly SettingsService $settings,
private readonly KeywordQuotaService $keywordQuota
)
{
}
@@ -83,6 +87,7 @@ class AdminDashboardController extends Controller
if ($data['tier'] === 'free') {
UserApiKey::where('user_id', $data['user_id'])->update(['revoked_at' => now()]);
}
$this->keywordQuota->enforceForUser((int) $data['user_id'], (string) $data['tier']);
$this->logAdminAction('user_tier_update', $data);
return back()->with('status', 'User tier updated.');
@@ -220,6 +225,7 @@ class AdminDashboardController extends Controller
if ($data['status'] === 'active') {
$user->update(['tier' => 'personal']);
$this->keywordQuota->enforceForUser((int) $user->id, 'personal');
}
$this->logAdminAction('subscription_grant', [
@@ -550,6 +556,7 @@ class AdminDashboardController extends Controller
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
$this->keywordQuota->enforceForUser($userId, $active ? 'personal' : 'free');
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}

View File

@@ -0,0 +1,245 @@
<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\AdminAuditLog;
use App\Services\EmojiCatalog\EmojiCatalogService;
use App\Services\System\SettingsService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Throwable;
use Illuminate\View\View;
class AdminEmojiCatalogController extends Controller
{
public function __construct(
private readonly EmojiCatalogService $catalog,
private readonly SettingsService $settings
) {
}
public function index(Request $request): View
{
$q = trim((string) $request->query('q', ''));
$items = $this->listItems($q);
return view('dashboard.admin.catalog', [
'items' => $items,
'totalRows' => (int) DB::table('emojis')->count(),
'snapshots' => $this->catalog->listSnapshots(),
'activeVersion' => (string) $this->settings->get('emoji_dataset_active_version', ''),
'activePath' => (string) $this->settings->get('emoji_dataset_active_path', ''),
'filters' => ['q' => $q],
]);
}
public function create(Request $request): View
{
return view('dashboard.admin.catalog-form', [
'mode' => 'create',
'selected' => null,
]);
}
public function edit(Request $request, int $emojiId): View
{
$selected = $this->catalog->findItem($emojiId);
abort_if($selected === null, 404);
return view('dashboard.admin.catalog-form', [
'mode' => 'edit',
'selected' => $selected,
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $this->validateItemPayload($request);
unset($validated['emoji_id']);
try {
$emojiId = $this->catalog->saveItem($validated);
} catch (Throwable $e) {
report($e);
return back()->withInput()->with('error', $e->getMessage() ?: 'Failed to create catalog item.');
}
$this->logAdminAction($request, 'emoji_catalog_create', [
'emoji_id' => $emojiId,
'slug' => $validated['slug'],
]);
return redirect()->route('dashboard.admin.catalog.edit', ['emojiId' => $emojiId])
->with('status', 'Catalog item created.');
}
public function update(Request $request, int $emojiId): RedirectResponse
{
$validated = $this->validateItemPayload($request);
$validated['emoji_id'] = $emojiId;
try {
$savedId = $this->catalog->saveItem($validated);
} catch (Throwable $e) {
report($e);
return back()
->withInput()
->with('error', $e->getMessage() ?: 'Failed to save catalog item.');
}
$this->logAdminAction($request, 'emoji_catalog_upsert', [
'emoji_id' => $savedId,
'slug' => $validated['slug'],
]);
return back()->with('status', 'Catalog item updated.');
}
public function destroy(Request $request, int $emojiId): RedirectResponse
{
try {
$selected = $this->catalog->findItem($emojiId);
$this->catalog->deleteItem($emojiId);
} catch (Throwable $e) {
report($e);
return back()->with('error', $e->getMessage() ?: 'Failed to delete catalog item.');
}
$this->logAdminAction($request, 'emoji_catalog_delete', [
'emoji_id' => $emojiId,
'slug' => (string) ($selected['slug'] ?? ''),
]);
return back()->with('status', 'Catalog item deleted.');
}
public function importCurrentJson(Request $request): RedirectResponse
{
$path = (string) config('dewemoji.data_path');
try {
$result = $this->catalog->importFromDataFile($path);
} catch (Throwable $e) {
report($e);
return back()->with('error', $e->getMessage() ?: 'Failed to import dataset.');
}
$this->logAdminAction($request, 'emoji_catalog_import_json', [
'path' => $path,
'total' => $result['total'],
'imported' => $result['imported'],
'skipped' => $result['skipped'],
]);
return back()->with(
'status',
"Import completed. New rows: {$result['imported']}, skipped existing: {$result['skipped']}, total scanned: {$result['total']}."
);
}
public function publish(Request $request): RedirectResponse
{
try {
$result = $this->catalog->publishSnapshot($request->user()?->email);
} catch (Throwable $e) {
report($e);
return back()->with('error', $e->getMessage() ?: 'Failed to publish snapshot.');
}
$this->logAdminAction($request, 'emoji_catalog_publish', $result);
return back()->with('status', "Published snapshot version {$result['version']} ({$result['count']} emojis).");
}
public function activateSnapshot(Request $request): RedirectResponse
{
$validated = $request->validate([
'snapshot' => 'required|string',
]);
try {
$result = $this->catalog->activateSnapshot($validated['snapshot'], $request->user()?->email);
} catch (Throwable $e) {
report($e);
return back()->with('error', $e->getMessage() ?: 'Failed to activate snapshot.');
}
$this->logAdminAction($request, 'emoji_catalog_activate_snapshot', $result);
return back()->with('status', "Snapshot {$result['version']} is now active.");
}
/**
* @param array<string,mixed> $payload
*/
private function logAdminAction(Request $request, string $action, array $payload): void
{
$admin = $request->user();
if (!$admin) {
return;
}
AdminAuditLog::create([
'admin_id' => $admin->id,
'admin_email' => $admin->email,
'action' => $action,
'payload' => $payload,
'ip_address' => $request->ip(),
]);
}
private function listItems(string $q): LengthAwarePaginator
{
$itemsQuery = DB::table('emojis')->select([
'emoji_id',
'slug',
'emoji_char as emoji',
'name',
'category',
'subcategory',
'updated_at',
]);
if ($q !== '') {
$itemsQuery->where(function ($sub) use ($q): void {
$sub->where('slug', 'like', '%'.$q.'%')
->orWhere('name', 'like', '%'.$q.'%')
->orWhere('category', 'like', '%'.$q.'%')
->orWhere('subcategory', 'like', '%'.$q.'%');
});
}
return $itemsQuery->orderBy('emoji_id')->paginate(30)->withQueryString();
}
/**
* @return array<string,mixed>
*/
private function validateItemPayload(Request $request): array
{
return $request->validate([
'emoji_id' => 'nullable|integer',
'slug' => 'required|string|max:160',
'emoji' => 'nullable|string|max:32',
'name' => 'required|string|max:200',
'category' => 'nullable|string|max:120',
'subcategory' => 'nullable|string|max:120',
'aliases' => 'nullable|string',
'shortcodes' => 'nullable|string',
'alt_shortcodes' => 'nullable|string',
'keywords_en' => 'nullable|string',
'keywords_id' => 'nullable|string',
'description' => 'nullable|string',
'unified' => 'nullable|string|max:120',
'default_presentation' => 'nullable|string|max:16',
'version' => 'nullable|string|max:16',
'supports_skin_tone' => 'nullable|boolean',
'permalink' => 'nullable|string|max:255',
'title' => 'nullable|string|max:255',
'meta_title' => 'nullable|string|max:255',
'meta_description' => 'nullable|string|max:255',
]);
}
}

View File

@@ -12,6 +12,7 @@ use App\Models\UserKeyword;
use App\Models\WebhookEvent;
use App\Http\Controllers\Api\V1\EmojiApiController;
use App\Services\Auth\ApiKeyService;
use App\Services\Keywords\KeywordQuotaService;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
@@ -24,7 +25,8 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
class UserDashboardController extends Controller
{
public function __construct(
private readonly ApiKeyService $keys
private readonly ApiKeyService $keys,
private readonly KeywordQuotaService $keywordQuota
) {
}
@@ -128,6 +130,9 @@ class UserDashboardController extends Controller
$items = UserKeyword::where('user_id', $user?->id)
->orderByDesc('id')
->get();
$activeCount = UserKeyword::where('user_id', $user?->id)
->where('is_active', true)
->count();
$emojiLookup = [];
$dataPath = (string) config('dewemoji.data_path');
@@ -152,6 +157,7 @@ class UserDashboardController extends Controller
'user' => $user,
'emojiLookup' => $emojiLookup,
'freeLimit' => $this->keywordLimitFor($user),
'activeCount' => $activeCount,
]);
}
@@ -175,30 +181,35 @@ class UserDashboardController extends Controller
'lang' => 'nullable|string|max:10',
]);
if ($limit = $this->keywordLimitFor($user)) {
$exists = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->exists();
$existing = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $data['emoji_slug'])
->where('keyword', $data['keyword'])
->first();
$targetActive = $existing ? (bool) $existing->is_active : true;
if (!$exists) {
$count = UserKeyword::where('user_id', $user->id)->count();
if ($count >= $limit) {
return $this->rejectKeywordLimit($request, $limit);
if ($limit = $this->keywordLimitFor($user)) {
if (!$existing && $targetActive) {
$activeCount = $this->keywordQuota->activeCount((int) $user->id);
if ($activeCount >= $limit) {
return $this->rejectKeywordLimit($request, $limit, 'free_active_limit_reached');
}
}
}
$item = UserKeyword::updateOrCreate(
[
if ($existing) {
$existing->update([
'lang' => $data['lang'] ?? 'und',
]);
$item = $existing;
} else {
$item = UserKeyword::create([
'user_id' => $user->id,
'emoji_slug' => $data['emoji_slug'],
'keyword' => $data['keyword'],
],
[
'lang' => $data['lang'] ?? 'und',
]
);
'is_active' => true,
]);
}
if ($request->expectsJson()) {
return response()->json(['ok' => true, 'item' => $item]);
@@ -287,7 +298,7 @@ class UserDashboardController extends Controller
$imported = 0;
$skipped = 0;
$limit = $this->keywordLimitFor($user);
$current = UserKeyword::where('user_id', $user->id)->count();
$activeCount = $this->keywordQuota->activeCount((int) $user->id);
foreach ($items as $row) {
if (!is_array($row)) {
continue;
@@ -298,35 +309,39 @@ class UserDashboardController extends Controller
if ($emojiSlug === '' || $keyword === '') {
continue;
}
$exists = UserKeyword::where('user_id', $user->id)
$existing = UserKeyword::where('user_id', $user->id)
->where('emoji_slug', $emojiSlug)
->where('keyword', $keyword)
->exists();
->first();
$targetActive = filter_var($row['is_active'] ?? true, FILTER_VALIDATE_BOOL);
if (!$exists && $limit !== null && $current >= $limit) {
if (!$existing && $targetActive && $limit !== null && $activeCount >= $limit) {
$skipped += 1;
continue;
}
UserKeyword::updateOrCreate(
[
if ($existing) {
$existing->update([
'lang' => $lang !== '' ? $lang : 'und',
]);
} else {
UserKeyword::create([
'user_id' => $user->id,
'emoji_slug' => $emojiSlug,
'keyword' => $keyword,
],
[
'lang' => $lang !== '' ? $lang : 'und',
]
);
if (!$exists) {
$current += 1;
'is_active' => $targetActive,
]);
if ($targetActive) {
$activeCount += 1;
}
}
$imported += 1;
}
$message = "Imported {$imported} keywords.";
if ($skipped > 0) {
$message .= " {$skipped} skipped (free limit reached).";
$message .= " {$skipped} skipped (active free limit reached).";
}
return back()->with('status', $message);
@@ -341,7 +356,7 @@ class UserDashboardController extends Controller
$items = UserKeyword::where('user_id', $user->id)
->orderByDesc('id')
->get(['emoji_slug', 'keyword', 'lang'])
->get(['emoji_slug', 'keyword', 'lang', 'is_active'])
->values()
->toJson(JSON_PRETTY_PRINT);
@@ -429,13 +444,41 @@ class UserDashboardController extends Controller
return view('dashboard.user.preferences');
}
private function rejectKeywordLimit(Request $request, int $limit): RedirectResponse|JsonResponse
public function toggleKeywordActive(Request $request, UserKeyword $keyword): RedirectResponse|JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['ok' => false, 'error' => 'free_limit_reached', 'limit' => $limit], 403);
$user = $request->user();
if (!$user || $keyword->user_id !== $user->id) {
abort(403);
}
return back()->withErrors(['tier' => "Free plan limit reached ({$limit} keywords)."]);
$data = $request->validate([
'is_active' => 'required|boolean',
]);
$target = (bool) $data['is_active'];
if ($target && ($limit = $this->keywordLimitFor($user))) {
$activeCount = $this->keywordQuota->activeCount((int) $user->id);
if (!$keyword->is_active && $activeCount >= $limit) {
return $this->rejectKeywordLimit($request, $limit, 'free_active_limit_reached');
}
}
$keyword->update(['is_active' => $target]);
if ($request->expectsJson()) {
return response()->json(['ok' => true, 'item' => $keyword]);
}
return back()->with('status', $target ? 'Keyword activated.' : 'Keyword deactivated.');
}
private function rejectKeywordLimit(Request $request, int $limit, string $error = 'free_limit_reached'): RedirectResponse|JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['ok' => false, 'error' => $error, 'limit' => $limit], 403);
}
return back()->withErrors(['tier' => "Free plan active limit reached ({$limit} keywords)."]);
}
private function keywordLimitFor(?User $user): ?int

View File

@@ -3,7 +3,9 @@
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;
@@ -118,6 +120,7 @@ class SiteController extends Controller
public function pricing(): View
{
$user = request()->user();
$currencyPref = strtoupper((string) session('pricing_currency', ''));
if (!in_array($currencyPref, ['IDR', 'USD'], true)) {
$currencyPref = $this->detectPricingCurrency(request());
@@ -150,6 +153,37 @@ class SiteController extends Controller
$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,
@@ -164,6 +198,9 @@ class SiteController extends Controller
&& (string) config('dewemoji.billing.providers.pakasir.project', '') !== '',
'paypalEnabled' => $this->paypalEnabled($this->billingMode()),
'paypalPlans' => $this->paypalPlanAvailability($this->billingMode()),
'hasActiveLifetime' => $hasActiveLifetime,
'hasPendingPayment' => $hasPendingPayment,
'pendingCooldownRemaining' => $pendingCooldownRemaining,
]);
}
@@ -249,7 +286,7 @@ class SiteController extends Controller
public function emojiDetail(string $slug): View|Response
{
$dataPath = (string) config('dewemoji.data_path');
$dataPath = $this->datasetPath();
if (!is_file($dataPath)) {
abort(500, 'Emoji dataset file not found.');
}
@@ -298,13 +335,17 @@ class SiteController extends Controller
$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 && $userKeywords->count() >= $keywordLimit;
$limitReached = $keywordLimit !== null && $activeKeywordCount >= $keywordLimit;
return view('site.emoji-detail', [
'emoji' => $match,
@@ -314,6 +355,7 @@ class SiteController extends Controller
'canManageKeywords' => $canManageKeywords,
'keywordLimit' => $keywordLimit,
'limitReached' => $limitReached,
'activeKeywordCount' => $activeKeywordCount,
'userTier' => $user?->tier,
]);
}
@@ -389,7 +431,7 @@ class SiteController extends Controller
*/
private function loadDataset(): array
{
$dataPath = (string) config('dewemoji.data_path');
$dataPath = $this->datasetPath();
if (!is_file($dataPath)) {
return ['emojis' => []];
}
@@ -407,6 +449,17 @@ class SiteController extends Controller
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');
}
/**
* @param array<string,mixed> $emoji
*/

View File

@@ -12,6 +12,11 @@ class UserKeyword extends Model
'emoji_slug',
'keyword',
'lang',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function user(): BelongsTo

View File

@@ -2,12 +2,21 @@
namespace App\Services\Billing;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserApiKey;
use App\Services\Keywords\KeywordQuotaService;
class PaypalWebhookProcessor
{
public function __construct(
private readonly SubscriptionTransitionService $subscriptionTransition,
private readonly KeywordQuotaService $keywordQuota
) {
}
/**
* @param array<string,mixed> $payload
*/
@@ -31,16 +40,34 @@ class PaypalWebhookProcessor
}
if ($eventType === 'BILLING.SUBSCRIPTION.ACTIVATED') {
Subscription::create([
'user_id' => $user->id,
'plan' => 'personal',
'status' => 'active',
$order = Order::query()
->where('provider', 'paypal')
->where('provider_ref', $subscriptionId)
->latest('id')
->first();
$planCode = (string) ($order?->plan_code ?: 'personal_monthly');
Subscription::updateOrCreate([
'provider' => 'paypal',
'provider_ref' => $subscriptionId,
], [
'user_id' => $user->id,
'plan' => $planCode,
'status' => 'active',
'started_at' => now(),
'expires_at' => null,
'canceled_at' => null,
]);
Order::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
Payment::where('provider', 'paypal')->where('provider_ref', $subscriptionId)->update(['status' => 'paid']);
$user->update(['tier' => 'personal']);
$this->keywordQuota->enforceForUser((int) $user->id, 'personal');
$this->subscriptionTransition->settleForActivatedPlan(
(int) $user->id,
'paypal',
$subscriptionId,
$planCode
);
return;
}
@@ -50,7 +77,7 @@ class PaypalWebhookProcessor
->where('provider_ref', $subscriptionId)
->where('status', 'active')
->update([
'status' => $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'cancelled' : 'suspended',
'status' => $eventType === 'BILLING.SUBSCRIPTION.CANCELLED' ? 'canceled' : 'suspended',
'expires_at' => now(),
]);
@@ -71,6 +98,7 @@ class PaypalWebhookProcessor
User::where('id', $userId)->update([
'tier' => $active ? 'personal' : 'free',
]);
$this->keywordQuota->enforceForUser($userId, $active ? 'personal' : 'free');
if (!$active) {
UserApiKey::where('user_id', $userId)->update(['revoked_at' => now()]);
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Services\Billing;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Subscription;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SubscriptionTransitionService
{
/**
* Cancel all pending checkout state for a user across providers.
*/
public function cancelPendingForUser(int $userId): void
{
Subscription::query()
->where('user_id', $userId)
->where('status', 'pending')
->update([
'status' => 'canceled',
'canceled_at' => now(),
]);
Order::query()
->where('user_id', $userId)
->where('status', 'pending')
->update(['status' => 'canceled']);
Payment::query()
->where('user_id', $userId)
->where('status', 'pending')
->update(['status' => 'canceled']);
}
/**
* Cancel other active/pending subscriptions when a new plan has been activated.
*/
public function settleForActivatedPlan(
int $userId,
string $activeProvider,
string $activeProviderRef,
string $newPlanCode
): void {
$others = Subscription::query()
->where('user_id', $userId)
->whereIn('status', ['active', 'pending'])
->where(function ($query) use ($activeProvider, $activeProviderRef) {
$query->where('provider', '!=', $activeProvider)
->orWhere('provider_ref', '!=', $activeProviderRef);
})
->get();
if ($others->isEmpty()) {
return;
}
$mode = $this->resolvePaypalMode((string) config('dewemoji.billing.mode', 'sandbox'));
foreach ($others as $sub) {
if ((string) $sub->plan === 'personal_lifetime') {
// Never auto-cancel lifetime ownership on later recurring checkouts.
continue;
}
if ($sub->status === 'pending') {
$sub->status = 'canceled';
$sub->canceled_at = now();
$sub->save();
continue;
}
// Lifetime is one-time ownership; cancel any recurring subscription.
$isRecurring = in_array((string) $sub->plan, ['personal_monthly', 'personal_annual'], true);
if ($sub->provider === 'paypal' && $isRecurring && $sub->provider_ref) {
$cancelled = $this->cancelPaypalSubscription($mode, (string) $sub->provider_ref);
if (!$cancelled) {
Log::warning('Could not cancel previous PayPal subscription during plan transition', [
'user_id' => $userId,
'new_plan' => $newPlanCode,
'old_subscription_id' => $sub->provider_ref,
]);
continue;
}
}
$sub->status = 'canceled';
$sub->canceled_at = now();
$sub->save();
}
}
private function cancelPaypalSubscription(string $mode, string $subscriptionId): bool
{
$token = $this->getAccessToken($mode);
if (!$token) {
return false;
}
$apiBase = (string) config("dewemoji.billing.providers.paypal.{$mode}.api_base");
if ($apiBase === '') {
return false;
}
$res = Http::withToken($token)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim($apiBase, '/')."/v1/billing/subscriptions/{$subscriptionId}/cancel", [
'reason' => 'Switched plan in Dewemoji',
]);
// PayPal may return 204 on success, and 404 if already cancelled/not found.
return in_array($res->status(), [200, 204, 404], true);
}
private function getAccessToken(string $mode): ?string
{
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
if (!$clientId || !$clientSecret || !$apiBase) {
return null;
}
$res = Http::asForm()
->withBasicAuth($clientId, $clientSecret)
->timeout((int) config('dewemoji.billing.providers.paypal.timeout', 10))
->post(rtrim((string) $apiBase, '/').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if (!$res->successful()) {
return null;
}
return $res->json('access_token');
}
private function paypalConfigured(string $mode): bool
{
$enabled = (bool) config('dewemoji.billing.providers.paypal.enabled', false);
$clientId = config("dewemoji.billing.providers.paypal.{$mode}.client_id");
$clientSecret = config("dewemoji.billing.providers.paypal.{$mode}.client_secret");
$apiBase = config("dewemoji.billing.providers.paypal.{$mode}.api_base");
return $enabled && $clientId && $clientSecret && $apiBase;
}
private function resolvePaypalMode(string $preferred): string
{
if ($this->paypalConfigured($preferred)) {
return $preferred;
}
$fallback = $preferred === 'live' ? 'sandbox' : 'live';
if ($this->paypalConfigured($fallback)) {
return $fallback;
}
return $preferred;
}
}

View File

@@ -0,0 +1,592 @@
<?php
namespace App\Services\EmojiCatalog;
use App\Services\System\SettingsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class EmojiCatalogService
{
public function __construct(
private readonly SettingsService $settings
) {
}
/**
* @return array<string,mixed>|null
*/
public function findItem(int $emojiId): ?array
{
$base = DB::table('emojis')->where('emoji_id', $emojiId)->first();
if (!$base) {
return null;
}
return $this->hydrateEditorItem((array) $base);
}
public function nextEmojiId(): int
{
return (int) DB::table('emojis')->max('emoji_id') + 1;
}
/**
* @param array<string,mixed> $input
*/
public function saveItem(array $input): int
{
$payload = $this->normalizeItemPayload($input);
if ($payload['slug'] === '' || $payload['name'] === '') {
throw new RuntimeException('Slug and name are required.');
}
$incomingId = (int) ($input['emoji_id'] ?? 0);
$existingBySlug = DB::table('emojis')->where('slug', $payload['slug'])->first();
if ($incomingId > 0) {
if ($existingBySlug && (int) $existingBySlug->emoji_id !== $incomingId) {
throw new RuntimeException('Slug is already used by another emoji.');
}
$emojiId = $incomingId;
} else {
$emojiId = (int) ($existingBySlug->emoji_id ?? 0);
if ($emojiId <= 0) {
$emojiId = $this->nextEmojiId();
}
}
DB::transaction(function () use ($emojiId, $payload): void {
$exists = DB::table('emojis')->where('emoji_id', $emojiId)->exists();
$base = [
'emoji_id' => $emojiId,
'slug' => $payload['slug'],
'emoji_char' => $payload['emoji'],
'name' => $payload['name'],
'category' => $payload['category'],
'subcategory' => $payload['subcategory'],
'unified' => $payload['unified'],
'default_presentation' => $payload['default_presentation'] ?: 'emoji',
'version' => $payload['version'] ?: 'custom',
'supports_skin_tone' => (bool) $payload['supports_skin_tone'],
'permalink' => $payload['permalink'] ?: '/emoji/'.$payload['slug'],
'description' => $payload['description'],
'meta_title' => $payload['meta_title'],
'meta_description' => $payload['meta_description'],
'title' => $payload['title'],
'updated_at' => now(),
];
if ($exists) {
DB::table('emojis')->where('emoji_id', $emojiId)->update($base);
} else {
$base['created_at'] = now();
DB::table('emojis')->insert($base);
}
DB::table('emoji_aliases')->where('emoji_id', $emojiId)->delete();
foreach ($payload['aliases'] as $alias) {
DB::table('emoji_aliases')->insert([
'emoji_id' => $emojiId,
'alias' => $alias,
]);
}
DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->delete();
foreach ($payload['shortcodes'] as $shortcode) {
DB::table('emoji_shortcodes')->insert([
'emoji_id' => $emojiId,
'shortcode' => $shortcode,
'kind' => 'primary',
]);
}
foreach ($payload['alt_shortcodes'] as $shortcode) {
DB::table('emoji_shortcodes')->insert([
'emoji_id' => $emojiId,
'shortcode' => $shortcode,
'kind' => 'alt',
]);
}
DB::table('emoji_keywords')
->where('emoji_slug', $payload['slug'])
->where(function ($query) {
$query->whereNull('owner_user_id')
->orWhere('owner_user_id', '');
})
->delete();
foreach ($payload['keywords_en'] as $keyword) {
DB::table('emoji_keywords')->insert([
'emoji_slug' => $payload['slug'],
'lang' => 'en',
'region' => '',
'keyword' => $keyword,
'status' => 'public',
'owner_user_id' => null,
'visibility' => 'public',
'public_key' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
foreach ($payload['keywords_id'] as $keyword) {
DB::table('emoji_keywords')->insert([
'emoji_slug' => $payload['slug'],
'lang' => 'id',
'region' => '',
'keyword' => $keyword,
'status' => 'public',
'owner_user_id' => null,
'visibility' => 'public',
'public_key' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
});
return $emojiId;
}
public function deleteItem(int $emojiId): void
{
$emoji = DB::table('emojis')->where('emoji_id', $emojiId)->first();
if (!$emoji) {
return;
}
DB::transaction(function () use ($emojiId, $emoji): void {
DB::table('emoji_aliases')->where('emoji_id', $emojiId)->delete();
DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->delete();
DB::table('emoji_keywords')
->where('emoji_slug', (string) $emoji->slug)
->where(function ($query) {
$query->whereNull('owner_user_id')->orWhere('owner_user_id', '');
})
->delete();
DB::table('emojis')->where('emoji_id', $emojiId)->delete();
});
}
/**
* Import from JSON in non-destructive mode.
* Existing rows (matched by slug or emoji_id) are kept as-is.
*
* @return array{total:int,imported:int,skipped:int}
*/
public function importFromDataFile(string $path): array
{
if (!is_file($path)) {
throw new RuntimeException('Dataset file not found: '.$path);
}
$raw = file_get_contents($path);
if ($raw === false) {
throw new RuntimeException('Could not read dataset file.');
}
$decoded = json_decode($raw, true);
if (!is_array($decoded) || !is_array($decoded['emojis'] ?? null)) {
throw new RuntimeException('Invalid emoji dataset format.');
}
$rows = $decoded['emojis'];
$total = 0;
$imported = 0;
$skipped = 0;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$total++;
$slug = $this->slugify((string) ($row['slug'] ?? ''));
$emojiId = (int) ($row['emoji_id'] ?? 0);
if ($slug !== '' && DB::table('emojis')->where('slug', $slug)->exists()) {
$skipped++;
continue;
}
if ($emojiId > 0 && DB::table('emojis')->where('emoji_id', $emojiId)->exists()) {
$skipped++;
continue;
}
$this->saveItem($row);
$imported++;
}
return [
'total' => $total,
'imported' => $imported,
'skipped' => $skipped,
];
}
/**
* @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function normalizeItemPayload(array $input): array
{
$slug = $this->slugify((string) ($input['slug'] ?? ''));
$name = trim((string) ($input['name'] ?? ''));
$emoji = trim((string) ($input['emoji'] ?? ''));
$category = trim((string) ($input['category'] ?? ''));
$subcategory = trim((string) ($input['subcategory'] ?? ''));
$description = trim((string) ($input['description'] ?? ''));
$permalink = trim((string) ($input['permalink'] ?? ''));
if ($permalink === '' && $slug !== '') {
$permalink = '/emoji/'.$slug;
}
$title = trim((string) ($input['title'] ?? ''));
if ($title === '' && $name !== '') {
$title = ($emoji !== '' ? $emoji.' ' : '').$name.' — meaning & copy';
}
$metaTitle = trim((string) ($input['meta_title'] ?? ''));
if ($metaTitle === '' && $name !== '') {
$metaTitle = ($emoji !== '' ? $emoji.' ' : '').$name.' | Meaning & Copy';
}
$metaDescription = trim((string) ($input['meta_description'] ?? ''));
if ($metaDescription === '' && $description !== '') {
$metaDescription = Str::limit($description, 160, '…');
}
$keywordsEn = $this->normalizeArray($input['keywords_en'] ?? []);
$keywordsId = $this->normalizeArray($input['keywords_id'] ?? []);
$aliases = $this->normalizeArray($input['aliases'] ?? []);
$shortcodes = $this->normalizeArray($input['shortcodes'] ?? []);
$altShortcodes = $this->normalizeArray($input['alt_shortcodes'] ?? []);
return [
'emoji_id' => isset($input['emoji_id']) ? (int) $input['emoji_id'] : null,
'slug' => $slug,
'emoji' => $emoji,
'name' => $name,
'category' => $category,
'subcategory' => $subcategory,
'aliases' => $aliases,
'shortcodes' => $shortcodes,
'alt_shortcodes' => $altShortcodes,
'keywords_en' => $keywordsEn,
'keywords_id' => $keywordsId,
'usage_examples' => [],
'description' => $description,
'codepoints' => [],
'unified' => trim((string) ($input['unified'] ?? '')),
'default_presentation' => trim((string) ($input['default_presentation'] ?? '')),
'version' => trim((string) ($input['version'] ?? '')),
'supports_skin_tone' => filter_var($input['supports_skin_tone'] ?? false, FILTER_VALIDATE_BOOL),
'related' => [],
'intent_tags' => [],
'search_tokens' => [],
'permalink' => $permalink,
'title' => $title,
'meta_title' => $metaTitle,
'meta_description' => $metaDescription,
];
}
/**
* @return array<string,mixed>
*/
public function publishSnapshot(?string $createdBy = null): array
{
$emojiRows = DB::table('emojis')->orderBy('emoji_id')->get();
$slugs = $emojiRows->pluck('slug')->filter()->values()->all();
$keywords = DB::table('emoji_keywords')
->whereIn('emoji_slug', $slugs)
->where(function ($query) {
$query->whereNull('owner_user_id')->orWhere('owner_user_id', '');
})
->select(['emoji_slug', 'lang', 'keyword'])
->get()
->groupBy('emoji_slug');
$aliases = DB::table('emoji_aliases')
->whereIn('emoji_id', $emojiRows->pluck('emoji_id')->all())
->select(['emoji_id', 'alias'])
->get()
->groupBy('emoji_id');
$shortcodes = DB::table('emoji_shortcodes')
->whereIn('emoji_id', $emojiRows->pluck('emoji_id')->all())
->select(['emoji_id', 'shortcode', 'kind'])
->get()
->groupBy('emoji_id');
$payload = [
'@version' => (string) ($emojiRows->first()->version ?? 'custom'),
'@source' => 'db:emojis(+aliases,+keywords,+shortcodes)',
'count' => $emojiRows->count(),
'emojis' => $emojiRows->map(function ($item) use ($aliases, $shortcodes, $keywords): array {
$itemAliases = $aliases->get($item->emoji_id, collect())->pluck('alias')->filter()->values()->all();
$itemShortcodes = $shortcodes->get($item->emoji_id, collect());
$primaryShortcodes = $itemShortcodes->where('kind', 'primary')->pluck('shortcode')->filter()->values()->all();
$altShortcodes = $itemShortcodes->where('kind', 'alt')->pluck('shortcode')->filter()->values()->all();
$itemKeywords = $keywords->get($item->slug, collect());
$keywordsEn = $itemKeywords->where('lang', 'en')->pluck('keyword')->filter()->values()->all();
$keywordsId = $itemKeywords->where('lang', 'id')->pluck('keyword')->filter()->values()->all();
return [
'emoji' => (string) $item->emoji_char,
'slug' => (string) $item->slug,
'permalink' => (string) ($item->permalink ?: '/emoji/'.$item->slug),
'title' => (string) ($item->title ?: (((string) $item->emoji_char !== '' ? $item->emoji_char.' ' : '').$item->name.' — meaning & copy')),
'meta_title' => (string) ($item->meta_title ?: (((string) $item->emoji_char !== '' ? $item->emoji_char.' ' : '').$item->name.' | Meaning & Copy')),
'meta_description' => (string) ($item->meta_description ?: Str::limit((string) ($item->description ?? ''), 160, '…')),
'name' => (string) $item->name,
'aliases' => $itemAliases,
'shortcodes' => $primaryShortcodes,
'alt_shortcodes' => $altShortcodes,
'keywords_en' => $keywordsEn,
'keywords_id' => $keywordsId,
'usage_examples' => [],
'description' => (string) ($item->description ?? ''),
'category' => (string) $item->category,
'subcategory' => (string) $item->subcategory,
'codepoints' => [],
'unified' => (string) $item->unified,
'default_presentation' => (string) ($item->default_presentation ?: 'emoji'),
'version' => (string) ($item->version ?: 'custom'),
'supports_skin_tone' => (bool) $item->supports_skin_tone,
'related' => [],
'intent_tags' => [],
'search_tokens' => $this->buildSearchTokens(
(string) $item->emoji_char,
(string) $item->slug,
(string) $item->name,
(string) $item->category,
(string) $item->subcategory,
$keywordsEn,
$keywordsId,
$itemAliases,
$primaryShortcodes,
$altShortcodes,
[],
[]
),
];
})->values()->all(),
];
$snapshotDir = $this->snapshotDirectory();
if (!is_dir($snapshotDir) && !mkdir($snapshotDir, 0775, true) && !is_dir($snapshotDir)) {
throw new RuntimeException('Could not create snapshot directory.');
}
$version = now()->format('YmdHis');
$filename = 'emojis-'.$version.'.json';
$fullPath = $snapshotDir.'/'.$filename;
$written = file_put_contents($fullPath, json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if ($written === false) {
throw new RuntimeException('Could not write snapshot file.');
}
DB::transaction(function () use ($version, $fullPath, $createdBy): void {
$this->settings->setMany([
'emoji_dataset_active_path' => $fullPath,
'emoji_dataset_active_version' => $version,
'emoji_dataset_last_published_at' => now()->toIso8601String(),
], $createdBy);
});
return [
'snapshot_id' => null,
'version' => $version,
'file_path' => $fullPath,
'count' => $emojiRows->count(),
];
}
/**
* @return array<int,array{name:string,version:string,path:string,is_active:bool,modified_at:int}>
*/
public function listSnapshots(): array
{
$dir = $this->snapshotDirectory();
if (!is_dir($dir)) {
return [];
}
$activePath = (string) $this->settings->get('emoji_dataset_active_path', '');
$files = glob($dir.'/emojis-*.json') ?: [];
$items = [];
foreach ($files as $file) {
if (!is_file($file)) {
continue;
}
$name = basename($file);
if (!preg_match('/^emojis-(\d{14})\.json$/', $name, $m)) {
continue;
}
$items[] = [
'name' => $name,
'version' => $m[1],
'path' => $file,
'is_active' => $activePath !== '' && realpath($activePath) === realpath($file),
'modified_at' => (int) @filemtime($file),
];
}
usort($items, static function (array $a, array $b): int {
return $b['version'] <=> $a['version'];
});
return $items;
}
public function activateSnapshot(string $filename, ?string $updatedBy = null): array
{
if (!preg_match('/^emojis-(\d{14})\.json$/', $filename, $m)) {
throw new RuntimeException('Invalid snapshot filename.');
}
$fullPath = $this->snapshotDirectory().'/'.$filename;
if (!is_file($fullPath)) {
throw new RuntimeException('Snapshot file not found.');
}
$version = $m[1];
$this->settings->setMany([
'emoji_dataset_active_path' => $fullPath,
'emoji_dataset_active_version' => $version,
'emoji_dataset_last_published_at' => now()->toIso8601String(),
], $updatedBy);
return [
'version' => $version,
'file_path' => $fullPath,
];
}
/**
* @param array<string,mixed> $base
* @return array<string,mixed>
*/
private function hydrateEditorItem(array $base): array
{
$emojiId = (int) ($base['emoji_id'] ?? 0);
$slug = (string) ($base['slug'] ?? '');
$aliases = DB::table('emoji_aliases')->where('emoji_id', $emojiId)->pluck('alias')->filter()->values()->all();
$sc = DB::table('emoji_shortcodes')->where('emoji_id', $emojiId)->get(['shortcode', 'kind']);
$keywords = DB::table('emoji_keywords')
->where('emoji_slug', $slug)
->where(function ($query) {
$query->whereNull('owner_user_id')->orWhere('owner_user_id', '');
})
->get(['lang', 'keyword']);
return [
'emoji_id' => $emojiId,
'slug' => $slug,
'emoji' => (string) ($base['emoji_char'] ?? ''),
'name' => (string) ($base['name'] ?? ''),
'category' => (string) ($base['category'] ?? ''),
'subcategory' => (string) ($base['subcategory'] ?? ''),
'aliases' => $aliases,
'shortcodes' => $sc->where('kind', 'primary')->pluck('shortcode')->filter()->values()->all(),
'alt_shortcodes' => $sc->where('kind', 'alt')->pluck('shortcode')->filter()->values()->all(),
'keywords_en' => $keywords->where('lang', 'en')->pluck('keyword')->filter()->values()->all(),
'keywords_id' => $keywords->where('lang', 'id')->pluck('keyword')->filter()->values()->all(),
'usage_examples' => [],
'description' => (string) ($base['description'] ?? ''),
'codepoints' => [],
'unified' => (string) ($base['unified'] ?? ''),
'default_presentation' => (string) ($base['default_presentation'] ?? ''),
'version' => (string) ($base['version'] ?? ''),
'supports_skin_tone' => (bool) ($base['supports_skin_tone'] ?? false),
'related' => [],
'intent_tags' => [],
'search_tokens' => [],
'permalink' => (string) ($base['permalink'] ?? ''),
'title' => (string) ($base['title'] ?? ''),
'meta_title' => (string) ($base['meta_title'] ?? ''),
'meta_description' => (string) ($base['meta_description'] ?? ''),
'is_active' => true,
'sort_order' => 0,
];
}
/**
* @param mixed $input
* @return array<int,string>
*/
private function normalizeArray(mixed $input): array
{
if (is_string($input)) {
$input = preg_split('/\r\n|\r|\n|,/', $input) ?: [];
}
if (!is_array($input)) {
return [];
}
$out = [];
foreach ($input as $value) {
$v = trim((string) $value);
if ($v !== '') {
$out[] = $v;
}
}
return array_values(array_unique($out));
}
private function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = str_replace('&', 'and', $value);
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
return trim($value, '-');
}
private function snapshotDirectory(): string
{
return storage_path('app/private/snapshots');
}
/**
* @param array<int,string> $keywordsEn
* @param array<int,string> $keywordsId
* @param array<int,string> $aliases
* @param array<int,string> $shortcodes
* @param array<int,string> $altShortcodes
* @param array<int,string> $intentTags
* @param array<int,string> $codepoints
* @return array<int,string>
*/
private function buildSearchTokens(
string $emoji,
string $slug,
string $name,
string $category,
string $subcategory,
array $keywordsEn,
array $keywordsId,
array $aliases,
array $shortcodes,
array $altShortcodes,
array $intentTags,
array $codepoints
): array {
$tokens = [
$emoji,
$slug,
$name,
strtolower($name),
strtolower($category),
strtolower($subcategory),
];
$tokens = array_merge($tokens, $keywordsEn, $keywordsId, $aliases, $shortcodes, $altShortcodes, $intentTags, $codepoints);
$cleaned = [];
foreach ($tokens as $token) {
$t = trim((string) $token);
if ($t !== '') {
$cleaned[] = $t;
}
}
return array_values(array_unique($cleaned));
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Services\Keywords;
use App\Models\UserKeyword;
class KeywordQuotaService
{
public function freeActiveLimit(): int
{
return max((int) config('dewemoji.pagination.free_max_limit', 20), 1);
}
public function activeCount(int $userId): int
{
return UserKeyword::query()
->where('user_id', $userId)
->where('is_active', true)
->count();
}
public function canActivateMore(int $userId): bool
{
return $this->activeCount($userId) < $this->freeActiveLimit();
}
public function enforceForUser(int $userId, string $tier): void
{
if ($tier === 'personal') {
UserKeyword::query()
->where('user_id', $userId)
->where('is_active', false)
->update(['is_active' => true]);
return;
}
$limit = $this->freeActiveLimit();
$activeIds = UserKeyword::query()
->where('user_id', $userId)
->where('is_active', true)
->orderBy('created_at')
->orderBy('id')
->pluck('id')
->all();
if (count($activeIds) <= $limit) {
return;
}
$keep = array_slice($activeIds, 0, $limit);
UserKeyword::query()
->where('user_id', $userId)
->where('is_active', true)
->whereNotIn('id', $keep)
->update(['is_active' => false]);
}
}

View File

@@ -12,6 +12,7 @@ return [
'billing' => [
'mode' => env('DEWEMOJI_BILLING_MODE', 'sandbox'),
'pending_cooldown_seconds' => (int) env('DEWEMOJI_CHECKOUT_PENDING_COOLDOWN', 120),
'providers' => [
'paypal' => [
'enabled' => filter_var(env('DEWEMOJI_PAYPAL_ENABLED', false), FILTER_VALIDATE_BOOL),

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('user_keywords', function (Blueprint $table): void {
$table->boolean('is_active')->default(true)->after('lang');
$table->index(['user_id', 'is_active']);
});
}
public function down(): void
{
Schema::table('user_keywords', function (Blueprint $table): void {
$table->dropIndex(['user_id', 'is_active']);
$table->dropColumn('is_active');
});
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('emojis')) {
return;
}
$duplicates = DB::table('emojis')
->select('slug', DB::raw('COUNT(*) as aggregate'))
->groupBy('slug')
->havingRaw('COUNT(*) > 1')
->limit(5)
->pluck('slug')
->all();
if (!empty($duplicates)) {
throw new \RuntimeException(
'Cannot add unique index on emojis.slug because duplicate slugs exist: '.implode(', ', $duplicates)
);
}
Schema::table('emojis', function (Blueprint $table): void {
$table->unique('slug', 'emojis_slug_unique');
});
}
public function down(): void
{
if (!Schema::hasTable('emojis')) {
return;
}
Schema::table('emojis', function (Blueprint $table): void {
$table->dropUnique('emojis_slug_unique');
});
}
};

View File

@@ -0,0 +1,129 @@
@extends('dashboard.app')
@php
$isEdit = ($mode ?? 'create') === 'edit';
$formAction = $isEdit
? route('dashboard.admin.catalog.update', data_get($selected, 'emoji_id'))
: route('dashboard.admin.catalog.store');
$textareas = [
'aliases' => data_get($selected, 'aliases', []),
'shortcodes' => data_get($selected, 'shortcodes', []),
'alt_shortcodes' => data_get($selected, 'alt_shortcodes', []),
'keywords_en' => data_get($selected, 'keywords_en', []),
'keywords_id' => data_get($selected, 'keywords_id', []),
];
@endphp
@section('page_title', $isEdit ? 'Edit Emoji' : 'Create Emoji')
@section('page_subtitle', 'Edit/add emoji data in database. Publish frozen JSON from Catalog page after batch updates.')
@section('dashboard_content')
@if (session('status'))
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
{{ session('status') }}
</div>
@endif
@if (session('error'))
<div class="mb-6 rounded-2xl border border-red-300/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-200">
{{ session('error') }}
</div>
@endif
@if ($errors->any())
<div class="mb-6 rounded-2xl border border-amber-300/40 bg-amber-400/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-200">
{{ $errors->first() }}
</div>
@endif
<div class="rounded-2xl glass-card p-5">
<form method="POST" action="{{ $formAction }}" class="grid gap-4">
@csrf
@if ($isEdit)
@method('PUT')
@endif
<div class="grid gap-4 md:grid-cols-3">
<label class="text-sm text-slate-700 dark:text-gray-300">
Emoji
<input name="emoji" value="{{ old('emoji', data_get($selected, 'emoji', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300 md:col-span-2">
Slug
<input name="slug" value="{{ old('slug', data_get($selected, 'slug', '')) }}" required class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
</div>
<div class="grid gap-4 md:grid-cols-2">
<label class="text-sm text-slate-700 dark:text-gray-300">
Name
<input name="name" value="{{ old('name', data_get($selected, 'name', '')) }}" required class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300">
Category
<input name="category" value="{{ old('category', data_get($selected, 'category', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300">
Subcategory
<input name="subcategory" value="{{ old('subcategory', data_get($selected, 'subcategory', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300">
Unified
<input name="unified" value="{{ old('unified', data_get($selected, 'unified', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300">
Version
<input name="version" value="{{ old('version', data_get($selected, 'version', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
</div>
<div class="grid gap-4 md:grid-cols-2">
@foreach ($textareas as $field => $values)
<label class="text-sm text-slate-700 dark:text-gray-300">
{{ strtoupper(str_replace('_', ' ', $field)) }} (comma or line-separated)
<textarea name="{{ $field }}" rows="3" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">{{ old($field, is_array($values) ? implode(PHP_EOL, $values) : '') }}</textarea>
</label>
@endforeach
</div>
<label class="text-sm text-slate-700 dark:text-gray-300">
Description
<textarea name="description" rows="3" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">{{ old('description', data_get($selected, 'description', '')) }}</textarea>
</label>
<div class="grid gap-4 md:grid-cols-2">
<label class="text-sm text-slate-700 dark:text-gray-300">
Permalink
<input name="permalink" value="{{ old('permalink', data_get($selected, 'permalink', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300">
Title
<input name="title" value="{{ old('title', data_get($selected, 'title', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300">
Meta title
<input name="meta_title" value="{{ old('meta_title', data_get($selected, 'meta_title', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
<label class="text-sm text-slate-700 dark:text-gray-300">
Meta description
<input name="meta_description" value="{{ old('meta_description', data_get($selected, 'meta_description', '')) }}" class="mt-2 w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-slate-900 dark:text-slate-100 theme-surface bg-white dark:bg-slate-900 placeholder:text-slate-400 dark:placeholder:text-slate-500">
</label>
</div>
<div class="flex flex-wrap items-center justify-between gap-3">
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-gray-300">
<input type="hidden" name="supports_skin_tone" value="0">
<input type="checkbox" name="supports_skin_tone" value="1" @checked(old('supports_skin_tone', data_get($selected, 'supports_skin_tone', false)))>
<span>Supports skin tone</span>
</label>
<div class="flex items-center gap-2">
<a href="{{ route('dashboard.admin.catalog') }}" class="rounded-xl border border-slate-200 dark:border-slate-700 px-4 py-2 text-sm text-slate-700 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-white/5">Back to catalog</a>
<button class="rounded-xl bg-slate-900 dark:bg-white/10 border border-slate-900 dark:border-slate-700 px-4 py-2 text-sm font-semibold text-white force-white hover:opacity-90">
{{ $isEdit ? 'Update emoji' : 'Create emoji' }}
</button>
</div>
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,175 @@
@extends('dashboard.app')
@section('page_title', 'Emoji Catalog')
@section('page_subtitle', 'Manage emojis in database, then publish one frozen JSON snapshot when ready.')
@section('dashboard_content')
@if (session('status'))
<div class="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200">
{{ session('status') }}
</div>
@endif
@if (session('error'))
<div class="mb-6 rounded-2xl border border-red-300/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-200">
{{ session('error') }}
</div>
@endif
<div class="grid gap-6 lg:grid-cols-3">
<div class="rounded-2xl glass-card p-5">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Catalog rows</div>
<div class="mt-3 text-3xl font-semibold text-slate-900 dark:text-white">{{ number_format($totalRows ?? $items->total()) }}</div>
<div class="mt-2 text-sm text-slate-600 dark:text-gray-400">Read from `emojis` table</div>
@if (($filters['q'] ?? '') !== '')
<div class="mt-1 text-xs text-slate-500 dark:text-gray-500">Filtered result: {{ number_format($items->total()) }}</div>
@endif
</div>
<div class="rounded-2xl glass-card p-5">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Active snapshot</div>
<div class="mt-3 text-2xl font-semibold text-slate-900 dark:text-white">{{ $activeVersion ?: 'None' }}</div>
<div class="mt-2 text-sm text-slate-600 dark:text-gray-400">{{ $activeVersion ? 'Published' : 'Not published yet' }}</div>
</div>
<div class="rounded-2xl glass-card p-5">
<div class="text-xs uppercase tracking-[0.2em] text-slate-500 dark:text-gray-400">Active file path</div>
<div class="mt-3 text-sm font-mono text-slate-800 dark:text-gray-200 break-all">{{ $activePath ?: config('dewemoji.data_path') }}</div>
<div class="mt-2 text-xs text-slate-500 dark:text-gray-500">Public search/API uses this dataset.</div>
</div>
</div>
<div class="mt-8 rounded-2xl glass-card p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<form method="GET" action="{{ route('dashboard.admin.catalog') }}" class="flex flex-wrap items-center gap-2">
<input
type="text"
name="q"
value="{{ $filters['q'] ?? '' }}"
placeholder="Search slug, name, category, subcategory"
class="w-72 max-w-full rounded-xl border border-slate-200 dark:border-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 bg-white dark:bg-slate-900"
>
<button class="rounded-xl border border-slate-200 dark:border-white/10 px-3 py-2 text-xs text-slate-700 dark:text-gray-200 hover:bg-slate-50 dark:hover:bg-white/5">Search</button>
<a href="{{ route('dashboard.admin.catalog') }}" class="rounded-xl border border-slate-200 dark:border-white/10 px-3 py-2 text-xs text-slate-700 dark:text-gray-200 hover:bg-slate-50 dark:hover:bg-white/5">Reset</a>
</form>
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('dashboard.admin.catalog.create') }}" class="rounded-xl bg-slate-900 dark:bg-white/10 border border-slate-900 dark:border-white/10 px-4 py-2 text-sm font-semibold text-white force-white dark:text-white hover:opacity-90">
Add Emoji
</a>
<form method="POST" action="{{ route('dashboard.admin.catalog.publish') }}">
@csrf
<button class="rounded-xl bg-brand-ocean px-4 py-2 text-sm font-semibold text-white force-white hover:opacity-90">Publish Frozen JSON</button>
</form>
<form method="POST" action="{{ route('dashboard.admin.catalog.import_json') }}">
@csrf
<button class="rounded-xl border border-slate-200 dark:border-white/10 px-3 py-2 text-xs text-slate-700 dark:text-gray-200 hover:bg-slate-50 dark:hover:bg-white/5">Import Current JSON (new only)</button>
</form>
</div>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200 dark:border-white/10">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-900/80">
<tr class="text-left text-xs uppercase tracking-[0.16em] text-slate-500 dark:text-gray-400">
<th class="px-3 py-3">ID</th>
<th class="px-3 py-3">Emoji</th>
<th class="px-3 py-3">Slug</th>
<th class="px-3 py-3">Name</th>
<th class="px-3 py-3">Category</th>
<th class="px-3 py-3">Updated</th>
<th class="px-3 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
@forelse ($items as $item)
<tr class="border-t border-slate-200 dark:border-white/10 text-slate-800 dark:text-gray-200">
<td class="px-3 py-3">#{{ $item->emoji_id }}</td>
<td class="px-3 py-3">{{ $item->emoji ?: '⬚' }}</td>
<td class="px-3 py-3 font-mono text-xs">{{ $item->slug }}</td>
<td class="px-3 py-3">{{ $item->name }}</td>
<td class="px-3 py-3 text-slate-600 dark:text-gray-400">{{ $item->category }}</td>
<td class="px-3 py-3 text-xs text-slate-500 dark:text-gray-400">{{ $item->updated_at ? \Illuminate\Support\Carbon::parse($item->updated_at)->format('Y-m-d H:i') : '—' }}</td>
<td class="px-3 py-3">
<div class="flex items-center justify-end gap-2">
<a href="{{ route('dashboard.admin.catalog.edit', ['emojiId' => $item->emoji_id]) }}" class="rounded-lg border border-slate-300 dark:border-white/10 px-3 py-1.5 text-xs text-slate-700 dark:text-gray-100 hover:bg-slate-50 dark:hover:bg-white/10">Edit</a>
<form method="POST" action="{{ route('dashboard.admin.catalog.delete', $item->emoji_id) }}" onsubmit="return confirm('Delete this emoji and related public records?');">
@csrf
@method('DELETE')
<button class="rounded-lg border border-red-300/50 bg-red-50 dark:bg-red-500/10 px-3 py-1.5 text-xs text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/20">Delete</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-3 py-6 text-center text-slate-500 dark:text-gray-400">
@if (($filters['q'] ?? '') !== '')
No rows match "<span class="font-semibold text-slate-700 dark:text-gray-300">{{ $filters['q'] }}</span>".
@else
No catalog rows in database.
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-4">
{{ $items->links('vendor.pagination.dashboard') }}
</div>
</div>
<div class="mt-8 rounded-2xl glass-card p-5">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-[0.2em] text-slate-600 dark:text-gray-400 font-semibold">Snapshot Versions</div>
<div class="mt-1 text-sm text-slate-700 dark:text-gray-300">Use this for quick rollback if latest publish is broken.</div>
</div>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200 dark:border-white/10">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 dark:bg-slate-900/80">
<tr class="text-left text-xs uppercase tracking-[0.16em] text-slate-600 dark:text-gray-400">
<th class="px-3 py-3">Version</th>
<th class="px-3 py-3">File</th>
<th class="px-3 py-3">Updated</th>
<th class="px-3 py-3">Status</th>
<th class="px-3 py-3 text-right">Action</th>
</tr>
</thead>
<tbody>
@forelse (($snapshots ?? []) as $snapshot)
<tr class="border-t border-slate-200 dark:border-white/10 text-slate-900 dark:text-gray-200">
<td class="px-3 py-3 font-mono text-xs text-slate-800 dark:text-gray-100">{{ $snapshot['version'] }}</td>
<td class="px-3 py-3 font-mono text-xs text-slate-700 dark:text-gray-300">{{ $snapshot['name'] }}</td>
<td class="px-3 py-3 text-xs text-slate-700 dark:text-gray-300">
{{ $snapshot['modified_at'] > 0 ? \Illuminate\Support\Carbon::createFromTimestamp($snapshot['modified_at'])->format('Y-m-d H:i') : '—' }}
</td>
<td class="px-3 py-3">
@if ($snapshot['is_active'])
<span class="rounded-full border border-emerald-400 bg-emerald-100 px-2 py-1 text-xs font-semibold text-emerald-800 dark:border-emerald-300/40 dark:bg-emerald-500/20 dark:text-emerald-200">Active</span>
@else
<span class="rounded-full border border-slate-300 dark:border-white/10 bg-slate-100 dark:bg-white/5 px-2 py-1 text-xs font-medium text-slate-700 dark:text-gray-300">Inactive</span>
@endif
</td>
<td class="px-3 py-3 text-right">
@if (!$snapshot['is_active'])
<form method="POST" action="{{ route('dashboard.admin.catalog.snapshot.activate') }}" class="inline">
@csrf
<input type="hidden" name="snapshot" value="{{ $snapshot['name'] }}">
<button class="rounded-lg border border-slate-300 dark:border-white/10 bg-white dark:bg-transparent px-3 py-1.5 text-xs font-medium text-slate-800 dark:text-gray-100 hover:bg-slate-50 dark:hover:bg-white/10">
Activate
</button>
</form>
@endif
</td>
</tr>
@empty
<tr><td colspan="5" class="px-3 py-6 text-center text-slate-600 dark:text-gray-400">No snapshot files found yet. Publish once to create versioned snapshots.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection

View File

@@ -42,7 +42,7 @@
<option value="active" @selected(($filters['status'] ?? '') === 'active')>Active</option>
<option value="pending" @selected(($filters['status'] ?? '') === 'pending')>Pending</option>
<option value="revoked" @selected(($filters['status'] ?? '') === 'revoked')>Revoked</option>
<option value="cancelled" @selected(($filters['status'] ?? '') === 'cancelled')>Cancelled</option>
<option value="canceled" @selected(($filters['status'] ?? '') === 'canceled')>Canceled</option>
<option value="suspended" @selected(($filters['status'] ?? '') === 'suspended')>Suspended</option>
</select>
<button class="rounded-xl border border-white/10 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/5 transition-colors">Filter</button>
@@ -165,7 +165,7 @@
<td class="py-4 pr-4">{{ $row->plan }}</td>
<td class="py-4 pr-4">
@php
$inactive = in_array($row->status, ['revoked', 'cancelled', 'suspended'], true);
$inactive = in_array($row->status, ['revoked', 'canceled', 'cancelled', 'suspended'], true);
$pill = $inactive
? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200']
: ($row->status === 'pending'

View File

@@ -59,7 +59,7 @@
<td class="py-4 pr-4">{{ $row->plan }}</td>
<td class="py-4 pr-4">
@php
$inactive = in_array($row->status, ['revoked', 'cancelled', 'suspended'], true);
$inactive = in_array($row->status, ['revoked', 'canceled', 'cancelled', 'suspended'], true);
$pill = $inactive
? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200']
: ($row->status === 'pending'

View File

@@ -18,6 +18,7 @@
['label' => 'Users', 'route' => 'dashboard.admin.users', 'icon' => 'users'],
['label' => 'Subscriptions', 'route' => 'dashboard.admin.subscriptions', 'icon' => 'credit-card'],
['label' => 'Pricing', 'route' => 'dashboard.admin.pricing', 'icon' => 'badge-dollar-sign'],
['label' => 'Catalog', 'route' => 'dashboard.admin.catalog', 'icon' => 'package-search'],
['label' => 'Webhooks', 'route' => 'dashboard.admin.webhooks', 'icon' => 'webhook'],
['label' => 'Audit Logs', 'route' => 'dashboard.admin.audit_logs', 'icon' => 'list-checks'],
['label' => 'Settings', 'route' => 'dashboard.admin.settings', 'icon' => 'settings'],
@@ -113,6 +114,9 @@
<a href="{{ route('dashboard.admin.subscriptions') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
<i data-lucide="credit-card" class="w-4 h-4"></i><span>Grant subscription</span>
</a>
<a href="{{ route('dashboard.admin.catalog') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
<i data-lucide="package-search" class="w-4 h-4"></i><span>Manage catalog</span>
</a>
<a href="{{ route('dashboard.admin.webhooks') }}" class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm text-slate-700 hover:bg-slate-100 dark:text-gray-200 dark:hover:bg-white/10">
<i data-lucide="webhook" class="w-4 h-4"></i><span>Review webhooks</span>
</a>

View File

@@ -11,6 +11,15 @@
$hasSub = $subscription !== null;
$orders = $orders ?? collect();
$payments = $payments ?? collect();
$currentPlan = (string) ($subscription->plan ?? ($user?->tier === 'personal' ? 'personal_monthly' : 'free'));
$hasPendingPayment = $payments->contains(fn ($payment) => (string) ($payment->status ?? '') === 'pending');
$pendingCooldownWindow = (int) config('dewemoji.billing.pending_cooldown_seconds', 120);
$latestPendingPayment = $payments->first(fn ($payment) => (string) ($payment->status ?? '') === 'pending');
$pendingCooldownRemaining = 0;
if ($latestPendingPayment?->created_at && $pendingCooldownWindow > 0) {
$age = max(0, now()->getTimestamp() - $latestPendingPayment->created_at->getTimestamp());
$pendingCooldownRemaining = max(0, $pendingCooldownWindow - $age);
}
$formatPlan = function (?string $code): string {
$value = (string) ($code ?? '');
return match ($value) {
@@ -64,6 +73,14 @@
<div class="mt-3 text-xs text-gray-400">
Downgrading to Free revokes any active API keys immediately.
</div>
@if ($hasPendingPayment)
<div class="mt-3 rounded-xl border border-emerald-300/40 bg-emerald-50 px-3 py-2 text-xs text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-200">
You have a pending checkout. Use Pay in the table below to continue the same payment.
@if ($pendingCooldownRemaining > 0)
New checkout unlocks in <span id="pending-cooldown-seconds" data-seconds="{{ $pendingCooldownRemaining }}" class="font-semibold">{{ $pendingCooldownRemaining }}</span>s.
@endif
</div>
@endif
<div class="mt-6 grid gap-4 md:grid-cols-2">
<div class="rounded-2xl bg-white/5 border border-white/10 p-4">
@@ -78,6 +95,43 @@
</div>
</div>
<div class="mt-6 rounded-2xl bg-white/5 border border-white/10 p-4">
<div class="text-sm font-semibold text-gray-200">Change plan</div>
<p class="mt-2 text-xs text-gray-400">
Plan change policy: when your new payment is confirmed, Dewemoji cancels the previous recurring plan automatically.
No prorated refund is applied.
</p>
<div class="mt-4 flex flex-wrap gap-2">
@if (in_array($currentPlan, ['personal_monthly', 'personal_annual'], true))
@if ($currentPlan === 'personal_monthly')
<a href="{{ route('pricing', ['period' => 'annual', 'currency' => 'USD']) }}"
class="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold text-gray-200 hover:bg-white/10">
Switch to Annual
</a>
@endif
@if ($currentPlan === 'personal_annual')
<a href="{{ route('pricing', ['period' => 'monthly', 'currency' => 'USD']) }}"
class="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold text-gray-200 hover:bg-white/10">
Switch to Monthly
</a>
@endif
<a href="{{ route('pricing', ['target' => 'lifetime', 'currency' => 'USD']) }}"
class="rounded-full bg-brand-sun text-black px-4 py-2 text-xs font-semibold hover:opacity-90">
Upgrade to Lifetime
</a>
@elseif ($currentPlan === 'personal_lifetime')
<span class="rounded-full border border-emerald-300/40 bg-emerald-50 px-4 py-2 text-xs font-semibold text-emerald-800 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-200">
Lifetime active
</span>
@else
<a href="{{ route('pricing') }}"
class="rounded-full bg-brand-sun text-black px-4 py-2 text-xs font-semibold hover:opacity-90">
Choose Personal Plan
</a>
@endif
</div>
</div>
@if ($payments->count() > 0)
<div class="mt-6">
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Recent payments</div>
@@ -90,6 +144,7 @@
<th class="px-4 py-3 text-left">Amount</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Created</th>
<th class="px-4 py-3 text-left">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
@@ -110,6 +165,20 @@
<span class="rounded-full {{ $pill['bg'] }} px-3 py-1 text-xs font-semibold {{ $pill['text'] }}">{{ $status }}</span>
</td>
<td class="px-4 py-3 text-xs text-gray-400">{{ $payment->created_at?->toDateString() ?? '—' }}</td>
<td class="px-4 py-3">
@if ($status === 'pending')
<button
type="button"
class="resume-payment-btn inline-flex items-center justify-center rounded-full border border-brand-ocean/40 px-3 py-1 text-xs font-semibold text-brand-ocean hover:bg-brand-ocean/10"
data-payment-id="{{ $payment->id }}"
data-provider="{{ strtolower((string) ($payment->provider ?? '')) }}"
>
Pay
</button>
@else
<span class="text-xs text-gray-500"></span>
@endif
</td>
</tr>
@endforeach
</tbody>
@@ -117,7 +186,7 @@
</div>
@if ($payments->contains(fn ($payment) => in_array($payment->status, ['pending', 'failed'], true)))
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-200">
Pending or failed payments need a new checkout. Start a fresh transaction from the pricing page.
Failed payments need a new checkout. Pending payments can be continued from the table using the Pay action.
</div>
<a href="{{ route('pricing') }}" class="mt-3 inline-flex items-center justify-center rounded-full border border-amber-300 px-4 py-2 text-xs font-semibold text-amber-800 hover:bg-amber-100 dark:border-amber-300/40 dark:text-amber-200 dark:hover:bg-amber-400/10">
Start new checkout
@@ -135,4 +204,243 @@
</a>
@endif
</div>
<div id="billing-qris-modal" class="hidden fixed inset-0 z-[70] items-center justify-center">
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
<div class="relative z-10 w-full max-w-lg rounded-3xl glass-card p-6 bg-white/95 text-slate-900 dark:bg-slate-950/90 dark:text-white">
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">QRIS payment</div>
<h3 class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">Scan to pay</h3>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="rounded-2xl bg-white/10 border border-white/10 p-4 flex items-center justify-center">
<div id="billing-qris-code" class="rounded-xl bg-white p-3 shadow-lg"></div>
</div>
<div class="space-y-3 text-sm text-gray-300">
<div class="rounded-xl bg-white/5 border border-white/10 p-3">
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Amount</div>
<div id="billing-qris-amount" class="mt-1 text-lg font-semibold text-gray-900 dark:text-white">Rp 0</div>
</div>
<div class="rounded-xl bg-white/5 border border-white/10 p-3">
<div class="text-xs uppercase tracking-[0.2em] text-gray-400">Expires</div>
<div id="billing-qris-expiry" class="mt-1 text-sm text-gray-700 dark:text-gray-300">Complete within 30 minutes</div>
</div>
<div id="billing-qris-text" class="hidden"></div>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-2">
<button id="billing-qris-cancel" class="rounded-full bg-rose-500 text-white font-semibold px-4 py-2 text-sm hover:bg-rose-600">
Cancel payment
</button>
</div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script>
(() => {
const cooldownEl = document.getElementById('pending-cooldown-seconds');
if (cooldownEl) {
let remaining = Math.max(0, Number(cooldownEl.dataset.seconds || 0));
const tick = () => {
cooldownEl.textContent = String(remaining);
if (remaining <= 0) return false;
remaining -= 1;
return true;
};
tick();
const timer = setInterval(() => {
if (!tick()) {
clearInterval(timer);
}
}, 1000);
}
const resumeButtons = document.querySelectorAll('.resume-payment-btn');
if (!resumeButtons.length) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const resumeUrlTpl = @json(route('billing.payments.resume', ['payment' => '__PAYMENT_ID__']));
const pakasirStatusUrl = @json(route('billing.pakasir.status'));
const pakasirCancelUrl = @json(route('billing.pakasir.cancel'));
const billingSuccessUrl = @json(route('dashboard.billing', ['status' => 'success']));
const modal = document.getElementById('billing-qris-modal');
const qrTarget = document.getElementById('billing-qris-code');
const qrText = document.getElementById('billing-qris-text');
const qrAmount = document.getElementById('billing-qris-amount');
const qrExpiry = document.getElementById('billing-qris-expiry');
const cancelBtn = document.getElementById('billing-qris-cancel');
let modalOpen = false;
let pollTimer = null;
let currentOrderId = null;
const openModal = () => {
if (!modal) return;
modal.classList.remove('hidden');
modal.classList.add('flex');
modalOpen = true;
};
const closeModal = () => {
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
modalOpen = false;
currentOrderId = null;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
const formatExpiry = (value) => {
if (!value) return null;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
return new Intl.DateTimeFormat('id-ID', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsed);
};
const resumeUrlFor = (paymentId) => resumeUrlTpl.replace('__PAYMENT_ID__', String(paymentId));
const startPolling = () => {
if (!currentOrderId) return;
if (pollTimer) {
clearInterval(pollTimer);
}
pollTimer = setInterval(async () => {
if (!modalOpen || !currentOrderId) return;
try {
const res = await fetch(pakasirStatusUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
body: JSON.stringify({ order_id: currentOrderId }),
});
const data = await res.json().catch(() => null);
if (res.ok && data?.paid) {
closeModal();
window.location.href = billingSuccessUrl;
}
} catch (e) {
// keep polling silently
}
}, 4000);
};
cancelBtn?.addEventListener('click', async () => {
if (!currentOrderId) {
closeModal();
return;
}
const ok = await window.dewemojiConfirm('Cancel this QRIS payment? You can start a new checkout from pricing.', {
title: 'Cancel payment',
okText: 'Cancel payment',
});
if (!ok) return;
try {
await fetch(pakasirCancelUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
body: JSON.stringify({ order_id: currentOrderId }),
});
} catch (e) {
// best-effort cancel
} finally {
closeModal();
window.location.reload();
}
});
document.addEventListener('keydown', (event) => {
if (!modalOpen) return;
if (event.key === 'Escape') {
event.preventDefault();
}
});
resumeButtons.forEach((btn) => {
btn.addEventListener('click', async () => {
const paymentId = btn.dataset.paymentId;
if (!paymentId) return;
const original = btn.textContent;
btn.disabled = true;
btn.textContent = 'Loading...';
try {
const res = await fetch(resumeUrlFor(paymentId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
body: JSON.stringify({}),
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
const error = data?.error || 'resume_failed';
if (error === 'payment_expired') {
alert('This payment has expired. Start a new checkout from pricing.');
} else if (error === 'payment_not_pending') {
window.location.reload();
} else {
alert('Could not continue this payment. Start a new checkout from pricing.');
}
btn.disabled = false;
btn.textContent = original;
return;
}
if (data.mode === 'redirect' && data.approve_url) {
window.location.href = data.approve_url;
return;
}
if (data.mode === 'qris' && data.payment_number) {
currentOrderId = data.order_id || null;
if (qrTarget) qrTarget.innerHTML = '';
if (qrTarget && window.QRCode) {
new QRCode(qrTarget, {
text: data.payment_number,
width: 220,
height: 220,
colorDark: '#0b0b0f',
colorLight: '#ffffff',
});
}
if (qrText) qrText.textContent = data.payment_number;
if (qrAmount) qrAmount.textContent = `Rp ${Number(data.total_payment || data.amount || 0).toLocaleString('id-ID')}`;
if (qrExpiry) {
const formatted = formatExpiry(data.expired_at);
qrExpiry.textContent = formatted ? `Expires ${formatted}` : 'Complete within 30 minutes';
}
openModal();
startPolling();
btn.disabled = false;
btn.textContent = original;
return;
}
alert('Could not continue this payment. Start a new checkout from pricing.');
btn.disabled = false;
btn.textContent = original;
} catch (e) {
alert('Resume request failed. Please try again.');
btn.disabled = false;
btn.textContent = original;
}
});
});
})();
</script>
@endpush

View File

@@ -9,7 +9,8 @@
$user = $user ?? auth()->user();
$isPersonal = $user && (string) $user->tier === 'personal';
$freeLimit = $freeLimit ?? null;
$limitReached = $freeLimit !== null && $items->count() >= $freeLimit;
$activeCount = (int) ($activeCount ?? $items->where('is_active', true)->count());
$limitReached = $freeLimit !== null && $activeCount >= $freeLimit;
$emojiLookup = $emojiLookup ?? [];
@endphp
@@ -31,7 +32,7 @@
<div class="mt-2 text-xl font-semibold text-white">{{ $isPersonal ? 'Ready to personalize' : 'Free plan keywords' }}</div>
<p class="mt-2 text-sm text-gray-400">Add keywords to emojis to improve your personal search results.</p>
@if (!$isPersonal && $freeLimit)
<p class="mt-1 text-xs text-gray-500">Free plan limit: {{ $items->count() }} / {{ $freeLimit }} keywords.</p>
<p class="mt-1 text-xs text-gray-500">Free active limit: {{ $activeCount }} / {{ $freeLimit }} keywords. Inactive keywords are stored but not used in search.</p>
@endif
</div>
<div class="flex flex-wrap items-center gap-2">
@@ -52,9 +53,12 @@
@if (!$isPersonal && $freeLimit)
<div class="mt-6 rounded-2xl border border-brand-sun/30 bg-brand-sun/10 p-4 text-sm text-brand-sun">
Free plan includes up to {{ $freeLimit }} keywords total. Upgrade for unlimited keywords.
Free plan allows up to {{ $freeLimit }} active keywords. You can keep extras as inactive and reactivate after upgrading.
</div>
@endif
<div class="mt-4 rounded-2xl border border-white/10 bg-white/5 p-4 text-xs text-gray-400">
Search behavior: only <strong class="text-gray-200">Active</strong> private keywords are used in emoji matching. Inactive keywords stay saved in your account but are ignored by search and API results.
</div>
<div id="import-panel" class="mt-6 hidden rounded-2xl border border-white/10 bg-white/5 p-5">
<form method="POST" action="{{ route('dashboard.keywords.import') }}" enctype="multipart/form-data" class="grid gap-3 md:grid-cols-2">
@@ -81,6 +85,7 @@
<th class="px-4 py-3 text-left">Emoji</th>
<th class="px-4 py-3 text-left">Keyword</th>
<th class="px-4 py-3 text-left">Language</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
@@ -101,6 +106,15 @@
</td>
<td class="px-4 py-3 font-semibold text-white">{{ $item->keyword }}</td>
<td class="px-4 py-3 text-xs uppercase tracking-[0.15em] text-gray-400">{{ $item->lang ?? 'und' }}</td>
<td class="px-4 py-3">
@php
$isActive = (bool) ($item->is_active ?? true);
$canActivate = $isActive || $isPersonal || !$limitReached;
@endphp
<span class="rounded-full px-3 py-1 text-xs font-semibold {{ $isActive ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200' : 'bg-slate-100 text-slate-700 dark:bg-slate-500/20 dark:text-slate-200' }}">
{{ $isActive ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="px-4 py-3 text-right">
<button
type="button"
@@ -109,9 +123,21 @@
data-emoji="{{ $item->emoji_slug }}"
data-keyword="{{ $item->keyword }}"
data-lang="{{ $item->lang }}"
>
>
Edit
</button>
<form method="POST" action="{{ route('dashboard.keywords.toggle_active', $item->id) }}" class="inline">
@csrf
@method('PUT')
<input type="hidden" name="is_active" value="{{ $isActive ? '0' : '1' }}">
<button
type="submit"
class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-200 hover:bg-white/10 {{ (!$canActivate && !$isActive) ? 'opacity-50 cursor-not-allowed' : '' }}"
{{ (!$canActivate && !$isActive) ? 'disabled' : '' }}
>
{{ $isActive ? 'Deactivate' : 'Activate' }}
</button>
</form>
<form method="POST" action="{{ route('dashboard.keywords.delete', $item->id) }}" class="inline">
@csrf
@method('DELETE')
@@ -123,7 +149,7 @@
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-8 text-center text-sm text-gray-500">No keywords yet.</td>
<td colspan="5" class="px-4 py-8 text-center text-sm text-gray-500">No keywords yet.</td>
</tr>
@endforelse
</tbody>
@@ -317,7 +343,7 @@
}
emojiSearchAbort = new AbortController();
const base = isPersonal ? emojiSearchUrl : publicEmojiSearchUrl;
const base = emojiSearchUrl;
const url = `${base}?${new URLSearchParams({ q, limit: '30', page: '1' }).toString()}`;
try {

View File

@@ -128,6 +128,7 @@
<section id="plans" class="glass-card rounded-2xl p-6 scroll-mt-28">
<h3 class="text-lg font-semibold">Plans &amp; limits</h3>
<p class="mt-2 text-sm text-gray-300">Private keyword matching uses only keywords with <code>is_active=true</code>. Inactive keywords remain stored but are excluded from search results until reactivated.</p>
<div class="overflow-x-auto mt-3">
<table class="min-w-full text-sm doc-table">
<thead>
@@ -145,7 +146,7 @@
<td class="py-2 pr-6 text-right">20</td>
<td class="py-2 pr-6">None</td>
<td class="py-2 pr-6">Server-level only</td>
<td class="py-2">Public dataset (EN + ID) only.</td>
<td class="py-2">Public dataset (EN + ID) plus up to 20 active private keywords for signed-in users.</td>
</tr>
<tr>
<td class="py-2 pr-6"><strong>Personal</strong></td>
@@ -236,6 +237,7 @@
<ul class="list-disc pl-5 text-sm text-gray-300 space-y-1">
<li><code>400</code> invalid_request</li>
<li><code>401</code> invalid_key</li>
<li><code>409</code> pending_cooldown (billing checkout lock, wait <code>retry_after</code> seconds)</li>
<li><code>404</code> not_found</li>
<li><code>429</code> rate_limited</li>
</ul>

View File

@@ -16,6 +16,7 @@
$userTier = $userTier ?? $user?->tier;
$isPersonal = $userTier === 'personal';
$userKeywords = $userKeywords ?? collect();
$activeKeywordCount = (int) ($activeKeywordCount ?? $userKeywords->where('is_active', true)->count());
$htmlHex = '';
$cssCode = '';
if (!empty($emoji['codepoints'][0])) {
@@ -214,17 +215,29 @@
@if ($canManageKeywords)
@if (!is_null($keywordLimit))
<div class="mt-3 text-xs text-gray-400">
Free plan limit: {{ $userKeywords->count() }} / {{ $keywordLimit }} keywords.
Free active limit: {{ $activeKeywordCount }} / {{ $keywordLimit }} keywords.
</div>
@endif
<div id="user-keyword-list" class="mt-4 flex flex-wrap gap-2">
@forelse ($userKeywords as $keyword)
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-sm text-gray-200">
@php($isKeywordActive = (bool) ($keyword->is_active ?? true))
<span
class="user-keyword-pill inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm {{ $isKeywordActive ? 'bg-white/5 border-white/10 text-gray-200' : 'bg-slate-200/10 border-slate-200/20 text-gray-300' }}"
data-id="{{ $keyword->id }}"
data-keyword="{{ $keyword->keyword }}"
data-lang="{{ $keyword->lang ?? 'und' }}"
data-active="{{ $isKeywordActive ? '1' : '0' }}"
>
<span>{{ $keyword->keyword }}</span>
<span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">{{ $keyword->lang ?? 'und' }}</span>
@unless($isKeywordActive)
<span class="text-[10px] uppercase tracking-[0.2em] text-amber-400">inactive</span>
@endunless
<button type="button" class="user-keyword-edit rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-white/10">Edit</button>
<button type="button" class="user-keyword-delete rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-red-500/20">Delete</button>
</span>
@empty
<span class="text-sm text-gray-400">No private keywords yet. Add one to personalize search.</span>
<span id="user-keyword-empty" class="text-sm text-gray-400">No private keywords yet. Add one to personalize search.</span>
@endforelse
</div>
@else
@@ -254,7 +267,7 @@
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm"></div>
<div class="relative z-10 w-full max-w-lg rounded-3xl glass-card theme-surface p-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Add keyword</h3>
<h3 id="user-keyword-title" class="text-lg font-semibold text-slate-900 dark:text-white">Add keyword</h3>
<button id="user-keyword-close" class="w-8 h-8 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-white/10 dark:hover:bg-white/20 flex items-center justify-center text-slate-600 dark:text-gray-200">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
@@ -277,7 +290,7 @@
</div>
<div class="flex items-center justify-end gap-2">
<button type="button" id="user-keyword-cancel" class="rounded-full border border-slate-300 px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-white/10 dark:text-gray-200 dark:hover:bg-white/5">Cancel</button>
<button type="submit" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-5 py-2 text-sm">Save keyword</button>
<button id="user-keyword-submit" type="submit" class="rounded-full bg-brand-ocean text-white force-white font-semibold px-5 py-2 text-sm">Save keyword</button>
</div>
</form>
</div>
@@ -337,6 +350,8 @@ addRecent(@json($symbol));
const closeBtn = document.getElementById('user-keyword-close');
const cancelBtn = document.getElementById('user-keyword-cancel');
const form = document.getElementById('user-keyword-form');
const modalTitle = document.getElementById('user-keyword-title');
const submitBtn = document.getElementById('user-keyword-submit');
const keywordInput = document.getElementById('user-keyword-input');
const langInput = document.getElementById('user-keyword-lang');
const langComboInput = document.getElementById('user-keyword-lang-combo');
@@ -344,6 +359,10 @@ addRecent(@json($symbol));
const langSourceEl = document.getElementById('user-keyword-lang-source');
const list = document.getElementById('user-keyword-list');
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const updateUrlTemplate = @json(route('dashboard.keywords.update', ['keyword' => '__ID__']));
const deleteUrlTemplate = @json(route('dashboard.keywords.delete', ['keyword' => '__ID__']));
let editingKeywordId = null;
const escHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
window.dewemojiPopulateLanguageSelect?.(langSourceEl);
const canonicalizeLanguageCode = (raw) => window.dewemojiCanonicalLanguageCode?.(raw) || String(raw || '').trim().toLowerCase();
@@ -397,25 +416,60 @@ addRecent(@json($symbol));
return UNDETERMINED_LANGUAGE_CODE;
};
const openModal = () => {
if (limitReached) {
const setModalMode = (mode = 'add') => {
if (mode === 'edit') {
modalTitle.textContent = 'Edit keyword';
submitBtn.textContent = 'Save changes';
return;
}
modalTitle.textContent = 'Add keyword';
submitBtn.textContent = 'Save keyword';
};
const buildKeywordPill = (item) => {
const pill = document.createElement('span');
const isActive = Boolean(item?.is_active ?? true);
const lang = String(item?.lang || 'und');
pill.className = `user-keyword-pill inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm ${isActive ? 'bg-white/5 border-white/10 text-gray-200' : 'bg-slate-200/10 border-slate-200/20 text-gray-300'}`;
pill.dataset.id = String(item?.id || '');
pill.dataset.keyword = String(item?.keyword || '');
pill.dataset.lang = lang;
pill.dataset.active = isActive ? '1' : '0';
pill.innerHTML = `
<span>${escHtml(String(item?.keyword || ''))}</span>
<span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">${escHtml(lang)}</span>
${isActive ? '' : '<span class="text-[10px] uppercase tracking-[0.2em] text-amber-400">inactive</span>'}
<button type="button" class="user-keyword-edit rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-white/10">Edit</button>
<button type="button" class="user-keyword-delete rounded-md border border-white/10 px-1.5 py-0.5 text-[10px] text-gray-300 hover:bg-red-500/20">Delete</button>
`;
return pill;
};
const openModal = (mode = 'add', item = null) => {
if (mode === 'add' && limitReached) {
showToast('Free plan keyword limit reached');
return;
}
editingKeywordId = mode === 'edit' ? item?.id || null : null;
setModalMode(mode);
modal.classList.remove('hidden');
modal.classList.add('flex');
keywordInput.value = '';
setLanguageByCode(DEFAULT_LANGUAGE_CODE);
keywordInput.value = mode === 'edit' ? (item?.keyword || '') : '';
setLanguageByCode(mode === 'edit' ? (item?.lang || UNDETERMINED_LANGUAGE_CODE) : DEFAULT_LANGUAGE_CODE, {
silentInput: mode === 'edit' && String(item?.lang || '').toLowerCase() === UNDETERMINED_LANGUAGE_CODE,
});
closeLanguageMenu();
keywordInput.focus();
};
const closeModal = () => {
editingKeywordId = null;
setModalMode('add');
modal.classList.add('hidden');
modal.classList.remove('flex');
};
openBtn?.addEventListener('click', openModal);
openBtn?.addEventListener('click', () => openModal('add'));
closeBtn?.addEventListener('click', closeModal);
cancelBtn?.addEventListener('click', closeModal);
modal?.addEventListener('click', (e) => {
@@ -444,8 +498,12 @@ addRecent(@json($symbol));
lang: langInput.value || 'und',
};
if (!payload.keyword) return;
const res = await fetch('{{ route('dashboard.keywords.store') }}', {
method: 'POST',
const url = editingKeywordId
? updateUrlTemplate.replace('__ID__', String(editingKeywordId))
: '{{ route('dashboard.keywords.store') }}';
const method = editingKeywordId ? 'PUT' : 'POST';
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
@@ -458,16 +516,71 @@ addRecent(@json($symbol));
showToast('Could not save keyword');
return;
}
const badge = document.createElement('span');
badge.className = 'inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-sm text-gray-200';
badge.innerHTML = `<span>${payload.keyword}</span><span class="text-[10px] uppercase tracking-[0.2em] text-gray-500">${payload.lang}</span>`;
const item = data?.item || payload;
const wasEditing = Boolean(editingKeywordId);
const badge = buildKeywordPill(item);
if (list) {
const empty = list.querySelector('span.text-sm');
const empty = list.querySelector('#user-keyword-empty');
if (empty) empty.remove();
list.prepend(badge);
if (wasEditing) {
const current = list.querySelector(`.user-keyword-pill[data-id="${editingKeywordId}"]`);
if (current) current.replaceWith(badge);
else list.prepend(badge);
} else {
list.prepend(badge);
}
}
closeModal();
showToast('Keyword added');
showToast(wasEditing ? 'Keyword updated' : 'Keyword added');
});
list?.addEventListener('click', async (event) => {
const editBtn = event.target.closest('.user-keyword-edit');
const deleteBtn = event.target.closest('.user-keyword-delete');
const pill = event.target.closest('.user-keyword-pill');
if (!pill) return;
const id = pill.dataset.id;
if (!id) return;
if (editBtn) {
openModal('edit', {
id,
keyword: pill.dataset.keyword || '',
lang: pill.dataset.lang || 'und',
});
return;
}
if (!deleteBtn) return;
const ok = window.dewemojiConfirm
? await window.dewemojiConfirm('Delete this keyword?', {
title: 'Delete keyword',
okText: 'Delete',
})
: true;
if (!ok) return;
const res = await fetch(deleteUrlTemplate.replace('__ID__', id), {
method: 'DELETE',
headers: {
'Accept': 'application/json',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
showToast('Could not delete keyword');
return;
}
pill.remove();
if (!list.querySelector('.user-keyword-pill')) {
const empty = document.createElement('span');
empty.id = 'user-keyword-empty';
empty.className = 'text-sm text-gray-400';
empty.textContent = 'No private keywords yet. Add one to personalize search.';
list.appendChild(empty);
}
showToast('Keyword deleted');
});
})();

View File

@@ -218,6 +218,7 @@
const state = { page: 1, limit: 32, total: 0, items: [], categories: {} };
const userTier = @json($userTier ?? null);
const isPersonal = userTier === 'personal';
const isSignedIn = @json(auth()->check());
const initialQuery = @json($initialQuery ?? '');
const initialCategory = @json($initialCategory ?? '');
const initialSubcategory = @json($initialSubcategory ?? '');
@@ -456,7 +457,7 @@
if (catEl.value) params.set('category', catEl.value);
if (subEl.value) params.set('subcategory', subEl.value);
const endpoint = isPersonal ? '/dashboard/keywords/search' : '/v1/emojis';
const endpoint = isSignedIn ? '/dashboard/keywords/search' : '/v1/emojis';
const res = await fetch(endpoint + '?' + params.toString());
const data = await res.json();
if (!res.ok) {
@@ -492,15 +493,10 @@
</a>
<div class="emoji-card-bar absolute bottom-0 left-0 right-0 border-t border-white/10 bg-black/20 px-2 py-1.5 flex items-start gap-1">
<span class="emoji-name-clamp text-[10px] text-gray-300 text-left flex-1">${esc(item.name)}</span>
${isPrivate ? `<span class="px-1.5 py-0.5 rounded bg-brand-ocean/20 text-[9px] text-brand-oceanSoft" title="${esc(item.matched_keyword || '')}">Your: ${esc(item.matched_keyword || '')}</span>` : ''}
${isPrivate ? `<button type="button" class="edit-btn shrink-0 rounded bg-white/10 px-1.5 text-[9px] text-gray-200 hover:bg-brand-ocean/30">Edit</button>` : ''}
${isPrivate ? `<button type="button" class="delete-btn shrink-0 rounded bg-white/10 px-1.5 text-[9px] text-gray-200 hover:bg-red-500/30">Del</button>` : ''}
<button type="button" class="copy-btn shrink-0 w-6 h-6 rounded bg-white/10 hover:bg-brand-ocean/30 text-[11px] text-gray-200 hover:text-white transition-colors" title="Copy emoji"></button>
</div>
`;
const copyBtn = card.querySelector('.copy-btn');
const editBtn = card.querySelector('.edit-btn');
const deleteBtn = card.querySelector('.delete-btn');
if (copyBtn) {
copyBtn.addEventListener('click', (e) => {
e.preventDefault();
@@ -511,36 +507,6 @@
});
});
}
if (editBtn && isPrivate) {
editBtn.addEventListener('click', (e) => {
e.preventDefault();
openKeywordEdit(item);
});
}
if (deleteBtn && isPrivate) {
deleteBtn.addEventListener('click', async (e) => {
e.preventDefault();
if (!item.matched_keyword_id) return;
const ok = await window.dewemojiConfirm('Delete this keyword?', {
title: 'Delete keyword',
okText: 'Delete',
});
if (!ok) return;
const res = await fetch(`/dashboard/keywords/${item.matched_keyword_id}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
...(csrf ? { 'X-CSRF-TOKEN': csrf } : {}),
},
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
showToast('Could not delete keyword');
return;
}
fetchEmojis(true);
});
}
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
navigator.clipboard.writeText(item.emoji).then(() => {

View File

@@ -135,6 +135,9 @@
$canQris = $pakasirEnabled ?? false;
$paypalEnabled = $paypalEnabled ?? false;
$paypalPlans = $paypalPlans ?? ['personal_monthly' => false, 'personal_annual' => false];
$hasActiveLifetime = (bool) ($hasActiveLifetime ?? false);
$hasPendingPayment = (bool) ($hasPendingPayment ?? false);
$pendingCooldownRemaining = max(0, (int) ($pendingCooldownRemaining ?? 0));
@endphp
<div class="mb-6 flex flex-wrap items-center justify-center gap-3 text-sm text-gray-400">
@@ -185,15 +188,18 @@
data-paypal-enabled="{{ $paypalEnabled && $paypalPlans['personal_monthly'] ? 'true' : 'false' }}"
data-paypal-annual-enabled="{{ $paypalEnabled && $paypalPlans['personal_annual'] ? 'true' : 'false' }}"
data-qris-enabled="{{ $canQris ? 'true' : 'false' }}"
data-has-lifetime="{{ $hasActiveLifetime ? 'true' : 'false' }}"
data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}"
data-pending-cooldown="{{ $pendingCooldownRemaining }}"
class="!text-white w-full py-2.5 rounded-xl bg-brand-ocean hover:bg-brand-oceanSoft text-white font-semibold text-center block">
Pay now
</button>
</div>
<div class="hidden">
<button type="button" data-paypal-plan="personal_monthly" data-original="Start Personal"></button>
<button type="button" data-paypal-plan="personal_annual" data-original="Start Personal"></button>
<button type="button" data-qris-plan="personal_monthly" data-original="Start Personal"></button>
<button type="button" data-qris-plan="personal_annual" data-original="Start Personal"></button>
<button type="button" data-paypal-plan="personal_monthly" data-original="Start Personal" data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}"></button>
<button type="button" data-paypal-plan="personal_annual" data-original="Start Personal" data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}"></button>
<button type="button" data-qris-plan="personal_monthly" data-original="Start Personal" data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}"></button>
<button type="button" data-qris-plan="personal_annual" data-original="Start Personal" data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}"></button>
</div>
</section>
@@ -223,14 +229,17 @@
id="lifetime-pay-btn"
data-paypal-enabled="{{ $paypalEnabled ? 'true' : 'false' }}"
data-qris-enabled="{{ $canQris ? 'true' : 'false' }}"
data-has-lifetime="{{ $hasActiveLifetime ? 'true' : 'false' }}"
data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}"
data-pending-cooldown="{{ $pendingCooldownRemaining }}"
class="force-white w-full py-2.5 rounded-xl border border-brand-ocean/60 text-brand-ocean font-semibold text-center block hover:bg-brand-ocean/10">
Pay now
</button>
</div>
<div class="hidden">
<button type="button" data-paypal-plan="personal_lifetime" data-original="Get Lifetime Access"></button>
<button type="button" data-paypal-plan="personal_lifetime" data-original="Get Lifetime Access" data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}"></button>
<button type="button"
data-qris-plan="personal_lifetime" data-original="Get Lifetime Access">
data-qris-plan="personal_lifetime" data-original="Get Lifetime Access" data-has-pending="{{ $hasPendingPayment ? 'true' : 'false' }}">
QRIS Lifetime
</button>
</div>
@@ -341,6 +350,16 @@
if (!buttons.length) return;
const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const isAuthed = @json(auth()->check());
const confirmReplacePending = async (btn) => {
if ((btn.dataset.hasPending || 'false') !== 'true') return true;
return window.dewemojiConfirm(
'You have a pending payment. Starting a new checkout will cancel the previous pending payment. Continue?',
{
title: 'Replace pending payment',
okText: 'Continue checkout',
}
);
};
buttons.forEach((btn) => {
btn.addEventListener('click', async () => {
@@ -350,6 +369,8 @@
window.location.href = "{{ route('login') }}";
return;
}
const proceed = await confirmReplacePending(btn);
if (!proceed) return;
const original = btn.dataset.original || btn.textContent;
btn.disabled = true;
btn.textContent = 'Redirecting...';
@@ -365,8 +386,17 @@
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.approve_url) {
const reason = data?.error ? ` (${data.error})` : '';
alert('Could not start PayPal checkout. Please try again.' + reason);
if (data?.error === 'lifetime_active') {
alert('Lifetime plan is already active. Monthly/annual checkout is disabled.');
} else if (data?.error === 'pending_cooldown') {
if (window.dewemojiStartCheckoutCooldown) {
window.dewemojiStartCheckoutCooldown(Number(data.retry_after || 120));
}
alert(`Payment confirmation is in progress. Please wait ${Number(data.retry_after || 120)}s or continue pending from Billing.`);
} else {
const reason = data?.error ? ` (${data.error})` : '';
alert('Could not start PayPal checkout. Please try again.' + reason);
}
btn.disabled = false;
btn.textContent = original;
return;
@@ -396,8 +426,17 @@
const lifetimeNote = document.getElementById('lifetime-pay-note');
if (!priceWrap || !secondary || !payBtn || !lifetimePrice || !lifetimeSecondary || !lifetimePay) return;
let period = 'monthly';
let currency = document.querySelector('[data-default-currency]')?.dataset.defaultCurrency || 'USD';
const params = new URLSearchParams(window.location.search);
const requestedPeriod = params.get('period');
const requestedCurrency = params.get('currency');
let period = requestedPeriod === 'annual' ? 'annual' : 'monthly';
let currency = requestedCurrency === 'IDR'
? 'IDR'
: (requestedCurrency === 'USD'
? 'USD'
: (document.querySelector('[data-default-currency]')?.dataset.defaultCurrency || 'USD'));
let checkoutCooldownRemaining = Math.max(0, Number(payBtn.dataset.pendingCooldown || 0));
let cooldownTimer = null;
const setActive = (nodes, value) => {
nodes.forEach((btn) => {
@@ -414,6 +453,23 @@
btn.classList.add('inline-flex', 'items-center', 'justify-center', 'gap-2');
btn.innerHTML = `${icon}<span>${label}</span>`;
};
const getCooldownRemaining = () => Math.max(0, Math.floor(checkoutCooldownRemaining));
const startCooldown = (seconds) => {
checkoutCooldownRemaining = Math.max(getCooldownRemaining(), Math.max(0, Math.floor(Number(seconds) || 0)));
if (getCooldownRemaining() <= 0) return;
if (!cooldownTimer) {
cooldownTimer = setInterval(() => {
checkoutCooldownRemaining = Math.max(0, getCooldownRemaining() - 1);
updatePrice();
if (getCooldownRemaining() <= 0 && cooldownTimer) {
clearInterval(cooldownTimer);
cooldownTimer = null;
}
}, 1000);
}
updatePrice();
};
window.dewemojiStartCheckoutCooldown = startCooldown;
const updatePrice = () => {
const amount = currency === 'USD'
@@ -431,12 +487,26 @@
const canPaypal = (period === 'monthly' ? payBtn.dataset.paypalEnabled === 'true' : payBtn.dataset.paypalAnnualEnabled === 'true');
const canQris = payBtn.dataset.qrisEnabled === 'true';
const hasLifetime = payBtn.dataset.hasLifetime === 'true';
const cooldownRemaining = getCooldownRemaining();
let disabled = false;
let label = 'Start Personal';
let note = '';
if (currency === 'USD') {
if (hasLifetime) {
disabled = true;
label = 'Lifetime active';
note = 'You already own Lifetime. Monthly/Annual checkout is disabled.';
payBtn.classList.remove('bg-brand-sun', 'hover:bg-brand-sunSoft', 'text-black');
payBtn.classList.add('bg-brand-ocean', 'hover:bg-brand-oceanSoft', 'text-white');
} else if (cooldownRemaining > 0) {
disabled = true;
label = 'Processing...';
note = `Payment confirmation in progress. Try again in ${cooldownRemaining}s or continue pending from Billing.`;
payBtn.classList.remove('bg-brand-sun', 'hover:bg-brand-sunSoft', 'text-black');
payBtn.classList.add('bg-brand-ocean', 'hover:bg-brand-oceanSoft', 'text-white');
} else if (currency === 'USD') {
disabled = !canPaypal;
label = 'Start Personal';
note = canPaypal ? '' : 'PayPal is not configured for this plan.';
@@ -473,10 +543,23 @@
const canLifetimePaypal = lifetimePay.dataset.paypalEnabled === 'true';
const canLifetimeQris = lifetimePay.dataset.qrisEnabled === 'true';
const hasLifetimeOnAccount = lifetimePay.dataset.hasLifetime === 'true';
let lifetimeDisabled = false;
let lifetimeLabel = 'Get Lifetime Access';
let lifetimeHint = '';
if (currency === 'USD') {
if (hasLifetimeOnAccount) {
lifetimeDisabled = true;
lifetimeLabel = 'Lifetime active';
lifetimeHint = 'Your lifetime plan is already active.';
lifetimePay.classList.remove('border-brand-sun/60', 'text-brand-sun', 'hover:bg-brand-sun/10');
lifetimePay.classList.add('border-brand-ocean/60', 'text-brand-ocean', 'hover:bg-brand-ocean/10');
} else if (cooldownRemaining > 0) {
lifetimeDisabled = true;
lifetimeLabel = 'Processing...';
lifetimeHint = `Payment confirmation in progress. Try again in ${cooldownRemaining}s or continue pending from Billing.`;
lifetimePay.classList.remove('border-brand-sun/60', 'text-brand-sun', 'hover:bg-brand-sun/10');
lifetimePay.classList.add('border-brand-ocean/60', 'text-brand-ocean', 'hover:bg-brand-ocean/10');
} else if (currency === 'USD') {
lifetimeDisabled = !canLifetimePaypal;
lifetimeLabel = 'Get Lifetime Access';
lifetimeHint = canLifetimePaypal ? '' : 'PayPal is not configured.';
@@ -540,6 +623,18 @@
setActive(periodButtons, period);
setActive(currencyButtons, currency);
updatePrice();
if (getCooldownRemaining() > 0) {
startCooldown(getCooldownRemaining());
}
if (params.get('target') === 'lifetime') {
const lifetimeCard = lifetimePay.closest('section');
if (lifetimeCard) {
lifetimeCard.classList.add('ring-2', 'ring-brand-sun/60');
lifetimeCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => lifetimeCard.classList.remove('ring-2', 'ring-brand-sun/60'), 2200);
}
}
})();
</script>
@@ -562,6 +657,16 @@
let currentOrderId = null;
let modalOpen = false;
let pollTimer = null;
const confirmReplacePending = async (btn) => {
if ((btn.dataset.hasPending || 'false') !== 'true') return true;
return window.dewemojiConfirm(
'You have a pending payment. Starting a new checkout will cancel the previous pending payment. Continue?',
{
title: 'Replace pending payment',
okText: 'Continue checkout',
}
);
};
const openModal = () => {
if (!modal) return;
@@ -657,6 +762,8 @@
window.location.href = "{{ route('login') }}";
return;
}
const proceed = await confirmReplacePending(btn);
if (!proceed) return;
const original = btn.dataset.original || btn.textContent;
btn.disabled = true;
btn.textContent = 'Generating QR...';
@@ -672,7 +779,16 @@
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.payment_number) {
alert('Could not generate QRIS. Please try again.');
if (data?.error === 'lifetime_active') {
alert('Lifetime plan is already active. Monthly/annual checkout is disabled.');
} else if (data?.error === 'pending_cooldown') {
if (window.dewemojiStartCheckoutCooldown) {
window.dewemojiStartCheckoutCooldown(Number(data.retry_after || 120));
}
alert(`Payment confirmation is in progress. Please wait ${Number(data.retry_after || 120)}s or continue pending from Billing.`);
} else {
alert('Could not generate QRIS. Please try again.');
}
btn.disabled = false;
btn.textContent = original;
return;

View File

@@ -8,7 +8,6 @@ use App\Http\Controllers\Api\V1\AdminSettingsController;
use App\Http\Controllers\Api\V1\AdminSubscriptionController;
use App\Http\Controllers\Api\V1\AdminAnalyticsController;
use App\Http\Controllers\Api\V1\AdminWebhookController;
use App\Http\Controllers\Api\V1\PaypalWebhookController;
use App\Http\Controllers\Api\V1\ExtensionController;
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserKeywordController;
@@ -55,6 +54,7 @@ Route::prefix('v1')->group(function () {
Route::get('/keywords', [UserKeywordController::class, 'index']);
Route::post('/keywords', [UserKeywordController::class, 'store']);
Route::put('/keywords/{id}', [UserKeywordController::class, 'update']);
Route::put('/keywords/{id}/active', [UserKeywordController::class, 'toggleActive']);
Route::delete('/keywords/{id}', [UserKeywordController::class, 'destroy']);
Route::post('/keywords/import', [UserKeywordController::class, 'import']);
Route::get('/keywords/export', [UserKeywordController::class, 'export']);
@@ -76,7 +76,8 @@ Route::prefix('v1')->group(function () {
Route::get('/admin/webhooks/{id}', [AdminWebhookController::class, 'show']);
Route::post('/admin/webhooks/{id}/replay', [AdminWebhookController::class, 'replay']);
Route::post('/paypal/webhook', [PaypalWebhookController::class, 'handle']);
// Keep /v1 alias for backward compatibility, but use the same canonical webhook handler.
Route::post('/paypal/webhook', [PayPalController::class, 'webhook']);
Route::get('/health', [SystemController::class, 'health']);
Route::get('/metrics-lite', [SystemController::class, 'metricsLite']);

View File

@@ -6,6 +6,9 @@ use Illuminate\Support\Facades\Schedule;
use App\Services\LiveSqlImportService;
use App\Services\Billing\PaypalWebhookProcessor;
use App\Services\Billing\PayPalPlanSyncService;
use App\Models\Order;
use App\Models\Payment;
use App\Models\Subscription;
use App\Models\WebhookEvent;
use Illuminate\Support\Facades\Mail;
use App\Mail\TestMailketing;
@@ -99,3 +102,20 @@ Artisan::command('mailketing:test {email : Recipient email address}', function (
return 1;
}
})->purpose('Send a Mailketing API test email');
Artisan::command('dewemoji:normalize-statuses', function () {
$subs = Subscription::query()
->where('status', 'cancelled')
->update(['status' => 'canceled']);
$orders = Order::query()
->where('status', 'cancelled')
->update(['status' => 'canceled']);
$payments = Payment::query()
->where('status', 'cancelled')
->update(['status' => 'canceled']);
$this->info("Normalized statuses: subscriptions={$subs}, orders={$orders}, payments={$payments}");
return 0;
})->purpose('Normalize legacy cancelled status spelling to canceled');

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Dashboard\AdminDashboardController;
use App\Http\Controllers\Dashboard\AdminEmojiCatalogController;
use App\Http\Controllers\Dashboard\UserDashboardController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
@@ -12,6 +13,7 @@ Route::middleware('auth')->prefix('dashboard')->name('dashboard.')->group(functi
Route::get('/keywords/search', [UserDashboardController::class, 'keywordSearch'])->name('keywords.search');
Route::post('/keywords', [UserDashboardController::class, 'storeKeyword'])->name('keywords.store');
Route::put('/keywords/{keyword}', [UserDashboardController::class, 'updateKeyword'])->name('keywords.update');
Route::put('/keywords/{keyword}/active', [UserDashboardController::class, 'toggleKeywordActive'])->name('keywords.toggle_active');
Route::delete('/keywords/{keyword}', [UserDashboardController::class, 'deleteKeyword'])->name('keywords.delete');
Route::post('/keywords/import', [UserDashboardController::class, 'importKeywords'])->name('keywords.import');
Route::get('/keywords/export', [UserDashboardController::class, 'exportKeywords'])->name('keywords.export');
@@ -41,6 +43,16 @@ Route::middleware('auth')->prefix('dashboard')->name('dashboard.')->group(functi
Route::post('/pricing/snapshot', [AdminDashboardController::class, 'createPricingSnapshot'])->name('pricing.snapshot');
Route::post('/pricing/paypal-sync', [AdminDashboardController::class, 'syncPaypalPlans'])->name('pricing.paypal_sync');
Route::get('/catalog', [AdminEmojiCatalogController::class, 'index'])->name('catalog');
Route::get('/catalog/create', [AdminEmojiCatalogController::class, 'create'])->name('catalog.create');
Route::get('/catalog/{emojiId}/edit', [AdminEmojiCatalogController::class, 'edit'])->whereNumber('emojiId')->name('catalog.edit');
Route::post('/catalog', [AdminEmojiCatalogController::class, 'store'])->name('catalog.store');
Route::put('/catalog/{emojiId}', [AdminEmojiCatalogController::class, 'update'])->whereNumber('emojiId')->name('catalog.update');
Route::delete('/catalog/{emojiId}', [AdminEmojiCatalogController::class, 'destroy'])->whereNumber('emojiId')->name('catalog.delete');
Route::post('/catalog/import-json', [AdminEmojiCatalogController::class, 'importCurrentJson'])->name('catalog.import_json');
Route::post('/catalog/publish', [AdminEmojiCatalogController::class, 'publish'])->name('catalog.publish');
Route::post('/catalog/snapshots/activate', [AdminEmojiCatalogController::class, 'activateSnapshot'])->name('catalog.snapshot.activate');
Route::get('/webhooks', [AdminDashboardController::class, 'webhooks'])->name('webhooks');
Route::post('/webhooks/{id}/replay', [AdminDashboardController::class, 'replayWebhook'])->name('webhooks.replay');
Route::post('/webhooks/replay-failed', [AdminDashboardController::class, 'replayFailedWebhooks'])->name('webhooks.replay_failed');

View File

@@ -4,6 +4,7 @@ use App\Http\Controllers\ProfileController;
use App\Http\Controllers\Web\SiteController;
use App\Http\Controllers\Billing\PayPalController;
use App\Http\Controllers\Billing\PakasirController;
use App\Http\Controllers\Billing\BillingPaymentController;
use Illuminate\Support\Facades\Route;
Route::get('/', [SiteController::class, 'home'])->name('home');
@@ -44,6 +45,9 @@ Route::middleware('auth')->group(function () {
Route::post('/billing/pakasir/status', [PakasirController::class, 'paymentStatus'])
->middleware('verified')
->name('billing.pakasir.status');
Route::post('/billing/payments/{payment}/resume', [BillingPaymentController::class, 'resume'])
->middleware('verified')
->name('billing.payments.resume');
Route::get('/dashboard/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/dashboard/profile', [ProfileController::class, 'update'])->name('profile.update');

View File

@@ -0,0 +1,129 @@
# Billing Cooldown Runtime Checklist (Staging)
## Scope
Validate these behaviors end-to-end:
1. `pending_cooldown` lock on new checkout (120s)
2. Continue pending payment via `Pay` button
3. PayPal/Pakasir webhook delay handling
4. Auto-status transition from `pending` to `paid`
## Preconditions
1. Staging is deployed with latest billing changes.
2. Config is refreshed:
- `php artisan optimize:clear`
- `php artisan config:cache`
3. Env includes:
- `DEWEMOJI_CHECKOUT_PENDING_COOLDOWN=120`
4. Webhooks are configured and reachable:
- PayPal webhook endpoint
- Pakasir webhook endpoint
5. Test account available (verified email, logged in).
## Quick Observability
1. Browser devtools open (`Network` tab).
2. Keep Billing page open in another tab for status checks.
3. Optional server logs tail:
- `tail -f storage/logs/laravel.log`
## Test Matrix
### A. Cooldown Lock (PayPal)
1. Start Personal checkout in USD.
2. Before webhook settles, try starting another checkout from Pricing.
3. Expected:
- API returns `409` with `error: pending_cooldown`.
- UI shows wait message with countdown (`retry_after` seconds).
- Checkout CTA stays disabled until countdown reaches 0.
### B. Cooldown Lock (Pakasir)
1. Start Personal checkout in IDR (QRIS modal opens).
2. Close/cancel modal only if needed for this test case.
3. Immediately try another new checkout from Pricing.
4. Expected:
- API returns `409 pending_cooldown`.
- UI countdown appears and blocks new checkout.
### C. Continue Pending (PayPal)
1. Create a PayPal pending payment.
2. Go to `/dashboard/billing`.
3. Click `Pay` on pending row.
4. Expected:
- Redirect to PayPal approve URL.
- No new payment row created just for resume action.
### D. Continue Pending (Pakasir)
1. Create a Pakasir pending payment.
2. Go to `/dashboard/billing`.
3. Click `Pay` on pending row.
4. Expected:
- QR modal opens with amount + expiry.
- Polling runs and closes modal when status becomes paid.
### E. Webhook Delay UX
1. Complete payment at provider.
2. Observe app while webhook is delayed.
3. Expected:
- Status may remain `pending` briefly.
- User can continue pending (`Pay` button).
- After webhook/poll settles: status updates to `paid`.
### F. Pending Timeout to New Checkout
1. Keep a payment pending beyond cooldown (`>=120s`).
2. Try new checkout from Pricing.
3. Expected:
- New checkout is allowed again.
- Existing behavior for replacing/canceling pending remains intact.
### G. Edge Responses
1. Try `Pay` on a row that is no longer pending (race condition).
2. Expected:
- UI handles response (`payment_not_pending`) and refreshes state safely.
3. If pending QR is expired:
- Expected `payment_expired`, prompt to start new checkout.
## API Assertions (Network)
Check response payloads:
1. Cooldown block:
```json
{
"error": "pending_cooldown",
"retry_after": 87,
"pending_payment_id": 123,
"provider": "paypal"
}
```
2. Resume PayPal:
```json
{
"ok": true,
"mode": "redirect",
"approve_url": "https://..."
}
```
3. Resume Pakasir:
```json
{
"ok": true,
"mode": "qris",
"payment_number": "...",
"order_id": "DW-...",
"expired_at": "..."
}
```
## Pass/Fail Sheet
| Check | Result | Notes |
|---|---|---|
| A. PayPal cooldown lock | | |
| B. Pakasir cooldown lock | | |
| C. Resume pending PayPal | | |
| D. Resume pending Pakasir | | |
| E. Webhook delay UX | | |
| F. New checkout after cooldown | | |
| G. Edge response handling | | |
## Exit Criteria
Release-ready when:
1. All rows above are `PASS`.
2. No uncaught JS errors in console.
3. No unexpected 5xx in network log during flow.

View File

@@ -0,0 +1,96 @@
# Sequel Ace + Coolify Staging MySQL (Reliable Access Guide)
This guide is for when Sequel Ace SSH mode fails against a Coolify-hosted app where MySQL is **internal-only**.
## Why Sequel Ace built-in SSH can fail on Coolify
In many Coolify setups, MySQL is not exposed on host `127.0.0.1:3306`.
It runs only inside Docker network (service name like `mysql`), so direct SSH-to-host + DB host `127.0.0.1` will fail.
## Recommended method (works with internal-only MySQL)
Use a terminal tunnel to the **current MySQL container IP**, then connect Sequel Ace to local `127.0.0.1:3307`.
## 1) On your Mac: get MySQL container IP from server
Replace:
- `SERVER_USER`
- `SERVER_HOST`
```bash
MYSQL_IP=$(ssh SERVER_USER@SERVER_HOST "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \$(docker ps --format '{{.ID}} {{.Names}}' | awk '/mysql|mariadb/{print \$1; exit}')")
echo "$MYSQL_IP"
```
If this prints an IP (example `172.18.0.5`), continue.
## 2) Create SSH tunnel (keep terminal open)
```bash
ssh -N -L 3307:${MYSQL_IP}:3306 SERVER_USER@SERVER_HOST
```
Keep this terminal open while using Sequel Ace.
## 3) Create Sequel Ace connection
Use **Standard** (not SSH) because tunnel is already created:
- Name: `Dewemoji Staging`
- Host: `127.0.0.1`
- Port: `3307`
- User: your MySQL user (example `dewesql`)
- Password: your MySQL password
- Database: `dewemoji`
Click **Test Connection** then **Connect**.
## 4) Verify database quickly
Run in Sequel Ace:
```sql
SHOW TABLES;
SELECT NOW();
```
## Troubleshooting
### A) `Connection refused`
- Tunnel terminal is closed, or command failed.
- Re-run step 2.
### B) `Access denied for user`
- Wrong DB username/password.
- Confirm app/coolify env values for DB credentials.
### C) `Unknown database`
- Wrong DB name.
- Check with:
```sql
SHOW DATABASES;
```
### D) No `MYSQL_IP` returned
- Server user cannot run Docker commands.
- Test manually:
```bash
ssh SERVER_USER@SERVER_HOST "docker ps --format '{{.Names}}'"
```
- If permission denied, use a user with Docker access.
### E) Tunnel worked yesterday but not today
- MySQL container IP changed after redeploy/restart.
- Re-run step 1 and step 2 each session.
## Optional: one-liner (resolve + tunnel)
```bash
ssh -N -L 3307:$(ssh SERVER_USER@SERVER_HOST "docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \$(docker ps --format '{{.ID}} {{.Names}}' | awk '/mysql|mariadb/{print \$1; exit}')"):3306 SERVER_USER@SERVER_HOST
```
## Security note
Do not expose MySQL publicly in Coolify just for GUI access.
Tunnel-only access is safer for staging and production.