@flaredev/cli 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,18 +1,132 @@
1
- import { SignOutButton } from "@/components/sign-out-button";
1
+ import Link from "next/link";
2
+ import { count, eq } from "drizzle-orm";
3
+ import { ArrowRightIcon, CheckCircle2Icon, CircleIcon, PlusIcon } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
7
+ import { PageHeader } from "@/components/dashboard/page-header";
8
+ import { resourceIcon } from "@/components/dashboard/resource-icon";
9
+ import { StatCards, type Stat } from "@/components/dashboard/stat-card";
10
+ import { getDb } from "@/db";
11
+ import { passkey } from "@/db/auth-schema";
12
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
13
+ import { adminPermissions, recentCounts, recordCount, resourcePath, trend, visibleResources } from "@/lib/dashboard";
2
14
  import { requireSession } from "@/lib/session";
3
15
 
16
+ export const metadata = { title: "Dashboard" };
17
+
18
+ /** The first thing you see when you sign in: how much data there is, and what's left to set up. */
4
19
  export default async function DashboardPage() {
5
20
  const { user } = await requireSession("/dashboard");
21
+ const resources = await visibleResources();
22
+
23
+ const cards = await Promise.all(
24
+ resources.map(async (resource) => {
25
+ const [total, recent, permissions] = await Promise.all([
26
+ recordCount(resource.name),
27
+ recentCounts(resource.name),
28
+ adminPermissions(resource.name),
29
+ ]);
30
+ return { resource, total, recent, permissions };
31
+ }),
32
+ );
33
+
34
+ const stats: Stat[] = cards.slice(0, 4).map(({ resource, total, recent }) => ({
35
+ label: resource.pluralLabel,
36
+ value: total,
37
+ hint: recent.current > 0 ? `${recent.current.toLocaleString()} this week` : "none this week",
38
+ change: trend(recent.current, recent.previous),
39
+ icon: resourceIcon(resource.icon),
40
+ href: resourcePath(resource),
41
+ }));
42
+
43
+ const [{ passkeys } = { passkeys: 0 }] = await getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
44
+ const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
45
+ const steps = [
46
+ { done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
47
+ ...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account#two-factor" }] : []),
48
+ ...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account#passkeys" }] : []),
49
+ ];
50
+ const remaining = steps.filter((step) => !step.done).length;
6
51
 
7
52
  return (
8
- <main className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 p-8">
9
- <div className="flex items-center justify-between">
10
- <h1 className="text-2xl font-semibold">Dashboard</h1>
11
- <SignOutButton />
53
+ <>
54
+ <PageHeader
55
+ title={`Welcome${user.name ? `, ${user.name.split(" ")[0]}` : ""}`}
56
+ description={resources.length > 0 ? "Everything in your app, and how it's moving." : "Your app is running. Add a resource to fill this page."}
57
+ />
58
+
59
+ <StatCards stats={stats} />
60
+
61
+ {resources.length === 0 && (
62
+ <Empty className="rounded-xl border border-dashed">
63
+ <EmptyHeader>
64
+ <EmptyTitle>No resources yet</EmptyTitle>
65
+ <EmptyDescription>
66
+ Generate one with <code>flare gen resource Contact --fields &quot;name:string, email:string&quot;</code>, then fill it with{" "}
67
+ <code>flare seed:resource Contact --count 1000</code>.
68
+ </EmptyDescription>
69
+ </EmptyHeader>
70
+ </Empty>
71
+ )}
72
+
73
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
74
+ {cards.length > 0 && (
75
+ <Card>
76
+ <CardHeader>
77
+ <CardTitle className="text-base">Your data</CardTitle>
78
+ <CardDescription>Every resource you can see.</CardDescription>
79
+ </CardHeader>
80
+ <CardContent className="flex flex-col">
81
+ {cards.map(({ resource, total, permissions }) => {
82
+ const Icon = resourceIcon(resource.icon);
83
+ return (
84
+ <div key={resource.name} className="flex items-center gap-3 border-b py-3 last:border-b-0 last:pb-0">
85
+ <span className="grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
86
+ <Icon className="size-4.5" />
87
+ </span>
88
+ <div className="flex min-w-0 flex-col">
89
+ <Link href={resourcePath(resource)} className="truncate text-sm font-medium hover:underline">
90
+ {resource.pluralLabel}
91
+ </Link>
92
+ <span className="text-xs text-muted-foreground">{total.toLocaleString()} records</span>
93
+ </div>
94
+ <div className="ml-auto flex items-center gap-1">
95
+ {permissions.create && (
96
+ <Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
97
+ <Link href={resourcePath(resource, "new")}>
98
+ <PlusIcon />
99
+ </Link>
100
+ </Button>
101
+ )}
102
+ <Button variant="ghost" size="icon" aria-label={`Open ${resource.pluralLabel.toLowerCase()}`} asChild>
103
+ <Link href={resourcePath(resource)}>
104
+ <ArrowRightIcon />
105
+ </Link>
106
+ </Button>
107
+ </div>
108
+ </div>
109
+ );
110
+ })}
111
+ </CardContent>
112
+ </Card>
113
+ )}
114
+
115
+ <Card className="h-fit">
116
+ <CardHeader>
117
+ <CardTitle className="text-base">Secure your account</CardTitle>
118
+ <CardDescription>{remaining === 0 ? "All done." : `${remaining} to go.`}</CardDescription>
119
+ </CardHeader>
120
+ <CardContent className="flex flex-col">
121
+ {steps.map((step) => (
122
+ <Link key={step.label} href={step.href} className="flex items-center gap-3 border-b py-3 text-sm last:border-b-0 last:pb-0 hover:text-primary">
123
+ {step.done ? <CheckCircle2Icon className="size-4.5 text-success" /> : <CircleIcon className="size-4.5 text-muted-foreground" />}
124
+ <span className={step.done ? "text-muted-foreground line-through" : "font-medium"}>{step.label}</span>
125
+ </Link>
126
+ ))}
127
+ </CardContent>
128
+ </Card>
12
129
  </div>
13
- <p className="text-sm text-[var(--foreground-muted)]">
14
- Signed in as <span className="font-medium text-[var(--foreground)]">{user.email}</span>
15
- </p>
16
- </main>
130
+ </>
17
131
  );
18
132
  }
@@ -0,0 +1,22 @@
1
+ import Link from "next/link";
2
+ import { AuthShell } from "@/components/auth/auth-shell";
3
+ import { ForgotPasswordForm } from "@/components/auth/password-forms";
4
+
5
+ export const metadata = { title: "Reset your password" };
6
+
7
+ export default async function ForgotPasswordPage({ searchParams }: { searchParams: Promise<{ email?: string }> }) {
8
+ const { email } = await searchParams;
9
+ return (
10
+ <AuthShell
11
+ title="Reset your password"
12
+ subtitle="We'll email you a link to choose a new one."
13
+ footer={
14
+ <Link href="/sign-in" className="font-medium text-link hover:underline">
15
+ Back to sign in
16
+ </Link>
17
+ }
18
+ >
19
+ <ForgotPasswordForm initialEmail={email ?? ""} />
20
+ </AuthShell>
21
+ );
22
+ }
@@ -1,5 +1,11 @@
1
1
  @import "tailwindcss";
2
2
  @import "tw-animate-css";
3
+ /* One typeface per theme (self-hosted; only the active theme's font is downloaded). */
4
+ @import "@fontsource-variable/inter";
5
+ @import "@fontsource-variable/plus-jakarta-sans";
6
+ @import "@fontsource-variable/noto-sans";
7
+ @import "@fontsource-variable/geist";
8
+ @import "@fontsource-variable/figtree";
3
9
 
4
10
  /*
5
11
  * Dark mode: the `dark` class on <html> (set from the theme cookie) or, with no
@@ -66,8 +72,6 @@
66
72
  --card-foreground: var(--foreground);
67
73
  --popover: var(--surface);
68
74
  --popover-foreground: var(--foreground);
69
- --primary: var(--foreground);
70
- --primary-foreground: var(--background);
71
75
  --secondary: var(--surface-muted);
72
76
  --secondary-foreground: var(--foreground);
73
77
  --muted: var(--surface-muted);
@@ -76,7 +80,6 @@
76
80
  --accent-foreground: var(--foreground);
77
81
  --destructive: var(--danger);
78
82
  --input: var(--border);
79
- --ring: var(--foreground-muted);
80
83
  --chart-1: var(--foreground);
81
84
  --chart-2: #71717a;
82
85
  --chart-3: #a1a1aa;
@@ -101,8 +104,104 @@
101
104
  }
102
105
  }
103
106
 
107
+ /*
108
+ * Themes (lib/theme.ts): <html data-theme="…">. Each sets the brand colour, link colour,
109
+ * button shape, heading weight and typeface; --primary and --ring follow the brand, so
110
+ * the admin, dashboard, auth screens and home page all change together.
111
+ */
112
+ :root,
113
+ [data-theme="default"] {
114
+ --brand: #5b5bd6;
115
+ --brand-foreground: #ffffff;
116
+ --link: #5b5bd6;
117
+ --radius: 0.5rem;
118
+ --button-radius: 0.5rem;
119
+ --heading-weight: 600;
120
+ --heading-tracking: -0.02em;
121
+ --font-theme: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
122
+ }
123
+
124
+ [data-theme="coral"] {
125
+ --brand: #e31c5f;
126
+ --brand-foreground: #ffffff;
127
+ --brand-gradient: linear-gradient(90deg, #e61e4d 0%, #e31c5f 50%, #d70466 100%);
128
+ --link: #d70466;
129
+ --radius: 0.75rem;
130
+ --button-radius: 0.5rem;
131
+ --heading-weight: 700;
132
+ --heading-tracking: -0.025em;
133
+ --font-theme: "Plus Jakarta Sans Variable", ui-sans-serif, system-ui, sans-serif;
134
+ }
135
+
136
+ [data-theme="amber"] {
137
+ --brand: #ffd814;
138
+ --brand-foreground: #0f1111;
139
+ --link: #2162a1;
140
+ --radius: 0.5rem;
141
+ --button-radius: 999px;
142
+ --heading-weight: 400;
143
+ --heading-tracking: 0;
144
+ --font-theme: "Noto Sans Variable", ui-sans-serif, system-ui, sans-serif;
145
+ }
146
+
147
+ [data-theme="sky"] {
148
+ --brand: #0051c3;
149
+ --brand-foreground: #ffffff;
150
+ --link: #0051c3;
151
+ --radius: 0.375rem;
152
+ --button-radius: 0.375rem;
153
+ --heading-weight: 650;
154
+ --heading-tracking: -0.025em;
155
+ --font-theme: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
156
+ }
157
+
158
+ [data-theme="mono"] {
159
+ --brand: var(--foreground);
160
+ --brand-foreground: var(--background);
161
+ --link: var(--foreground);
162
+ --radius: 0.5rem;
163
+ --button-radius: 0.5rem;
164
+ --heading-weight: 600;
165
+ --heading-tracking: -0.03em;
166
+ --font-theme: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
167
+ }
168
+
169
+ [data-theme="emerald"] {
170
+ --brand: #3ecf8e;
171
+ --brand-foreground: #0f1f17;
172
+ --link: #11784a;
173
+ --radius: 0.375rem;
174
+ --button-radius: 0.375rem;
175
+ --heading-weight: 500;
176
+ --heading-tracking: -0.02em;
177
+ --font-theme: "Figtree Variable", ui-sans-serif, system-ui, sans-serif;
178
+ }
179
+
180
+ /* Links stay readable on dark grounds. */
181
+ .dark {
182
+ --link: color-mix(in oklab, var(--brand) 65%, white);
183
+ }
184
+ @media (prefers-color-scheme: dark) {
185
+ :root:not(.light) {
186
+ --link: color-mix(in oklab, var(--brand) 65%, white);
187
+ }
188
+ }
189
+ [data-theme="mono"].dark,
190
+ [data-theme="amber"].dark {
191
+ --link: var(--foreground);
192
+ }
193
+
194
+ :root {
195
+ --primary: var(--brand);
196
+ --primary-foreground: var(--brand-foreground);
197
+ --ring: var(--brand);
198
+ }
199
+
104
200
  @theme inline {
105
- --font-sans: ui-sans-serif, -apple-system, "Inter", "Segoe UI", sans-serif;
201
+ --font-sans: var(--font-theme);
202
+ --color-brand: var(--brand);
203
+ --color-brand-foreground: var(--brand-foreground);
204
+ --color-link: var(--link);
106
205
  --color-background: var(--background);
107
206
  --color-foreground: var(--foreground);
108
207
  --color-surface: var(--surface);
@@ -153,4 +252,10 @@
153
252
  body {
154
253
  @apply bg-background text-foreground font-sans antialiased;
155
254
  }
255
+ h1,
256
+ h2,
257
+ h3 {
258
+ font-weight: var(--heading-weight);
259
+ letter-spacing: var(--heading-tracking);
260
+ }
156
261
  }
@@ -1,26 +1,28 @@
1
- import type { Metadata } from "next";
2
- import "./globals.css";
3
-
4
- export const metadata: Metadata = {
5
- title: "__APP_NAME__",
6
- description: "Built with Flare",
7
- };
8
-
9
- /**
10
- * Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
11
- * first paint. It runs in the browser rather than reading cookies() here: a layout
12
- * that reads cookies makes every page under it dynamic, and dynamic pages can never
13
- * be served from the CDN cache.
14
- */
15
- const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
16
-
17
- export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
18
- return (
19
- <html lang="en" suppressHydrationWarning>
20
- <head>
21
- <script dangerouslySetInnerHTML={{ __html: themeScript }} />
22
- </head>
23
- <body>{children}</body>
24
- </html>
25
- );
26
- }
1
+ import type { Metadata } from "next";
2
+ import { site } from "@/lib/site";
3
+ import { activeTheme } from "@/lib/theme";
4
+ import "./globals.css";
5
+
6
+ export const metadata: Metadata = {
7
+ title: { default: site.name, template: `%s · ${site.name}` },
8
+ description: site.description,
9
+ };
10
+
11
+ /**
12
+ * Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
13
+ * first paint. It runs in the browser rather than reading cookies() here: a layout
14
+ * that reads cookies makes every page under it dynamic, and dynamic pages can never
15
+ * be served from the CDN cache.
16
+ */
17
+ const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
18
+
19
+ export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
20
+ return (
21
+ <html lang="en" data-theme={activeTheme} suppressHydrationWarning>
22
+ <head>
23
+ <script dangerouslySetInnerHTML={{ __html: themeScript }} />
24
+ </head>
25
+ <body>{children}</body>
26
+ </html>
27
+ );
28
+ }
@@ -1,19 +1,132 @@
1
1
  import Link from "next/link";
