@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.
- package/dist/index.js +340 -163
- package/package.json +3 -2
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +38 -0
- package/templates/app/app/dashboard/layout.tsx +39 -0
- package/templates/app/app/dashboard/page.tsx +42 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/components/account/security-settings.tsx +525 -0
- package/templates/app/components/admin/fields/country-field.tsx +152 -0
- package/templates/app/components/admin/fields/field-widget.tsx +186 -128
- package/templates/app/components/admin/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/admin/fields/phone-field.tsx +86 -0
- package/templates/app/components/admin/resource-nav.tsx +120 -109
- package/templates/app/components/admin/resource-table.tsx +31 -1
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/password-forms.tsx +79 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +315 -0
- package/templates/app/components/auth/sign-up-form.tsx +101 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +124 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/dashboard/dashboard-nav.tsx +35 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/lib/admin-nav.ts +1 -1
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +33 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +81 -6
- package/templates/app/lib/site.ts +47 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +32 -1
- package/templates/app/migrations/meta/0000_snapshot.json +215 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- 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.
|
|
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.
|
|
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'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
|
|
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
|
-
|
|
9
|
-
<div className="flex
|
|
10
|
-
<h1 className="text-
|
|
11
|
-
<
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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:
|
|
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 "
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
6
|
-
<
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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">“{site.testimonial.quote}”</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
|
+
}
|