page.tsx154 lines · main
1import Link from 'next/link';
2
3import { ArrowLeftRightIcon } from '@/components/ui/arrow-left-right';
4
5import { apiJson } from '@/lib/api';
6
7import { EmptyState } from '../../_components/empty-state';
8import { Section } from '../../_components/section';
9
10export const metadata = { title: 'admin · migration funnel' };
11export const dynamic = 'force-dynamic';
12
13interface FunnelRow {
14 source: string;
15 views: number;
16 leads: number;
17 conversion: number | null;
18}
19
20interface FunnelResponse {
21 rows: FunnelRow[];
22 totals: FunnelRow;
23 sinceDays: number;
24}
25
26const SOURCE_LABEL: Record<string, string> = {
27 hub: '/migrate (hub)',
28 convex: 'convex',
29 supabase: 'supabase',
30 firebase: 'firebase / firestore',
31 mongodb: 'mongodb',
32 drizzle: 'drizzle',
33 prisma: 'prisma',
34 postgres: 'raw postgres',
35 hasura: 'hasura',
36 nextauth: 'nextauth / auth.js',
37 other: 'other / not listed',
38 all: 'totals',
39};
40
41function formatPercent(c: number | null): string {
42 if (c === null) return '—';
43 return `${(c * 100).toFixed(1)}%`;
44}
45
46function formatNum(n: number): string {
47 return n.toLocaleString('en-US');
48}
49
50export default async function MigrationFunnelPage({
51 searchParams,
52}: {
53 searchParams: Promise<{ days?: string }>;
54}) {
55 const params = await searchParams;
56 const days = Math.max(1, Math.min(365, Number(params.days) || 30));
57 const data = await apiJson<FunnelResponse>(`/v1/admin/marketing-funnel?days=${days}`).catch((): FunnelResponse => ({ rows: [], totals: { source: 'all', views: 0, leads: 0, conversion: null }, sinceDays: days }));
58
59 return (
60 <div className="flex flex-col gap-10">
61 <header className="flex flex-col gap-2">
62 <p className="font-mono text-xs text-[var(--color-text-muted)]">
63 <Link href="/admin/migrations" className="hover:text-[var(--color-text)]">
64 ← migration triage
65 </Link>
66 </p>
67 <div className="flex items-center gap-2">
68 <span className="text-[var(--color-primary)]">
69 <ArrowLeftRightIcon size={20} />
70 </span>
71 <h1 className="font-mono text-xl tracking-tight">migration funnel</h1>
72 </div>
73 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
74 per-source views (anyone landing on /migrate or /migrate/&lt;source&gt;) and
75 leads submitted through the public form on those pages. lead-counts are
76 server-side, so a forged claim from the public POST can&apos;t inflate them.
77 window: last {days} days.
78 </p>
79 </header>
80
81 <Section
82 title={`views → leads · last ${days}d`}
83 icon={<ArrowLeftRightIcon size={16} />}
84 right={
85 <div className="flex gap-2">
86 {[7, 30, 90].map((d) => (
87 <Link
88 key={d}
89 href={`?days=${d}`}
90 className={`rounded-full border px-3 py-1 font-mono text-[10px] transition-colors ${
91 d === days
92 ? 'border-[var(--color-primary)] bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
93 : 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]'
94 }`}
95 >
96 {d}d
97 </Link>
98 ))}
99 </div>
100 }
101 >
102 {data.rows.length === 0 ? (
103 <EmptyState
104 icon={<ArrowLeftRightIcon size={28} />}
105 title="no funnel events yet"
106 message={`no events recorded in the last ${days} days yet — views and leads appear here as visitors reach the /migrate pages.`}
107 />
108 ) : (
109 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
110 <table className="w-full font-mono text-xs">
111 <thead className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
112 <tr>
113 <th className="px-6 py-4 text-left font-medium">source</th>
114 <th className="px-6 py-4 text-right font-medium">views</th>
115 <th className="px-6 py-4 text-right font-medium">leads</th>
116 <th className="px-6 py-4 text-right font-medium">conv.</th>
117 </tr>
118 </thead>
119 <tbody>
120 {data.rows.map((row) => (
121 <tr
122 key={row.source}
123 className="border-t border-[var(--color-border-subtle)] text-[var(--color-text-muted)]"
124 >
125 <td className="px-6 py-4 text-[var(--color-text)]">
126 {SOURCE_LABEL[row.source] ?? row.source}
127 </td>
128 <td className="px-6 py-4 text-right">{formatNum(row.views)}</td>
129 <td className="px-6 py-4 text-right">{formatNum(row.leads)}</td>
130 <td className="px-6 py-4 text-right text-[var(--color-text)]">
131 {formatPercent(row.conversion)}
132 </td>
133 </tr>
134 ))}
135 <tr className="border-t-2 border-[var(--color-border)] font-medium text-[var(--color-text)]">
136 <td className="px-6 py-4">{SOURCE_LABEL.all}</td>
137 <td className="px-6 py-4 text-right">{formatNum(data.totals.views)}</td>
138 <td className="px-6 py-4 text-right">{formatNum(data.totals.leads)}</td>
139 <td className="px-6 py-4 text-right">{formatPercent(data.totals.conversion)}</td>
140 </tr>
141 </tbody>
142 </table>
143 </div>
144 )}
145 </Section>
146
147 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
148 views are tracked client-side via a pageview beacon to /v1/marketing-events
149 (rate-limited 30/min per IP). leads are tracked server-side from POST
150 /v1/migration-leads on success only.
151 </p>
152 </div>
153 );
154}