2
+ import { ArrowRightIcon, CreditCardIcon, LayoutDashboardIcon, PlugIcon, ShieldCheckIcon, SparklesIcon, UsersIcon, ZapIcon } from "lucide-react";
3
+ import { ProductPreview } from "@/components/marketing/product-preview";
4
+ import { SiteHeader } from "@/components/marketing/site-header";
5
+ import { site } from "@/lib/site";
2
6
 
7
+ const ICONS = { zap: ZapIcon, "shield-check": ShieldCheckIcon, "layout-dashboard": LayoutDashboardIcon, users: UsersIcon, plug: PlugIcon, "credit-card": CreditCardIcon };
8
+
9
+ /** The public home page. Every word comes from lib/site.ts; the look from the theme. */
3
10
  export default function Home() {
4
11
  return (
5
- <main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 p-8">
6
- <p className="text-sm font-medium text-[var(--foreground-muted)]">Flare</p>
7
- <h1 className="text-2xl font-semibold">__APP_NAME__</h1>
8
- <p className="text-sm text-[var(--foreground-muted)]">
9
- Edit <code>app/page.tsx</code> to get started, or generate a resource with{" "}
10
- <code>flare gen resource</code>.
11
- </p>
12
- <nav className="flex gap-3 text-sm font-medium">
13
- <Link href="/sign-in" className="underline">Sign in</Link>
14
- <Link href="/sign-up" className="underline">Sign up</Link>
15
- <Link href="/dashboard" className="underline">Dashboard</Link>
16
- </nav>
17
- </main>
12
+ <>
13
+ <SiteHeader />
14
+ <main>
15
+ {/* Hero */}
16
+ <section className="relative overflow-hidden">
17
+ <div
18
+ aria-hidden="true"
19
+ className="absolute inset-0 -z-10 [background-image:linear-gradient(var(--border)_1px,transparent_1px),linear-gradient(90deg,var(--border)_1px,transparent_1px)] [background-size:64px_64px] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_0%,black,transparent_75%)] opacity-70"
20
+ />
21
+ <div aria-hidden="true" className="absolute top-[-12rem] left-1/2 -z-10 h-[28rem] w-[48rem] -translate-x-1/2 rounded-full bg-[conic-gradient(from_120deg,var(--brand),#f9a8d4,#a5b4fc,#99f6e4,var(--brand))] opacity-20 blur-3xl" />
22
+ <div className="mx-auto flex max-w-4xl flex-col items-center px-6 pt-20 pb-16 text-center md:pt-28">
23
+ {site.announcement && (
24
+ <Link
25
+ href={site.announcement.href}
26
+ className="mb-8 inline-flex items-center gap-2 rounded-full border border-border bg-surface/80 py-1 pr-3 pl-1 text-sm text-foreground-muted shadow-sm backdrop-blur hover:text-foreground"
27
+ >
28
+ <span className="rounded-full bg-brand/15 px-2 py-0.5 text-xs font-semibold text-link">{site.announcement.label}</span>
29
+ {site.announcement.text}
30
+ <ArrowRightIcon className="size-3.5" />
31
+ </Link>
32
+ )}
33
+ <h1 className="text-5xl leading-[1.05] text-balance md:text-7xl">{site.tagline}</h1>
34
+ <p className="mt-6 max-w-2xl text-lg text-foreground-muted text-pretty md:text-xl">{site.description}</p>
35
+ <div className="mt-10 flex flex-wrap justify-center gap-3">
36
+ <Link
37
+ href="/sign-up"
38
+ className="inline-flex h-12 items-center gap-2 rounded-[var(--button-radius)] bg-brand px-6 font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
39
+ >
40
+ Start for free
41
+ </Link>
42
+ <a href="#features" className="inline-flex h-12 items-center rounded-[var(--button-radius)] border border-border bg-surface px-6 font-medium hover:bg-surface-muted">
43
+ See how it works
44
+ </a>
45
+ </div>
46
+ </div>
47
+ <div className="px-6 pb-24">
48
+ <ProductPreview />
49
+ </div>
50
+ </section>
51
+
52
+ {/* Logos */}
53
+ {site.customers.length > 0 && (
54
+ <section id="customers" aria-label="Customers" className="border-y border-border bg-surface-muted/50 py-12">
55
+ <p className="text-center text-sm text-foreground-muted">Trusted by teams at</p>
56
+ <ul className="mx-auto mt-6 flex max-w-5xl flex-wrap items-center justify-center gap-x-12 gap-y-4 px-6">
57
+ {site.customers.map((name) => (
58
+ <li key={name} className="text-2xl font-semibold tracking-tight text-foreground/35">
59
+ {name}
60
+ </li>
61
+ ))}
62
+ </ul>
63
+ </section>
64
+ )}
65
+
66
+ {/* Features */}
67
+ <section id="features" className="mx-auto max-w-6xl px-6 py-24">
68
+ <div className="mx-auto max-w-2xl text-center">
69
+ <h2 className="text-4xl text-balance">Everything your team needs, in one place</h2>
70
+ <p className="mt-4 text-lg text-foreground-muted">{site.name} takes care of the busywork so you can focus on your customers.</p>
71
+ </div>
72
+ <ul className="mt-16 grid gap-px overflow-hidden rounded-[calc(var(--radius)+8px)] border border-border bg-border sm:grid-cols-2 lg:grid-cols-3">
73
+ {site.features.map((feature) => {
74
+ const Icon = ICONS[feature.icon as keyof typeof ICONS] ?? SparklesIcon;
75
+ return (
76
+ <li key={feature.title} className="flex flex-col gap-3 bg-surface p-8">
77
+ <span className="grid size-10 place-items-center rounded-[var(--radius)] bg-brand/10 text-link">
78
+ <Icon className="size-5" />
79
+ </span>
80
+ <h3 className="text-lg">{feature.title}</h3>
81
+ <p className="text-sm leading-relaxed text-foreground-muted">{feature.description}</p>
82
+ </li>
83
+ );
84
+ })}
85
+ </ul>
86
+ </section>
87
+
88
+ {/* Testimonial */}
89
+ <section className="border-t border-border bg-surface-muted/50 px-6 py-24">
90
+ <figure className="mx-auto flex max-w-3xl flex-col items-center gap-8 text-center">
91
+ <blockquote className="text-3xl leading-snug text-balance">&ldquo;{site.testimonial.quote}&rdquo;</blockquote>
92
+ <figcaption className="text-sm">
93
+ <span className="font-semibold">{site.testimonial.author}</span>
94
+ <span className="text-foreground-muted">, {site.testimonial.role}</span>
95
+ </figcaption>
96
+ </figure>
97
+ </section>
98
+
99
+ {/* Closing call to action */}
100
+ <section className="px-6 py-24">
101
+ <div className="relative mx-auto flex max-w-5xl flex-col items-center gap-6 overflow-hidden rounded-[calc(var(--radius)+12px)] bg-foreground px-8 py-16 text-center text-background">
102
+ <div aria-hidden="true" className="absolute -top-24 left-1/2 h-64 w-[36rem] -translate-x-1/2 rounded-full bg-brand opacity-40 blur-3xl" />
103
+ <h2 className="relative text-4xl text-balance">Get started with {site.name} today</h2>
104
+ <p className="relative max-w-xl text-background/70">Create an account in seconds. No credit card, nothing to install.</p>
105
+ <Link href="/sign-up" className="relative inline-flex h-12 items-center gap-2 rounded-[var(--button-radius)] bg-background px-6 font-medium text-foreground hover:bg-background/90">
106
+ Create your account <ArrowRightIcon className="size-4" />
107
+ </Link>
108
+ </div>
109
+ </section>
110
+ </main>
111
+
112
+ <footer className="border-t border-border">
113
+ <div className="mx-auto flex max-w-6xl flex-col gap-6 px-6 py-10 text-sm text-foreground-muted md:flex-row md:items-center md:justify-between">
114
+ <p>
115
+ © {new Date().getFullYear()} {site.name}
116
+ </p>
117
+ <nav aria-label="Footer" className="flex gap-6">
118
+ <Link href={site.links.terms} className="hover:text-foreground">
119
+ Terms
120
+ </Link>
121
+ <Link href={site.links.privacy} className="hover:text-foreground">
122
+ Privacy
123
+ </Link>
124
+ <a href={site.links.support} className="hover:text-foreground">
125
+ Contact
126
+ </a>
127
+ </nav>
128
+ </div>
129
+ </footer>
130
+ </>
18
131
  );
19
132
  }
