@flaredev/cli 0.1.2 → 0.2.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 (50) hide show
  1. package/dist/index.js +340 -163
  2. package/package.json +3 -2
  3. package/templates/app/app/api/openapi.json/route.ts +23 -0
  4. package/templates/app/app/api/reference/route.ts +36 -0
  5. package/templates/app/app/dashboard/account/page.tsx +38 -0
  6. package/templates/app/app/dashboard/layout.tsx +39 -0
  7. package/templates/app/app/dashboard/page.tsx +42 -9
  8. package/templates/app/app/forgot-password/page.tsx +22 -0
  9. package/templates/app/app/globals.css +109 -4
  10. package/templates/app/app/layout.tsx +28 -26
  11. package/templates/app/app/page.tsx +126 -13
  12. package/templates/app/app/reset-password/page.tsx +31 -0
  13. package/templates/app/app/sign-in/page.tsx +49 -33
  14. package/templates/app/app/sign-up/page.tsx +42 -23
  15. package/templates/app/app/two-factor/page.tsx +25 -0
  16. package/templates/app/components/account/security-settings.tsx +525 -0
  17. package/templates/app/components/admin/fields/country-field.tsx +152 -0
  18. package/templates/app/components/admin/fields/field-widget.tsx +186 -128
  19. package/templates/app/components/admin/fields/multi-select-field.tsx +46 -0
  20. package/templates/app/components/admin/fields/phone-field.tsx +86 -0
  21. package/templates/app/components/admin/resource-nav.tsx +120 -109
  22. package/templates/app/components/admin/resource-table.tsx +31 -1
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/password-forms.tsx +79 -0
  25. package/templates/app/components/auth/provider-icons.tsx +39 -0
  26. package/templates/app/components/auth/sign-in-flow.tsx +315 -0
  27. package/templates/app/components/auth/sign-up-form.tsx +101 -0
  28. package/templates/app/components/auth/social-buttons.tsx +56 -0
  29. package/templates/app/components/auth/two-factor-form.tsx +124 -0
  30. package/templates/app/components/auth/ui.tsx +96 -0
  31. package/templates/app/components/dashboard/dashboard-nav.tsx +35 -0
  32. package/templates/app/components/marketing/product-preview.tsx +70 -0
  33. package/templates/app/components/marketing/site-header.tsx +39 -0
  34. package/templates/app/components/sign-out-button.tsx +1 -1
  35. package/templates/app/components/ui/checkbox.tsx +25 -0
  36. package/templates/app/components/ui/radio-group.tsx +29 -0
  37. package/templates/app/db/auth-schema.ts +167 -118
  38. package/templates/app/lib/admin-nav.ts +1 -1
  39. package/templates/app/lib/api-docs.ts +16 -0
  40. package/templates/app/lib/auth-client.ts +16 -1
  41. package/templates/app/lib/auth-config.ts +33 -0
  42. package/templates/app/lib/auth-emails.ts +94 -0
  43. package/templates/app/lib/auth.ts +81 -6
  44. package/templates/app/lib/site.ts +47 -0
  45. package/templates/app/lib/theme.ts +79 -0
  46. package/templates/app/migrations/0000_init.sql +32 -1
  47. package/templates/app/migrations/meta/0000_snapshot.json +215 -1
  48. package/templates/app/migrations/meta/_journal.json +1 -1
  49. package/templates/app/vite.config.ts +5 -3
  50. package/templates/app/components/auth-form.tsx +0 -120
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",
@@ -36,11 +36,12 @@
36
36
  "README.md"
37
37
  ],
38
38
  "dependencies": {
39
+ "@clack/prompts": "1.8.1",
39
40
  "cac": "^7.0.0",
40
41
  "jiti": "^2.7.0",
41
42
  "jsonc-parser": "^3.3.1",
42
43
  "picocolors": "^1.1.1",
43
- "@flaredev/core": "0.1.2"
44
+ "@flaredev/core": "0.2.0"
44
45
  },
