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

@@ -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