page.tsx494 lines · main
1import Link from 'next/link';
2
3import { apiJson } from '../../../lib/api';
4import { requireUser } from '../../../lib/session';
5
6interface Project {
7 id: string;
8 slug: string;
9 name: string;
10 region: string;
11 tier: 'free' | 'pro' | 'team';
12 createdAt: string;
13 orgName: string | null;
14 orgPersonal: boolean | null;
15}
16
17interface PendingInvitation {
18 id: string;
19 projectName: string;
20}
21
22interface PendingOrgInvitation {
23 id: string;
24 orgId: string;
25 orgName: string;
26}
27
28interface ActivityRow {
29 id: string;
30 action: string;
31 actorId: string | null;
32 metadata: Record<string, unknown> | null;
33 createdAt: string;
34}
35
36interface ProjectActivity {
37 project: Project;
38 rows: ActivityRow[];
39}
40
41interface MigrationCard {
42 id: string;
43 source: string;
44 sourceUrl: string | null;
45 sourceNotes: string;
46 urgency: string;
47 status: string;
48 contactEmail: string;
49 createdAt: string;
50 updatedAt: string;
51}
52
53export const dynamic = 'force-dynamic';
54
55const TIER_LABEL: Record<Project['tier'], string> = {
56 free: 'free',
57 pro: 'pro',
58 team: 'team',
59};
60
61export default async function DashboardHome() {
62 const user = await requireUser();
63
64 const [projectsResult, invitesResult, orgInvitesResult, migrationsResult] =
65 await Promise.all([
66 apiJson<{ projects: Project[] }>('/v1/projects').catch(() => ({
67 projects: [] as Project[],
68 })),
69 apiJson<{ invitations: PendingInvitation[] }>('/v1/me/invitations').catch(() => ({
70 invitations: [] as PendingInvitation[],
71 })),
72 apiJson<{ invitations: PendingOrgInvitation[] }>('/v1/me/org-invitations').catch(
73 () => ({
74 invitations: [] as PendingOrgInvitation[],
75 }),
76 ),
77 apiJson<{ requests: MigrationCard[] }>(
78 '/v1/migration-requests',
79 ).catch(() => ({ requests: [] as MigrationCard[] })),
80 ]);
81
82 const projects = projectsResult.projects;
83 const invitations = invitesResult.invitations;
84 const orgInvitations = orgInvitesResult.invitations;
85 const openMigrations = migrationsResult.requests.filter(
86 (r) => r.status !== 'completed' && r.status !== 'cancelled',
87 );
88
89 // Cross-project activity rollup: fan-out to the three most recently
90 // created projects only. Bounded N+1 keeps the dashboard root cheap.
91 const recentProjects = projects.slice(0, 3);
92 const activityFanout: ProjectActivity[] = await Promise.all(
93 recentProjects.map(async (project) => {
94 const result = await apiJson<{ activity: ActivityRow[] }>(
95 `/v1/projects/${project.id}/activity`,
96 ).catch(() => ({ activity: [] as ActivityRow[] }));
97 return { project, rows: result.activity.slice(0, 10) };
98 }),
99 );
100
101 const mergedActivity = activityFanout
102 .flatMap((pa) =>
103 pa.rows.map((row) => ({
104 ...row,
105 projectId: pa.project.id,
106 projectName: pa.project.name,
107 })),
108 )
109 .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
110 .slice(0, 10);
111
112 const firstName = (user.name ?? '').split(' ')[0] ?? null;
113 const hour = new Date().getHours();
114 const greeting =
115 hour < 5 ? 'still up' : hour < 12 ? 'good morning' : hour < 18 ? 'afternoon' : 'evening';
116
117 return (
118 <section className="flex flex-col gap-8">
119 <header className="flex flex-col gap-1">
120 <p className="font-mono text-xs text-[var(--color-text-muted)]">
121 {greeting}
122 {firstName ? `, ${firstName.toLowerCase()}` : ''}.
123 </p>
124 <h1 className="font-sans text-2xl font-medium tracking-[-0.02em] text-[var(--color-text)]">
125 {projects.length === 0
126 ? "let's set up your first project"
127 : `${projects.length} project${projects.length === 1 ? '' : 's'} on briven`}
128 </h1>
129 </header>
130
131 {invitations.length > 0 ? (
132 <Link
133 href="/dashboard/invitations"
134 className="flex items-center justify-between rounded-md border border-[var(--color-primary)] bg-[var(--color-surface)] px-4 py-3 transition hover:bg-[var(--color-surface-raised)]"
135 >
136 <div>
137 <p className="font-mono text-sm text-[var(--color-text)]">
138 {invitations.length === 1
139 ? `you have a pending invitation to ${invitations[0]?.projectName ?? 'a project'}.`
140 : `you have ${invitations.length} pending project invitations.`}
141 </p>
142 <p className="mt-0.5 font-mono text-xs text-[var(--color-text-muted)]">
143 click to review and accept.
144 </p>
145 </div>
146 <span className="font-mono text-sm text-[var(--color-primary)]">→</span>
147 </Link>
148 ) : null}
149
150 {orgInvitations.length > 0 ? (
151 <Link
152 href="/dashboard/teams"
153 className="flex items-center justify-between rounded-md border border-[var(--color-primary)] bg-[var(--color-surface)] px-4 py-3 transition hover:bg-[var(--color-surface-raised)]"
154 >
155 <div>
156 <p className="font-mono text-sm text-[var(--color-text)]">
157 {orgInvitations.length === 1
158 ? `you have a pending invitation to join ${orgInvitations[0]?.orgName ?? 'a team'}.`
159 : `you have ${orgInvitations.length} pending team invitations.`}
160 </p>
161 <p className="mt-0.5 font-mono text-xs text-[var(--color-text-muted)]">
162 open the link in the email to accept.
163 </p>
164 </div>
165 <span className="font-mono text-sm text-[var(--color-primary)]">→</span>
166 </Link>
167 ) : null}
168
169 {projects.length === 0 && invitations.length === 0 && orgInvitations.length === 0 ? (
170 <OnboardingFlow />
171 ) : null}
172
173 {openMigrations.length > 0 ? (
174 <div className="flex flex-col gap-2">
175 <h2 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
176 active migrations · {openMigrations.length}
177 </h2>
178 {openMigrations.map((m) => (
179 <Link
180 key={m.id}
181 href={`/dashboard/migrations/${m.id}`}
182 className="flex flex-wrap items-center justify-between rounded-md border border-[var(--color-primary)] bg-[var(--color-surface)] px-4 py-3 transition hover:bg-[var(--color-surface-raised)]"
183 >
184 <div className="flex items-center gap-3">
185 <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
186 {m.source}
187 </span>
188 <span className="rounded-full border border-[var(--color-primary)] bg-[var(--color-primary-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
189 {m.status.replace(/_/g, ' ')}
190 </span>
191 <p className="font-mono text-sm text-[var(--color-text)]">
192 {m.urgency.replace(/_/g, ' ')} · submitted {new Date(m.createdAt).toISOString().slice(0, 10)}
193 </p>
194 </div>
195 <span className="font-mono text-sm text-[var(--color-primary)]">see progress →</span>
196 </Link>
197 ))}
198 <Link
199 href="/dashboard/migrations"
200 className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)] self-start ml-1"
201 >
202 all migrations →
203 </Link>
204 </div>
205 ) : null}
206
207 <div className="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
208 <section className="flex flex-col gap-4">
209 <header className="flex items-center justify-between">
210 <h2 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
211 your projects
212 </h2>
213 <Link
214 href="/dashboard/projects"
215 className="font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
216 >
217 show all →
218 </Link>
219 </header>
220
221 {projects.length === 0 ? (
222 <EmptyProjects />
223 ) : (
224 <ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
225 {projects.slice(0, 6).map((p) => (
226 <li key={p.id}>
227 <Link
228 href={`/dashboard/projects/${p.id}`}
229 className="flex flex-col gap-1.5 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 transition hover:border-[var(--color-border-strong)]"
230 >
231 <div className="flex items-center justify-between gap-3">
232 <span className="font-sans text-sm text-[var(--color-text)]">{p.name}</span>
233 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
234 {TIER_LABEL[p.tier]}
235 </span>
236 </div>
237 <p className="font-mono text-xs text-[var(--color-text-muted)]">
238 {p.orgName ?? 'personal'} · {p.region}
239 </p>
240 </Link>
241 </li>
242 ))}
243 </ul>
244 )}
245
246 <Link
247 href="/dashboard/projects/new"
248 className="self-start font-mono text-xs text-[var(--color-text-link)] hover:underline"
249 >
250 + new project
251 </Link>
252 </section>
253
254 <aside className="flex flex-col gap-4">
255 <h2 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
256 quick actions
257 </h2>
258 <div className="flex flex-col gap-2">
259 <QuickLink href="/dashboard/projects/new" label="new project" />
260 <QuickLink href="https://docs.briven.tech/quickstart" label="quickstart" external />
261 <QuickLink href="https://docs.briven.tech/cli" label="cli reference" external />
262 <QuickLink href="/dashboard/settings" label="account settings" />
263 <QuickLink href="/dashboard/billing" label="billing" />
264 <QuickLink href="/status" label="platform status" />
265 </div>
266 </aside>
267 </div>
268
269 <section className="flex flex-col gap-4">
270 <header className="flex items-center justify-between">
271 <h2 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
272 recent activity
273 </h2>
274 {recentProjects.length > 0 ? (
275 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
276 across your {recentProjects.length} most recent project
277 {recentProjects.length === 1 ? '' : 's'}
278 </span>
279 ) : null}
280 </header>
281
282 {mergedActivity.length === 0 ? (
283 <p className="font-mono text-xs text-[var(--color-text-muted)]">
284 no recent activity. deploys, schema changes, and team invites will show up here.
285 </p>
286 ) : (
287 <ul className="flex flex-col divide-y divide-[var(--color-border-subtle)] overflow-hidden rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)]">
288 {mergedActivity.map((row) => (
289 <li
290 key={`${row.projectId}-${row.id}`}
291 className="flex items-baseline justify-between gap-4 px-4 py-3"
292 >
293 <div className="min-w-0 flex-1">
294 <p className="truncate font-mono text-xs text-[var(--color-text)]">
295 {row.action}
296 </p>
297 <p className="mt-0.5 truncate font-mono text-[10px] text-[var(--color-text-muted)]">
298 in{' '}
299 <Link
300 href={`/dashboard/projects/${row.projectId}`}
301 className="text-[var(--color-text-link)] hover:underline"
302 >
303 {row.projectName}
304 </Link>
305 </p>
306 </div>
307 <time
308 className="shrink-0 font-mono text-[10px] text-[var(--color-text-subtle)]"
309 dateTime={row.createdAt}
310 >
311 {formatRelative(row.createdAt)}
312 </time>
313 </li>
314 ))}
315 </ul>
316 )}
317 </section>
318 </section>
319 );
320}
321
322function EmptyProjects() {
323 return (
324 <div className="flex flex-col gap-4 rounded-md border border-dashed border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
325 <p className="font-sans text-sm text-[var(--color-text)]">no projects yet.</p>
326 <ol className="flex flex-col gap-2 font-mono text-xs text-[var(--color-text-muted)]">
327 <li>
328 1. from a folder:{' '}
329 <code>npx @briven/cli setup --name my-app</code> — signs in, creates a cloud project,
330 wires the folder.
331 </li>
332 <li>
333 2. or click <em>create your first project</em> here, then{' '}
334 <code>briven connect p_…</code> from your app folder.
335 </li>
336 <li>
337 3. <code>briven deploy</code> (or <code>briven dev</code>) ships schema + functions.
338 </li>
339 </ol>
340 <Link
341 href="/dashboard/projects/new"
342 className="inline-flex h-9 w-fit items-center justify-center rounded-md bg-[var(--color-primary)] px-4 font-sans text-sm text-[var(--color-text-inverse)] transition-colors hover:bg-[var(--color-primary-hover)]"
343 >
344 create your first project
345 </Link>
346 </div>
347 );
348}
349
350function QuickLink({
351 href,
352 label,
353 external,
354}: {
355 href: string;
356 label: string;
357 external?: boolean;
358}) {
359 return (
360 <Link
361 href={href}
362 className="flex items-center justify-between rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-2 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
363 >
364 <span>{label}</span>
365 <span aria-hidden className="text-[var(--color-text-subtle)]">
366 {external ? '↗' : '→'}
367 </span>
368 </Link>
369 );
370}
371
372function formatRelative(iso: string): string {
373 const then = new Date(iso).getTime();
374 const diffSec = Math.round((Date.now() - then) / 1000);
375 if (diffSec < 60) return `${diffSec}s ago`;
376 if (diffSec < 3600) return `${Math.round(diffSec / 60)}m ago`;
377 if (diffSec < 86400) return `${Math.round(diffSec / 3600)}h ago`;
378 if (diffSec < 86400 * 7) return `${Math.round(diffSec / 86400)}d ago`;
379 return new Date(iso).toLocaleDateString('en-GB', { month: 'short', day: 'numeric' });
380}
381
382/**
383 * First-run onboarding for users with zero projects, zero invitations,
384 * and zero pending org invites. Three-step path: spin up a project →
385 * open studio → ship a function. Each card is interactive on a single
386 * step (the current one); the others are passive previews so the
387 * shape of the journey is visible.
388 *
389 * The "current step" inference is intentionally simple — projects === 0
390 * means step 1. Once a project exists this whole block disappears, so
391 * we don't need to track step 2/3 cross-render. The studio + CLI links
392 * are passive learning anchors here, made interactive after the project
393 * lands and the existing dashboard layout takes over.
394 */
395function OnboardingFlow() {
396 return (
397 <section className="flex flex-col gap-4">
398 <header>
399 <h2 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
400 first 60 seconds on briven
401 </h2>
402 <p className="mt-1 font-mono text-sm text-[var(--color-text-muted)]">
403 three steps to a live reactive backend. you can also import an existing project
404 from convex, supabase, firebase, etc. via{' '}
405 <Link
406 href="/dashboard/projects/new"
407 className="text-[var(--color-text-link)] hover:underline"
408 >
409 new project
410 </Link>
411 .
412 </p>
413 </header>
414 <ol className="flex flex-col gap-3 sm:grid sm:grid-cols-3 sm:gap-3">
415 <OnboardingStep
416 n={1}
417 title="create a project"
418 body="one click. ready in under 10 seconds. you get an empty postgres schema + function runtime + dashboard."
419 cta={{ label: 'create project', href: '/dashboard/projects/new' }}
420 tone="active"
421 />
422 <OnboardingStep
423 n={2}
424 title="open studio"
425 body="point-and-click table editor + SQL runner. add your first table, drop in a few rows, see realtime updates land in the schema view."
426 cta={{ label: 'after step 1', href: '/dashboard/projects/new' }}
427 tone="next"
428 />
429 <OnboardingStep
430 n={3}
431 title="deploy a function"
432 body="briven setup creates a new cloud project; briven connect attaches an existing one. briven deploy ships schema + functions."
433 cta={{ label: 'cli docs', href: 'https://docs.briven.tech/cli', external: true }}
434 tone="next"
435 />
436 </ol>
437 </section>
438 );
439}
440
441interface OnboardingStepProps {
442 n: number;
443 title: string;
444 body: string;
445 cta: { label: string; href: string; external?: boolean };
446 tone: 'active' | 'next';
447}
448
449function OnboardingStep({ n, title, body, cta, tone }: OnboardingStepProps) {
450 const borderClass =
451 tone === 'active'
452 ? 'border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)]'
453 : 'border-[var(--color-border-subtle)] bg-[var(--color-surface)]';
454 const numberClass =
455 tone === 'active'
456 ? 'bg-[var(--color-primary)] text-[var(--color-text-inverse)]'
457 : 'border border-[var(--color-border)] text-[var(--color-text-muted)]';
458 return (
459 <li
460 className={`flex flex-col gap-3 rounded-md border p-4 ${borderClass}`}
461 >
462 <div className="flex items-center gap-2">
463 <span
464 className={`flex size-6 shrink-0 items-center justify-center rounded-full font-mono text-xs ${numberClass}`}
465 >
466 {n}
467 </span>
468 <p className="font-mono text-sm text-[var(--color-text)]">{title}</p>
469 </div>
470 <p className="font-mono text-xs text-[var(--color-text-muted)]">{body}</p>
471 {tone === 'active' ? (
472 cta.external ? (
473 <a
474 href={cta.href}
475 className="mt-auto inline-flex w-fit items-center justify-center rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)]"
476 >
477 {cta.label} ↗
478 </a>
479 ) : (
480 <Link
481 href={cta.href}
482 className="mt-auto inline-flex w-fit items-center justify-center rounded-md bg-[var(--color-primary)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-inverse)] hover:bg-[var(--color-primary-hover)]"
483 >
484 {cta.label}
485 </Link>
486 )
487 ) : (
488 <p className="mt-auto font-mono text-[10px] text-[var(--color-text-subtle)]">
489 {cta.label}
490 </p>
491 )}
492 </li>
493 );
494}