Files
dewemoji/app/resources/views/dashboard/user/billing.blade.php

447 lines
21 KiB
PHP

@extends('dashboard.app')
@section('title', 'Billing')
@section('page_title', 'Billing')
@section('page_subtitle', 'Subscription status and plan details.')
@section('dashboard_content')
@php
$user = $user ?? auth()->user();
$subscription = $subscription ?? null;
$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) {
'personal_monthly' => 'Personal Monthly',
'personal_annual' => 'Personal Annual',
'personal_lifetime' => 'Personal Lifetime',
'free' => 'Free',
'' => 'Free',
default => \Illuminate\Support\Str::of($value)->replace('_', ' ')->title(),
};
};
@endphp
<div class="grid gap-6 lg:grid-cols-3">
<div class="rounded-3xl glass-card p-6">
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Plan</div>
<div class="mt-3 text-3xl font-semibold text-white">{{ $hasSub ? $formatPlan($subscription->plan ?? 'Personal') : 'Free' }}</div>
<div class="mt-2 text-sm text-gray-400">{{ $hasSub ? ucfirst($subscription->status ?? 'active') : 'No subscription' }}</div>
</div>
<div class="rounded-3xl glass-card p-6">
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Renewal</div>
<div class="mt-3 text-2xl font-semibold text-white">
{{ $subscription?->next_renewal_at?->toDateString() ?? '—' }}
</div>
<div class="mt-2 text-sm text-gray-400">Next charge date</div>
</div>
<div class="rounded-3xl glass-card p-6">
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Status</div>
<div class="mt-3 text-2xl font-semibold text-white">
{{ $subscription?->expires_at?->toDateString() ?? 'Active' }}
</div>
<div class="mt-2 text-sm text-gray-400">Access ends</div>
</div>
</div>
<div class="mt-8 rounded-3xl glass-card p-6">
<div class="flex items-center justify-between">
<div>
<div class="text-xs uppercase tracking-[0.25em] text-gray-400">Billing summary</div>
<div class="mt-2 text-xl font-semibold text-white">Subscription details</div>
</div>
<span class="rounded-full border border-white/10 px-3 py-1 text-xs text-gray-300 theme-surface">Current cycle</span>
</div>
<div class="mt-4 text-sm text-gray-300">
@if ($hasSub)
Provider: {{ $subscription->provider ?? 'direct' }} · Started {{ $subscription->started_at?->toDateString() }}
@else
You are on the free plan. Upgrade to unlock private keywords and sync.
@endif
</div>
<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">
<div class="text-sm font-semibold text-gray-200">Payment method</div>
<div class="mt-2 text-2xl font-semibold text-white">Card</div>
<div class="mt-1 text-xs text-gray-400">Coming soon</div>
</div>
<div class="rounded-2xl bg-white/5 border border-white/10 p-4">
<div class="text-sm font-semibold text-gray-200">Invoices</div>
<div class="mt-2 text-2xl font-semibold text-white">0</div>
<div class="mt-1 text-xs text-gray-400">Billing history</div>
</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>
<div class="mt-3 overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full text-sm text-gray-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-gray-400">
<tr>
<th class="px-4 py-3 text-left">Provider</th>
<th class="px-4 py-3 text-left">Plan</th>
<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">
@foreach ($payments as $payment)
@php
$status = $payment->status ?? 'pending';
$pill = $status === 'paid'
? ['bg' => 'bg-emerald-100 dark:bg-emerald-500/20', 'text' => 'text-emerald-800 dark:text-emerald-200']
: ($status === 'failed'
? ['bg' => 'bg-rose-100 dark:bg-rose-500/20', 'text' => 'text-rose-800 dark:text-rose-200']
: ['bg' => 'bg-amber-100 dark:bg-amber-500/20', 'text' => 'text-amber-800 dark:text-amber-200']);
@endphp
<tr>
<td class="px-4 py-3">{{ $payment->provider ?? '—' }}</td>
<td class="px-4 py-3">{{ $formatPlan($payment->plan_code) }}</td>
<td class="px-4 py-3">{{ $payment->currency ?? 'USD' }} {{ number_format((float) ($payment->amount ?? 0), 2) }}</td>
<td class="px-4 py-3">
<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>
</table>
</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">
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
</a>
@endif
</div>
@endif
@if (!$hasSub || (string) $user?->tier !== 'personal')
<div class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-brand-sun/30 dark:bg-brand-sun/10 dark:text-brand-sun">
Upgrade to Personal for private keywords and synced personalization.
</div>
<a href="{{ route('pricing') }}" class="mt-4 inline-flex items-center justify-center rounded-full bg-brand-sun text-black font-semibold px-4 py-2 text-sm">
Upgrade to Personal
</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