@@ -0,0 +1,31 @@
1
+ import Link from "next/link";
2
+ import { AuthShell } from "@/components/auth/auth-shell";
3
+ import { ResetPasswordForm } from "@/components/auth/password-forms";
4
+ import { FormMessage } from "@/components/auth/ui";
5
+
6
+ export const metadata = { title: "Choose a new password" };
7
+
8
+ export default async function ResetPasswordPage({ searchParams }: { searchParams: Promise<{ token?: string; error?: string }> }) {
9
+ const { token, error } = await searchParams;
10
+ return (
11
+ <AuthShell
12
+ title="Choose a new password"
13
+ footer={
14
+ <Link href="/sign-in" className="font-medium text-link hover:underline">
15
+ Back to sign in
16
+ </Link>
17
+ }
18
+ >
19
+ {token && !error ? (
20
+ <ResetPasswordForm token={token} />
21
+ ) : (
22
+ <div className="flex flex-col gap-4">
23
+ <FormMessage>This reset link has expired or was already used.</FormMessage>
24
+ <Link href="/forgot-password" className="text-center text-sm font-medium text-link hover:underline">
25
+ Ask for a new link
26
+ </Link>
27
+ </div>
28
+ )}
29
+ </AuthShell>
30
+ );
31
+ }
@@ -1,33 +1,49 @@
1
- import Link from "next/link";
2
- import { redirect } from "next/navigation";
3
- import { AuthForm } from "@/components/auth-form";
4
- import { enabledSocialProviders } from "@/lib/auth";
5
- import { getSession, safeRedirectPath } from "@/lib/session";
6
-
7
- export default async function SignInPage({
8
- searchParams,
9
- }: {
10
- searchParams: Promise<{ next?: string; error?: string }>;
11
- }) {
12
- const params = await searchParams;
13
- const next = safeRedirectPath(params.next);
14
- if (await getSession()) redirect(next);
15
-
16
- return (
17
- <main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 p-6">
18
- <h1 className="text-2xl font-semibold">Sign in</h1>
19
- <AuthForm
20
- mode="sign-in"
21
- next={next}
22
- socialProviders={enabledSocialProviders()}
23
- initialError={params.error ? "Sign-in with that provider failed. Please try again." : null}
24
- />
25
- <p className="text-sm text-[var(--foreground-muted)]">
26
- No account?{" "}
27
- <Link href={`/sign-up?next=${encodeURIComponent(next)}`} className="font-medium text-[var(--foreground)] underline">
28
- Create one
29
- </Link>
30
- </p>
31
- </main>
32
- );
33
- }
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { AuthShell } from "@/components/auth/auth-shell";
4
+ import { SignInFlow } from "@/components/auth/sign-in-flow";
5
+ import { enabledSocialProviders } from "@/lib/auth";
6
+ import { authConfig } from "@/lib/auth-config";
7
+ import { getSession, safeRedirectPath } from "@/lib/session";
8
+ import { site } from "@/lib/site";
9
+ import { theme } from "@/lib/theme";
10
+
11
+ export const metadata = { title: "Sign in" };
12
+
13
+ const ERRORS: Record<string, string> = {
14
+ oauth: "Sign-in with that provider didn't finish. Try again.",
15
+ link: "That sign-in link has expired or was already used. Ask for a new one.",
16
+ };
17
+
18
+ export default async function SignInPage({ searchParams }: { searchParams: Promise<{ next?: string; error?: string; reset?: string }> }) {
19
+ const params = await searchParams;
20
+ const next = safeRedirectPath(params.next);
21
+ if (await getSession()) redirect(next);
22
+
23
+ const copy = theme.signIn(site.name);
24
+ return (
25
+ <AuthShell
26
+ page="sign-in"
27
+ title={copy.title}
28
+ subtitle={(copy as { subtitle?: string }).subtitle}
29
+ footer={
30
+ <>
31
+ Don&apos;t have an account?{" "}
32
+ <Link href={`/sign-up?next=${encodeURIComponent(next)}`} className="font-medium text-link hover:underline">
33
+ Sign up
34
+ </Link>
35
+ </>
36
+ }
37
+ >
38
+ <SignInFlow
39
+ providers={enabledSocialProviders()}
40
+ socialPlacement={theme.social}
41
+ socialStyle={theme.socialStyle}
42
+ methods={{ magicLink: authConfig.magicLink, emailOtp: authConfig.emailOtp, passkeys: authConfig.passkeys }}
43
+ next={next}
44
+ initialError={params.error ? (ERRORS[params.error] ?? "Sign-in didn't finish. Try again.") : null}
45
+ initialNotice={params.reset ? "Your password is changed. Sign in with the new one." : null}
46
+ />
47
+ </AuthShell>
48
+ );
49
+ }