page.tsx274 lines · main
1import Link from 'next/link';
2
3import { GlobeIcon } from '@/components/ui/globe';
4
5import { apiJson } from '@/lib/api';
6
7import { EmptyState } from '../_components/empty-state';
8import { Section } from '../_components/section';
9import { StatCard } from '../_components/stat-card';
10
11export const metadata = { title: 'admin · sign-ups · geo (SEO)' };
12export const dynamic = 'force-dynamic';
13
14interface CountryCount {
15 country: string | null;
16 count: number;
17}
18interface CityCount {
19 country: string | null;
20 city: string | null;
21 count: number;
22}
23interface RecentSignup {
24 id: string;
25 projectId: string;
26 userId: string | null;
27 email: string | null;
28 ip: string | null;
29 country: string | null;
30 city: string | null;
31 region: string | null;
32 createdAt: string;
33}
34interface SignupGeoSummary {
35 total: number;
36 byCountry: CountryCount[];
37 byCity: CityCount[];
38 recent: RecentSignup[];
39 sinceDays: number;
40 projectId: string | null;
41 geoPending: boolean;
42}
43
44const EMPTY: SignupGeoSummary = {
45 total: 0,
46 byCountry: [],
47 byCity: [],
48 recent: [],
49 sinceDays: 30,
50 projectId: null,
51 geoPending: false,
52};
53
54function formatNum(n: number): string {
55 return n.toLocaleString('en-US');
56}
57function formatPercent(part: number, whole: number): string {
58 if (whole <= 0) return '—';
59 return `${((part / whole) * 100).toFixed(1)}%`;
60}
61function formatWhen(iso: string): string {
62 const d = new Date(iso);
63 return d.toLocaleString('en-US', {
64 month: 'short',
65 day: 'numeric',
66 hour: '2-digit',
67 minute: '2-digit',
68 });
69}
70
71export default async function SignupGeoPage({
72 searchParams,
73}: {
74 searchParams: Promise<{ days?: string; projectId?: string }>;
75}) {
76 const params = await searchParams;
77 const days = Math.max(1, Math.min(365, Number(params.days) || 30));
78 const projectId = params.projectId?.trim() || '';
79
80 const qs = new URLSearchParams({ days: String(days) });
81 if (projectId) qs.set('projectId', projectId);
82 const data = await apiJson<SignupGeoSummary>(
83 `/v1/admin/auth/signups/geo?${qs.toString()}`,
84 ).catch((): SignupGeoSummary => ({ ...EMPTY, sinceDays: days }));
85
86 return (
87 <div className="flex flex-col gap-10">
88 <header className="flex flex-col gap-2">
89 <div className="flex items-center gap-2">
90 <span className="text-[var(--color-primary)]">
91 <GlobeIcon size={20} />
92 </span>
93 <h1 className="font-mono text-xl tracking-tight">sign-ups · geo (SEO)</h1>
94 </div>
95 <p className="max-w-prose font-mono text-sm text-[var(--color-text-muted)]">
96 where end-user sign-ups come from, across every briven-auth project —
97 raw ip + country / city / region, captured at sign-up in the control
98 plane. this is the platform-wide SEO analytics feed (admin-only); the
99 per-project customer users page never shows any of this. window: last{' '}
100 {days} days.
101 </p>
102 </header>
103
104 {data.geoPending ? (
105 <div className="rounded-xl border border-[var(--color-warning)] bg-[var(--color-surface)] px-6 py-4">
106 <p className="font-mono text-sm text-[var(--color-warning)]">
107 geo pending — GeoLite2 database not yet installed on the server
108 </p>
109 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
110 sign-up ip addresses are being recorded, but country / city / region
111 are all blank because the GeoLite2-City .mmdb file isn&apos;t on the
112 server yet. once ops sets BRIVEN_GEOIP_DB_PATH and provisions the
113 file, new sign-ups will resolve to a location.
114 </p>
115 </div>
116 ) : null}
117
118 <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
119 <StatCard
120 label={`total sign-ups · ${days}d`}
121 value={data.total}
122 icon={<GlobeIcon size={14} />}
123 />
124 <StatCard
125 label="countries"
126 value={data.byCountry.filter((r) => r.country).length}
127 />
128 <StatCard
129 label="cities"
130 value={data.byCity.filter((r) => r.city).length}
131 />
132 </div>
133
134 <Section
135 title={`by country · last ${days}d`}
136 icon={<GlobeIcon size={16} />}
137 right={
138 <div className="flex gap-2">
139 {[7, 30, 90].map((d) => (
140 <Link
141 key={d}
142 href={projectId ? `?days=${d}&projectId=${projectId}` : `?days=${d}`}
143 className={`rounded-full border px-3 py-1 font-mono text-[10px] transition-colors ${
144 d === days
145 ? 'border-[var(--color-primary)] bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
146 : 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]'
147 }`}
148 >
149 {d}d
150 </Link>
151 ))}
152 </div>
153 }
154 >
155 {data.byCountry.length === 0 ? (
156 <EmptyState
157 icon={<GlobeIcon size={28} />}
158 title="no sign-ups yet"
159 message={`no end-user sign-ups recorded in the last ${days} days. rows appear here as people sign up through briven-auth on any project.`}
160 />
161 ) : (
162 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
163 <table className="w-full font-mono text-xs">
164 <thead className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
165 <tr>
166 <th className="px-6 py-4 text-left font-medium">country</th>
167 <th className="px-6 py-4 text-right font-medium">sign-ups</th>
168 <th className="px-6 py-4 text-right font-medium">%</th>
169 </tr>
170 </thead>
171 <tbody>
172 {data.byCountry.map((row, i) => (
173 <tr
174 key={row.country ?? `unknown-${i}`}
175 className="border-t border-[var(--color-border-subtle)] text-[var(--color-text-muted)]"
176 >
177 <td className="px-6 py-4 text-[var(--color-text)]">
178 {row.country ?? 'unknown'}
179 </td>
180 <td className="px-6 py-4 text-right">{formatNum(row.count)}</td>
181 <td className="px-6 py-4 text-right text-[var(--color-text)]">
182 {formatPercent(row.count, data.total)}
183 </td>
184 </tr>
185 ))}
186 </tbody>
187 </table>
188 </div>
189 )}
190 </Section>
191
192 {data.byCity.length > 0 ? (
193 <Section title={`by city · last ${days}d`} icon={<GlobeIcon size={16} />}>
194 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
195 <table className="w-full font-mono text-xs">
196 <thead className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
197 <tr>
198 <th className="px-6 py-4 text-left font-medium">city</th>
199 <th className="px-6 py-4 text-left font-medium">country</th>
200 <th className="px-6 py-4 text-right font-medium">sign-ups</th>
201 <th className="px-6 py-4 text-right font-medium">%</th>
202 </tr>
203 </thead>
204 <tbody>
205 {data.byCity.map((row, i) => (
206 <tr
207 key={`${row.country ?? ''}-${row.city ?? ''}-${i}`}
208 className="border-t border-[var(--color-border-subtle)] text-[var(--color-text-muted)]"
209 >
210 <td className="px-6 py-4 text-[var(--color-text)]">
211 {row.city ?? 'unknown'}
212 </td>
213 <td className="px-6 py-4">{row.country ?? 'unknown'}</td>
214 <td className="px-6 py-4 text-right">{formatNum(row.count)}</td>
215 <td className="px-6 py-4 text-right text-[var(--color-text)]">
216 {formatPercent(row.count, data.total)}
217 </td>
218 </tr>
219 ))}
220 </tbody>
221 </table>
222 </div>
223 </Section>
224 ) : null}
225
226 <Section title="recent sign-ups" icon={<GlobeIcon size={16} />}>
227 {data.recent.length === 0 ? (
228 <EmptyState
229 icon={<GlobeIcon size={28} />}
230 title="nothing recent"
231 message="no sign-ups in the selected window."
232 />
233 ) : (
234 <div className="overflow-x-auto rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
235 <table className="w-full font-mono text-xs">
236 <thead className="text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
237 <tr>
238 <th className="px-6 py-4 text-left font-medium">when</th>
239 <th className="px-6 py-4 text-left font-medium">project</th>
240 <th className="px-6 py-4 text-left font-medium">country / city</th>
241 <th className="px-6 py-4 text-left font-medium">ip</th>
242 </tr>
243 </thead>
244 <tbody>
245 {data.recent.map((row) => (
246 <tr
247 key={row.id}
248 className="border-t border-[var(--color-border-subtle)] text-[var(--color-text-muted)]"
249 >
250 <td className="px-6 py-4 whitespace-nowrap text-[var(--color-text)]">
251 {formatWhen(row.createdAt)}
252 </td>
253 <td className="px-6 py-4 whitespace-nowrap">{row.projectId}</td>
254 <td className="px-6 py-4 whitespace-nowrap text-[var(--color-text)]">
255 {row.country ?? 'unknown'}
256 {row.city ? ` · ${row.city}` : ''}
257 </td>
258 <td className="px-6 py-4 whitespace-nowrap">{row.ip ?? '—'}</td>
259 </tr>
260 ))}
261 </tbody>
262 </table>
263 </div>
264 )}
265 </Section>
266
267 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">
268 raw ip + geo are captured server-side at sign-up and stored in the
269 control plane (auth_signup_geo). admin-only. self-hosted geo lookup via
270 the GeoLite2-City database when BRIVEN_GEOIP_DB_PATH is set.
271 </p>
272 </div>
273 );
274}