page.tsx384 lines · main
| 1 | import { ActivityIcon } from '@/components/ui/activity'; |
| 2 | import { CreditCardIcon } from '@/components/ui/credit-card'; |
| 3 | import { LayoutGridIcon } from '@/components/ui/layout-grid'; |
| 4 | import { TriangleAlertIcon } from '@/components/ui/triangle-alert'; |
| 5 | import { UsersIcon } from '@/components/ui/users'; |
| 6 | |
| 7 | import { apiJson } from '@/lib/api'; |
| 8 | import { toValidDate } from '@/lib/utils'; |
| 9 | |
| 10 | import { AreaChart, type AreaChartPoint } from '../_components/area-chart'; |
| 11 | import { EmptyState } from '../_components/empty-state'; |
| 12 | import { Section } from '../_components/section'; |
| 13 | import { CountUp, StatCard } from '../_components/stat-card'; |
| 14 | |
| 15 | export const metadata = { title: 'revenue · admin' }; |
| 16 | export const dynamic = 'force-dynamic'; |
| 17 | |
| 18 | /* ─── payload type (mirrors /v1/admin/revenue) ───────────────────────────── */ |
| 19 | |
| 20 | interface 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. */ |
| 45 | function 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 | |
| 58 | function 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. */ |
| 70 | function monthToMs(month: string): number { |
| 71 | return Date.parse(`${month}-01T00:00:00Z`); |
| 72 | } |
| 73 | |
| 74 | /** ms → 'jun 25' style lowercase month label. */ |
| 75 | function formatMonth(x: number): string { |
| 76 | return new Date(x) |
| 77 | .toLocaleDateString(undefined, { month: 'short', year: '2-digit', timeZone: 'UTC' }) |
| 78 | .toLowerCase(); |
| 79 | } |
| 80 | |
| 81 | const 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 | |
| 92 | export 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't yet prove shows “—” 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 | |
| 218 | function 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. */ |
| 239 | function 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. */ |
| 266 | function 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 | |
| 280 | function 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 | |
| 321 | const 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). */ |
| 329 | function 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 | |
| 343 | function 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 | } |