206 lines
10 KiB
JavaScript
206 lines
10 KiB
JavaScript
import React from 'react'
|
|
import { Head, Link } from '@inertiajs/react'
|
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
|
|
|
function StatCard({ label, value, hint = null }) {
|
|
return (
|
|
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
|
<p className="mt-3 text-3xl font-bold text-white">{value.toLocaleString()}</p>
|
|
{hint ? <p className="mt-2 text-sm text-slate-400">{hint}</p> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatTimestamp(value) {
|
|
if (!value) return 'No webhook processed yet'
|
|
|
|
try {
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(new Date(value))
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
function formatEventSummary(summary) {
|
|
const payload = summary && typeof summary === 'object' ? summary : {}
|
|
const preferredKeys = [
|
|
'action',
|
|
'outcome',
|
|
'local_subscription_status',
|
|
'status',
|
|
'tracked',
|
|
'user_resolved',
|
|
]
|
|
|
|
const prioritized = preferredKeys
|
|
.filter((key) => Object.prototype.hasOwnProperty.call(payload, key))
|
|
.map((key) => [key, payload[key]])
|
|
|
|
const priceIds = Array.isArray(payload.price_ids) && payload.price_ids.length
|
|
? [['price_ids', payload.price_ids.join(', ')]]
|
|
: []
|
|
|
|
const cacheCleared = typeof payload.cache_cleared === 'boolean'
|
|
? [['cache_cleared', payload.cache_cleared ? 'yes' : 'no']]
|
|
: []
|
|
|
|
const lines = [...prioritized, ...priceIds, ...cacheCleared]
|
|
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
|
.slice(0, 4)
|
|
|
|
return lines.length
|
|
? lines.map(([key, value]) => `${key}: ${String(value)}`).join(' · ')
|
|
: 'No summary fields captured'
|
|
}
|
|
|
|
export default function AcademyBilling({ summary, planBreakdown, recentEvents, links }) {
|
|
const missingPlans = Array.isArray(summary.missing_plan_keys) ? summary.missing_plan_keys : []
|
|
const noData =
|
|
summary.enabled &&
|
|
(summary.active_subscribers || 0) === 0 &&
|
|
(summary.ended_subscriptions || 0) === 0 &&
|
|
(summary.recent_webhook_count || 0) === 0
|
|
|
|
return (
|
|
<AdminLayout title="Academy Billing" subtitle="Moderation overview of Academy subscriptions, Stripe webhook sync activity, and plan readiness.">
|
|
<Head title="Admin · Academy Billing" />
|
|
|
|
{noData ? (
|
|
<div className="mb-6 rounded-2xl border border-sky-300/20 bg-sky-300/[0.06] px-5 py-4 text-sm text-sky-100">
|
|
<p className="font-semibold">No subscriber data in the database yet.</p>
|
|
<p className="mt-1 text-sky-100/70">
|
|
Subscription records are created when Stripe sends webhook events to this server after a completed checkout. In local development, use{' '}
|
|
<code className="rounded bg-black/30 px-1.5 py-0.5 font-mono text-xs">stripe listen --forward-to {window.location.origin}/stripe/webhook</code>{' '}
|
|
to forward events. On production, confirm the Stripe webhook is configured and active.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
<StatCard label="Active Subscribers" value={summary.active_subscribers || 0} />
|
|
<StatCard label="Creator Subscribers" value={summary.creator_subscribers || 0} />
|
|
<StatCard label="Pro Subscribers" value={summary.pro_subscribers || 0} />
|
|
<StatCard label="Grace Period" value={summary.grace_period_subscribers || 0} hint="Canceled subscriptions that still keep access until the billing period ends." />
|
|
</div>
|
|
|
|
<div className="mt-8 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
|
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Plan Health</p>
|
|
<h2 className="mt-2 text-xl font-semibold text-white">Configured Academy plans</h2>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Link href={links.dashboard} className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/15 hover:bg-white/[0.06] hover:text-white">
|
|
Dashboard
|
|
</Link>
|
|
<Link href={links.pricing} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/30 hover:bg-sky-300/15">
|
|
Public pricing
|
|
</Link>
|
|
<Link href={links.account} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-300/30 hover:bg-emerald-300/15">
|
|
My billing account
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{missingPlans.length ? (
|
|
<div className="mt-5 rounded-2xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm text-amber-100">
|
|
Missing Stripe price IDs for: {missingPlans.join(', ')}
|
|
</div>
|
|
) : (
|
|
<div className="mt-5 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">
|
|
All configured Academy plans have Stripe price IDs.
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
|
{planBreakdown.map((plan) => (
|
|
<div key={plan.key} className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-base font-semibold text-white">{plan.label}</p>
|
|
<p className="mt-1 text-sm text-slate-400">{plan.tier} · {plan.interval}</p>
|
|
</div>
|
|
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${plan.configured ? 'bg-emerald-300/12 text-emerald-100' : 'bg-amber-300/12 text-amber-100'}`}>
|
|
{plan.configured ? 'configured' : 'missing'}
|
|
</span>
|
|
</div>
|
|
<p className="mt-4 text-3xl font-bold text-white">{(plan.subscribers || 0).toLocaleString()}</p>
|
|
<p className="mt-1 text-sm text-slate-400">active subscriptions on this plan</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Webhook Sync</p>
|
|
<h2 className="mt-2 text-xl font-semibold text-white">Recent Stripe activity</h2>
|
|
|
|
<div className="mt-5 space-y-3">
|
|
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
|
<p className="text-sm text-slate-400">Billing enabled</p>
|
|
<p className="mt-1 text-lg font-semibold text-white">{summary.enabled ? 'Yes' : 'No'}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
|
<p className="text-sm text-slate-400">Webhook audits stored</p>
|
|
<p className="mt-1 text-lg font-semibold text-white">{(summary.recent_webhook_count || 0).toLocaleString()}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
|
<p className="text-sm text-slate-400">Last processed webhook</p>
|
|
<p className="mt-1 text-lg font-semibold text-white">{formatTimestamp(summary.last_webhook_at)}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
|
<p className="text-sm text-slate-400">Ended subscriptions</p>
|
|
<p className="mt-1 text-lg font-semibold text-white">{(summary.ended_subscriptions || 0).toLocaleString()}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section className="mt-8 rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Audit Trail</p>
|
|
<h2 className="mt-2 text-xl font-semibold text-white">Latest academy billing events</h2>
|
|
</div>
|
|
<p className="text-sm text-slate-400">Only the safe local summary is stored, not the raw Stripe payload.</p>
|
|
</div>
|
|
|
|
<div className="mt-5 overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-white/[0.08] text-sm text-slate-300">
|
|
<thead>
|
|
<tr className="text-left text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
|
|
<th className="px-3 py-3">Event</th>
|
|
<th className="px-3 py-3">Plan</th>
|
|
<th className="px-3 py-3">Tier</th>
|
|
<th className="px-3 py-3">User</th>
|
|
<th className="px-3 py-3">Processed</th>
|
|
<th className="px-3 py-3">Summary</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/[0.06]">
|
|
{recentEvents.length ? recentEvents.map((event) => (
|
|
<tr key={event.id}>
|
|
<td className="px-3 py-3 font-medium text-white">{event.event_type}</td>
|
|
<td className="px-3 py-3">{event.academy_plan || 'n/a'}</td>
|
|
<td className="px-3 py-3">{event.academy_tier || 'n/a'}</td>
|
|
<td className="px-3 py-3">{event.user_id || 'guest/unresolved'}</td>
|
|
<td className="px-3 py-3">{formatTimestamp(event.processed_at || event.created_at)}</td>
|
|
<td className="px-3 py-3 text-slate-400">{formatEventSummary(event.payload_summary)}</td>
|
|
</tr>
|
|
)) : (
|
|
<tr>
|
|
<td colSpan="6" className="px-3 py-6 text-center text-slate-400">No Academy billing webhook audits have been stored yet.</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</AdminLayout>
|
|
)
|
|
} |