page.tsx384 lines · main
1import { ActivityIcon } from '@/components/ui/activity';
2import { CreditCardIcon } from '@/components/ui/credit-card';
3import { LayoutGridIcon } from '@/components/ui/layout-grid';
4import { TriangleAlertIcon } from '@/components/ui/triangle-alert';
5import { UsersIcon } from '@/components/ui/users';
6
7import { apiJson } from '@/lib/api';
8import { toValidDate } from '@/lib/utils';
9
10import { AreaChart, type AreaChartPoint } from '../_components/area-chart';
11import { EmptyState } from '../_components/empty-state';
12import { Section } from '../_components/section';
13import { CountUp, StatCard } from '../_components/stat-card';
14
15export const metadata = { title: 'revenue · admin' };
16export const dynamic = 'force-dynamic';
17
18/* ─── payload type (mirrors /v1/admin/revenue) ───────────────────────────── */
19
20interface Revenue {
21 connected: boolean;
22 currency: 'EUR';
23 mrr: number | null;
24 planMix: { free: number; pro: number; team: number };
25 activeSubscriptions: Array<{
26 orgId: string;
27 orgName: string;
28 tier: string;
29 status: string;
30 since: string;
31 currentPeriodEnd: string | null;
32 }>;
33 meteredUsage: Array<{
34 metric: string;
35 period: string;
36 quantity: number;
37 unit: string;
38 pushStatus: string;
39 }>;
40 monthlyTimeline: Array<{ month: string; invocations: number; storageRows: number }>;
41 note: string;
42}
43
44/** ISO currency code → display symbol, falling back to the code itself. */
45function currencySymbol(code: string | null): string {
46 switch (code) {
47 case 'EUR':
48 return '€';
49 case 'USD':
50 return '$';
51 case 'GBP':
52 return '£';
53 default:
54 return code ? `${code} ` : '';
55 }
56}
57
58function fmtDate(iso: string | null): string {
59 if (!iso) return '—';
60 const d = toValidDate(iso);
61 if (!d) return '—';
62 return d.toLocaleDateString(undefined, {
63 year: 'numeric',
64 month: 'short',
65 day: 'numeric',
66 });
67}
68
69/** 'YYYY-MM' → midnight-UTC ms of the first of that month, or NaN if unparseable. */
70function monthToMs(month: string): number {
71 return Date.parse(`${month}-01T00:00:00Z`);
72}
73
74/** ms → 'jun 25' style lowercase month label. */
75function formatMonth(x: number): string {
76 return new Date(x)
77 .toLocaleDateString(undefined, { month: 'short', year: '2-digit', timeZone: 'UTC' })
78 .toLowerCase();
79}
80
81const REVENUE_FALLBACK: Revenue = {
82 connected: false,
83 currency: 'EUR',
84 mrr: null,
85 planMix: { free: 0, pro: 0, team: 0 },
86 activeSubscriptions: [],
87 meteredUsage: [],
88 monthlyTimeline: [],
89 note: 'revenue data unavailable — the api didn’t answer or Mavi Pay is not wired up yet.',
90};
91
92export default async function AdminRevenuePage() {
93 const data = await apiJson<Revenue>('/v1/admin/revenue').catch(() => REVENUE_FALLBACK);
94
95 // Drop unparseable months instead of feeding NaN coordinates to the chart —
96 // one odd bucket must never take the whole view down.
97 const timeline = (data.monthlyTimeline ?? []).filter((m) =>
98 Number.isFinite(monthToMs(m.month)),
99 );
100 const invocationSeries: AreaChartPoint[] = timeline.map((m) => ({
101 x: monthToMs(m.month),
102 y: Number(m.invocations) || 0,
103 }));
104 const storageSeries: AreaChartPoint[] = timeline.map((m) => ({
105 x: monthToMs(m.month),
106 y: Number(m.storageRows) || 0,
107 }));
108
109 return (
110 <div className="flex flex-col gap-10">
111 <header className="flex flex-col gap-2">
112 <div className="flex items-center gap-2">
113 <span className="text-[var(--color-primary)]">
114 <CreditCardIcon size={20} />
115 </span>
116 <h1 className="font-mono text-xl tracking-tight">revenue</h1>
117 </div>
118 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
119 the money view — mrr, subscriptions, and what will bill, wired to Mavi Pay. real numbers
120 only; anything we can&apos;t yet prove shows &ldquo;—&rdquo; rather than a fake zero.
121 </p>
122 </header>
123
124 {/* ── honest banner (only when the engine isn't connected) ──────── */}
125 {!data.connected ? (
126 <div className="flex items-start gap-3 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
127 <span className="mt-0.5 shrink-0 text-[var(--color-warning)]">
128 <TriangleAlertIcon size={16} />
129 </span>
130 <div className="flex flex-col gap-1.5">
131 <p className="font-mono text-sm text-[var(--color-text)]">
132 revenue engine not connected
133 </p>
134 <p className="font-mono text-xs text-[var(--color-text-muted)]">{data.note}</p>
135 </div>
136 </div>
137 ) : null}
138
139 {/* ── top numbers ──────────────────────────────────────────────── */}
140 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
141 <StatCard
142 label="mrr"
143 value={data.mrr}
144 prefix={currencySymbol(data.currency)}
145 decimals={data.mrr !== null && !Number.isInteger(data.mrr) ? 2 : 0}
146 icon={<CreditCardIcon size={14} />}
147 tone="primary"
148 hint="monthly recurring revenue"
149 waitingOn="Mavi Pay not connected"
150 />
151 <StatCard
152 label="active subscriptions"
153 value={data.activeSubscriptions.length}
154 icon={<UsersIcon size={14} />}
155 hint="non-canceled subscriptions"
156 />
157 <PlanMixCard planMix={data.planMix} />
158 </div>
159
160 {/* ── what will bill (metered usage) ───────────────────────────── */}
161 <Section
162 title="what will bill"
163 icon={<LayoutGridIcon size={16} />}
164 right={
165 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
166 metered — bills when Mavi Pay connects
167 </span>
168 }
169 >
170 {data.meteredUsage.length === 0 ? (
171 <EmptyState
172 icon={<LayoutGridIcon size={24} />}
173 title="no metered usage yet"
174 message="metered line-items appear here as the platform records billable usage."
175 />
176 ) : (
177 <MeteredUsageTable rows={data.meteredUsage} />
178 )}
179 </Section>
180
181 {/* ── monthly timeline (6 months) ──────────────────────────────── */}
182 <Section title="monthly · 6 months" icon={<ActivityIcon size={16} />}>
183 <div className="grid grid-cols-1 gap-6">
184 <TimelineChartCard
185 label="invocations"
186 data={invocationSeries}
187 ariaLabel="function invocations per month"
188 />
189 <TimelineChartCard
190 label="storage rows"
191 data={storageSeries}
192 ariaLabel="storage rows per month"
193 />
194 </div>
195 </Section>
196
197 {/* ── active subscriptions ─────────────────────────────────────── */}
198 <Section
199 title={`active subscriptions · ${data.activeSubscriptions.length}`}
200 icon={<UsersIcon size={16} />}
201 >
202 {data.activeSubscriptions.length === 0 ? (
203 <EmptyState
204 icon={<CreditCardIcon size={24} />}
205 title="no active subscriptions yet"
206 message="subscriptions appear here the moment Mavi Pay records one — no placeholder rows in the meantime."
207 />
208 ) : (
209 <SubscriptionTable rows={data.activeSubscriptions} />
210 )}
211 </Section>
212 </div>
213 );
214}
215
216/* ─── small pieces ───────────────────────────────────────────────────────── */
217
218function PlanMixCard({ planMix }: { planMix: { free: number; pro: number; team: number } }) {
219 return (
220 <div className="flex h-full flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
221 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
222 plan mix
223 </p>
224 <dl className="flex flex-col gap-2 font-mono text-sm">
225 {(['free', 'pro', 'team'] as const).map((tier) => (
226 <div key={tier} className="flex items-center justify-between">
227 <dt className="text-[var(--color-text-muted)]">{tier}</dt>
228 <dd className="text-[var(--color-text)]">
229 <CountUp value={planMix[tier]} />
230 </dd>
231 </div>
232 ))}
233 </dl>
234 </div>
235 );
236}
237
238/** One 6-month timeline series in a surface card, matching the overview cards. */
239function TimelineChartCard({
240 label,
241 data,
242 ariaLabel,
243}: {
244 label: string;
245 data: AreaChartPoint[];
246 ariaLabel: string;
247}) {
248 return (
249 <div className="flex flex-col gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
250 <p className="font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
251 {label}
252 </p>
253 <AreaChart
254 data={data}
255 height={200}
256 yFormat={(y) => y.toLocaleString('en-US', { maximumFractionDigits: 0 })}
257 xFormat={formatMonth}
258 ariaLabel={ariaLabel}
259 pendingLabel="not enough monthly history yet."
260 />
261 </div>
262 );
263}
264
265/** Push status → pill tone. Tolerant of free-form (or missing) status strings. */
266function pushTone(status: string | null | undefined): string {
267 const s = String(status ?? '').toLowerCase();
268 if (/push|sent|ok|synced/.test(s)) {
269 return 'text-[var(--color-success)] border-[var(--color-success)]';
270 }
271 if (/pending|queued/.test(s)) {
272 return 'text-[var(--color-warning)] border-[var(--color-warning)]';
273 }
274 if (/error|failed/.test(s)) {
275 return 'text-[var(--color-error)] border-[var(--color-error)]';
276 }
277 return 'text-[var(--color-text-subtle)] border-[var(--color-border-subtle)]';
278}
279
280function MeteredUsageTable({ rows }: { rows: Revenue['meteredUsage'] }) {
281 return (
282 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
283 <table className="w-full border-collapse font-mono text-xs">
284 <thead>
285 <tr className="border-b border-[var(--color-border-subtle)] text-left text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
286 <th className="px-6 py-3 font-normal">metric</th>
287 <th className="px-6 py-3 font-normal">period</th>
288 <th className="px-6 py-3 font-normal">quantity</th>
289 <th className="px-6 py-3 font-normal">push status</th>
290 </tr>
291 </thead>
292 <tbody>
293 {rows.map((r, i) => (
294 <tr
295 key={`${r.metric}-${r.period}-${i}`}
296 className="border-b border-[var(--color-border-subtle)] transition-colors last:border-0 hover:bg-[var(--color-surface-raised)]"
297 >
298 <td className="px-6 py-4 text-[var(--color-text)]">{r.metric}</td>
299 <td className="px-6 py-4 text-[var(--color-text-muted)]">{r.period}</td>
300 <td className="whitespace-nowrap px-6 py-4 text-[var(--color-text)]">
301 {(Number(r.quantity) || 0).toLocaleString()}{' '}
302 <span className="text-[var(--color-text-subtle)]">{r.unit}</span>
303 </td>
304 <td className="px-6 py-4">
305 <span
306 className={`rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-wider ${pushTone(
307 r.pushStatus,
308 )}`}
309 >
310 {String(r.pushStatus ?? 'pending').replace(/_/g, ' ')}
311 </span>
312 </td>
313 </tr>
314 ))}
315 </tbody>
316 </table>
317 </div>
318 );
319}
320
321const SUB_STATUS_TONE: Record<string, string> = {
322 active: 'text-[var(--color-success)] border-[var(--color-success)]',
323 trialing: 'text-[var(--color-text-link)] border-[var(--color-border-strong)]',
324 past_due: 'text-[var(--color-warning)] border-[var(--color-warning)]',
325 canceled: 'text-[var(--color-text-subtle)] border-[var(--color-border-subtle)]',
326};
327
328/** Tolerant status badge — status is a free string per the contract (may be missing). */
329function SubStatusBadge({ status }: { status: string | null | undefined }) {
330 const label = String(status ?? 'unknown');
331 const tone =
332 SUB_STATUS_TONE[label.toLowerCase()] ??
333 'text-[var(--color-text-subtle)] border-[var(--color-border-subtle)]';
334 return (
335 <span
336 className={`rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-wider ${tone}`}
337 >
338 {label.replace(/_/g, ' ')}
339 </span>
340 );
341}
342
343function SubscriptionTable({ rows }: { rows: Revenue['activeSubscriptions'] }) {
344 return (
345 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
346 <table className="w-full border-collapse font-mono text-xs">
347 <thead>
348 <tr className="border-b border-[var(--color-border-subtle)] text-left text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
349 <th className="px-6 py-3 font-normal">org</th>
350 <th className="px-6 py-3 font-normal">tier</th>
351 <th className="px-6 py-3 font-normal">status</th>
352 <th className="px-6 py-3 font-normal">since</th>
353 <th className="px-6 py-3 font-normal">renews</th>
354 </tr>
355 </thead>
356 <tbody>
357 {rows.map((r) => (
358 <tr
359 key={r.orgId}
360 className="border-b border-[var(--color-border-subtle)] transition-colors last:border-0 hover:bg-[var(--color-surface-raised)]"
361 >
362 <td className="px-6 py-4">
363 <div className="flex flex-col gap-0.5">
364 <span className="text-[var(--color-text)]">{r.orgName}</span>
365 <span className="text-[10px] text-[var(--color-text-subtle)]">{r.orgId}</span>
366 </div>
367 </td>
368 <td className="px-6 py-4">
369 <span className="text-[var(--color-primary)]">{r.tier}</span>
370 </td>
371 <td className="px-6 py-4">
372 <SubStatusBadge status={r.status} />
373 </td>
374 <td className="px-6 py-4 text-[var(--color-text-muted)]">{fmtDate(r.since)}</td>
375 <td className="px-6 py-4 text-[var(--color-text-muted)]">
376 {fmtDate(r.currentPeriodEnd)}
377 </td>
378 </tr>
379 ))}
380 </tbody>
381 </table>
382 </div>
383 );
384}