Implement catalog CRUD overhaul, snapshot fallback activation, and billing/UX hardening
This commit is contained in:
@@ -128,6 +128,7 @@
|
||||
|
||||
<section id="plans" class="glass-card rounded-2xl p-6 scroll-mt-28">
|
||||
<h3 class="text-lg font-semibold">Plans & 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>
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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');
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user