page.tsx494 lines · main
| 1 | import Link from 'next/link'; |
| 2 | |
| 3 | import { apiJson } from '../../../lib/api'; |
| 4 | import { requireUser } from '../../../lib/session'; |
| 5 | |
| 6 | interface 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 | |
| 17 | interface PendingInvitation { |
| 18 | id: string; |
| 19 | projectName: string; |
| 20 | } |
| 21 | |
| 22 | interface PendingOrgInvitation { |
| 23 | id: string; |
| 24 | orgId: string; |
| 25 | orgName: string; |
| 26 | } |
| 27 | |
| 28 | interface ActivityRow { |
| 29 | id: string; |
| 30 | action: string; |
| 31 | actorId: string | null; |
| 32 | metadata: Record<string, unknown> | null; |
| 33 | createdAt: string; |
| 34 | } |
| 35 | |
| 36 | interface ProjectActivity { |
| 37 | project: Project; |
| 38 | rows: ActivityRow[]; |
| 39 | } |
| 40 | |
| 41 | interface 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 | |
| 53 | export const dynamic = 'force-dynamic'; |
| 54 | |
| 55 | const TIER_LABEL: Record<Project['tier'], string> = { |
| 56 | free: 'free', |
| 57 | pro: 'pro', |
| 58 | team: 'team', |
| 59 | }; |
| 60 | |
| 61 | export 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 | |
| 322 | function 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 | |
| 350 | function 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 | |
| 372 | function 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 | */ |
| 395 | function 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 | |
| 441 | interface OnboardingStepProps { |
| 442 | n: number; |
| 443 | title: string; |
| 444 | body: string; |
| 445 | cta: { label: string; href: string; external?: boolean }; |
| 446 | tone: 'active' | 'next'; |
| 447 | } |
| 448 | |
| 449 | function 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 | } |