Files
SkinbaseNova/resources/js/Pages/Admin/Academy/Billing.jsx

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>
)
}