45
46
  "engines": {
46
47
  "node": ">=22"
@@ -0,0 +1,23 @@
1
+ import { openApiDocument } from "@flaredev/core/server";
2
+ import { apiDocs } from "@/lib/api-docs";
3
+ import { auth } from "@/lib/auth";
4
+ import { policies } from "@/policies";
5
+ import { resources } from "@/resources";
6
+
7
+ /** The OpenAPI 3.1 document for this app's REST API, as the current viewer may use it. */
8
+ export async function GET(request: Request) {
9
+ const session = await auth.api.getSession({ headers: request.headers });
10
+ const role = session ? ((session.user as { role?: string | null }).role ?? "") : null;
11
+ const document = openApiDocument({
12
+ resources,
13
+ policies,
14
+ role,
15
+ visibility: apiDocs.visibility,
16
+ title: apiDocs.title,
17
+ version: apiDocs.version,
18
+ description: apiDocs.description,
19
+ serverUrl: new URL(request.url).origin,
20
+ });
21
+ // Depends on who is asking: never share it through a cache.
22
+ return Response.json(document, { headers: { "cache-control": "private, no-store" } });
23
+ }
@@ -0,0 +1,36 @@
1
+ import { apiDocs } from "@/lib/api-docs";
2
+
3
+ /** Scalar's API reference, pinned to one release, reading /api/openapi.json. */
4
+ const SCALAR = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.71.0";
5
+
6
+ const escape = (text: string) => text.replace(/[&<>"]/g, (char) => `&#${char.charCodeAt(0)};`);
7
+
8
+ export function GET() {
9
+ const config = {
10
+ url: "/api/openapi.json",
11
+ theme: "default",
12
+ layout: "modern",
13
+ hideModels: false,
14
+ defaultHttpClient: { targetKey: "js", clientKey: "fetch" },
15
+ metaData: { title: apiDocs.title },
16
+ // Scalar's own hosted extras (toolbar, developer tools, MCP generator, AI chat) don't belong in an app.
17
+ showToolbar: "never",
18
+ showDeveloperTools: "never",
19
+ mcp: { disabled: true },
20
+ agent: { disabled: true },
21
+ };
22
+ const html = `<!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="utf-8" />
26
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
27
+ <title>${escape(apiDocs.title)}</title>
28
+ </head>
29
+ <body>
30
+ <div id="app"></div>
31
+ <script src="${SCALAR}"></script>
32
+ <script>Scalar.createApiReference("#app", ${JSON.stringify(config).replace(/</g, "\u003c")});</script>
33
+ </body>
34
+ </html>`;
35
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
36
+ }
@@ -0,0 +1,38 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { SecuritySettings } from "@/components/account/security-settings";
3
+ import { getDb } from "@/db";
4
+ import { account } from "@/db/auth-schema";
5
+ import { enabledSocialProviders } from "@/lib/auth";
6
+ import { authConfig } from "@/lib/auth-config";
7
+ import { requireSession } from "@/lib/session";
8
+
9
+ export const metadata = { title: "Account" };
10
+
11
+ export default async function AccountPage() {
12
+ const { user } = await requireSession("/dashboard/account");
13
+ const credential = await getDb()
14
+ .select({ id: account.id })
15
+ .from(account)
16
+ .where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
17
+ .get();
18
+
19
+ return (
20
+ <>
21
+ <div className="flex flex-col gap-1">
22
+ <h1 className="text-3xl">Account</h1>
23
+ <p className="text-foreground-muted">Your profile, how you sign in, and where you&apos;re signed in.</p>
24
+ </div>
25
+ <SecuritySettings
26
+ user={{
27
+ name: user.name,
28
+ email: user.email,
29
+ emailVerified: user.emailVerified,
30
+ twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
31
+ }}
32
+ hasPassword={Boolean(credential)}
33
+ methods={{ passkeys: authConfig.passkeys, twoFactor: authConfig.twoFactor }}
34
+ socialProviders={enabledSocialProviders()}
35
+ />
36
+ </>
37
+ );
38
+ }
@@ -0,0 +1,39 @@
1
+ import Link from "next/link";
2
+ import { BrandMark } from "@/components/auth/auth-shell";
3
+ import { DashboardNav } from "@/components/dashboard/dashboard-nav";
4
+ import { SignOutButton } from "@/components/sign-out-button";
5
+ import { ADMIN_ROLES } from "@/lib/admin";
6
+ import { requireSession } from "@/lib/session";
7
+ import { site } from "@/lib/site";
8
+
9
+ /** The frame around every signed-in page: the app's mark, its sections and the account menu. */
10
+ export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
11
+ const { user } = await requireSession("/dashboard");
12
+ const role = (user as { role?: string | null }).role ?? "";
13
+ const links = [
14
+ { href: "/dashboard", label: "Overview" },
15
+ { href: "/dashboard/account", label: "Account" },
16
+ ...(ADMIN_ROLES.includes(role) ? [{ href: "/admin", label: "Admin" }] : []),
17
+ ];
18
+
19
+ return (
20
+ <div className="min-h-dvh bg-surface-muted/40">
21
+ <header className="border-b border-border bg-surface">
22
+ <div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 pt-4">
23
+ <Link href="/dashboard" className="flex items-center gap-3">
24
+ <BrandMark link={false} />
25
+ <span className="font-semibold">{site.name}</span>
26
+ </Link>
27
+ <div className="flex items-center gap-3">
28
+ <span className="hidden text-sm text-foreground-muted sm:inline">{user.email}</span>
29
+ <SignOutButton />
30
+ </div>
31
+ </div>
32
+ <div className="mx-auto max-w-5xl px-3">
33
+ <DashboardNav links={links} />
34
+ </div>
35
+ </header>
36
+ <main className="mx-auto flex max-w-5xl flex-col gap-8 px-6 py-10">{children}</main>
37
+ </div>
38
+ );
39
+ }
@@ -1,18 +1,51 @@
1
- import { SignOutButton } from "@/components/sign-out-button";
1
+ import Link from "next/link";
2
+ import { count, eq } from "drizzle-orm";
3
+ import { CheckCircle2Icon, CircleIcon } from "lucide-react";
4
+ import { getDb } from "@/db";
5
+ import { passkey } from "@/db/auth-schema";
6
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
2
7
  import { requireSession } from "@/lib/session";
8
+ import { site } from "@/lib/site";
9
+
10
+ export const metadata = { title: "Dashboard" };
3
11
 
4
12
  export default async function DashboardPage() {
5
13
  const { user } = await requireSession("/dashboard");
14
+ const [{ passkeys } = { passkeys: 0 }] = await getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
15
+ const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
16
+
17
+ const steps = [
18
+ { done: user.emailVerified, label: "Verify your email address", href: "/dashboard/account#profile" },
19
+ ...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account#two-factor" }] : []),
20
+ ...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account#passkeys" }] : []),
21
+ ];
22
+ const remaining = steps.filter((step) => !step.done).length;
6
23
 
7
24
  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 />
25
+ <>
26
+ <div className="flex flex-col gap-1">
27
+ <h1 className="text-3xl">Welcome{user.name ? `, ${user.name.split(" ")[0]}` : ""}</h1>
28
+ <p className="text-foreground-muted">This is your {site.name} dashboard.</p>
12
29
  </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>
30
+
31
+ <section aria-labelledby="security" className="rounded-[calc(var(--radius)+4px)] border border-border bg-surface">
32
+ <div className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
33
+ <h2 id="security" className="text-base">
34
+ Secure your account
35
+ </h2>
36
+ <span className="text-sm text-foreground-muted">{remaining === 0 ? "All done" : `${remaining} to go`}</span>
37
+ </div>
38
+ <ul>
39
+ {steps.map((step) => (
40
+ <li key={step.label} className="border-b border-border last:border-b-0">
41
+ <Link href={step.href} className="flex items-center gap-3 px-6 py-4 text-sm hover:bg-surface-muted">
42
+ {step.done ? <CheckCircle2Icon className="size-5 text-success" /> : <CircleIcon className="size-5 text-foreground-muted" />}
43
+ <span className={step.done ? "text-foreground-muted line-through" : "font-medium"}>{step.label}</span>
44
+ </Link>
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ </section>
49
+ </>
17
50
  );
18
51
  }
@@ -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
+ }