scale-stack 0.0.1-alpha.1 → 0.0.1

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 (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +97 -3
  3. package/dist/index.js +4218 -16
  4. package/package.json +22 -10
  5. package/templates/ai-chat/chat-panel.tsx.ejs +70 -0
  6. package/templates/ai-chat/layout.tsx.ejs +39 -0
  7. package/templates/ai-chat/page.tsx.ejs +31 -0
  8. package/templates/ai-chat/route.ts.ejs +68 -0
  9. package/templates/ai-chat/use-chat.ts.ejs +26 -0
  10. package/templates/analytics/analytics-provider.tsx.ejs +32 -0
  11. package/templates/analytics/analytics.ts.ejs +40 -0
  12. package/templates/auth/auth-client.ts.ejs +7 -0
  13. package/templates/auth/auth.ts.ejs +45 -0
  14. package/templates/auth/route.ts.ejs +4 -0
  15. package/templates/auth/sign-in-page.tsx.ejs +122 -0
  16. package/templates/auth/sign-up-page.tsx.ejs +137 -0
  17. package/templates/auth/unauthorized.tsx.ejs +28 -0
  18. package/templates/core/layout.tsx.ejs +36 -0
  19. package/templates/core/loading.tsx.ejs +7 -0
  20. package/templates/core/next.config.ts.ejs +33 -0
  21. package/templates/error-handling/catch-all-not-found-page.tsx.ejs +5 -0
  22. package/templates/error-handling/error.tsx.ejs +33 -0
  23. package/templates/error-handling/global-error.tsx.ejs +32 -0
  24. package/templates/error-handling/not-found.tsx.ejs +28 -0
  25. package/templates/eslint-prettier/.prettierignore.ejs +29 -0
  26. package/templates/eslint-prettier/eslint.config.mjs.ejs +31 -0
  27. package/templates/form-handling/dashboard-page.tsx.ejs +33 -0
  28. package/templates/form-handling/example-form-action.ts.ejs +50 -0
  29. package/templates/form-handling/example-form-schema.ts.ejs +87 -0
  30. package/templates/form-handling/example-form.tsx.ejs +428 -0
  31. package/templates/i18n/ar.json.ejs +77 -0
  32. package/templates/i18n/en.json.ejs +77 -0
  33. package/templates/i18n/locale-layout.tsx.ejs +45 -0
  34. package/templates/i18n/navigation.ts.ejs +5 -0
  35. package/templates/i18n/next-intl.d.ts.ejs +9 -0
  36. package/templates/i18n/request.ts.ejs +15 -0
  37. package/templates/i18n/routing.ts.ejs +7 -0
  38. package/templates/orm/prisma.config.ts.ejs +12 -0
  39. package/templates/orm/prisma.ts.ejs +17 -0
  40. package/templates/orm/schema.prisma.ejs +8 -0
  41. package/templates/pre-commit/prek.toml.ejs +35 -0
  42. package/templates/proxy/proxy.ts.ejs +81 -0
  43. package/templates/server-actions/safe-action.ts.ejs +51 -0
  44. package/templates/ui/client-side-wrappers.tsx.ejs +19 -0
  45. package/templates/ui/page.tsx.ejs +111 -0
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ <% if (isStateful) { %>import type { FormEvent } from "react";
4
+ <% if (i18n) { %>import { Link } from "@/i18n/navigation";
5
+ <% } else { %>import Link from "next/link";
6
+ <% } %><% } %><% if (i18n) { %>import { useTranslations } from "next-intl";
7
+ <% } %>import { useState } from "react";
8
+ import { Button } from "@/components/ui/button";
9
+ import {
10
+ Card,
11
+ CardContent,
12
+ CardDescription,
13
+ <% if (isStateful) { %> CardFooter,
14
+ <% } %>
15
+ CardHeader,
16
+ CardTitle,
17
+ } from "@/components/ui/card";
18
+ <% if (isStateful) { %>import { Input } from "@/components/ui/input";
19
+ import { Label } from "@/components/ui/label";
20
+ <% } %>import { authClient } from "@/lib/auth-client";
21
+
22
+ export default function SignUpPage() {
23
+ <% if (i18n) { %> const t = useTranslations("Auth");
24
+ <% } %>
25
+ <% if (isStateful) { %> const [name, setName] = useState("");
26
+ const [email, setEmail] = useState("");
27
+ const [password, setPassword] = useState("");
28
+ <% } %> const [error, setError] = useState<string | null>(null);
29
+ const [pending, setPending] = useState(false);
30
+
31
+ async function signInWithMicrosoft() {
32
+ setPending(true);
33
+ setError(null);
34
+ const result = await authClient.signIn.social({
35
+ provider: "microsoft",
36
+ callbackURL: "/",
37
+ });
38
+ setPending(false);
39
+
40
+ if (result.error) {
41
+ setError(result.error.message ?? <% if (i18n) { %>t("microsoftSignInFailed")<% } else { %>"Microsoft sign-in failed."<% } %>);
42
+ }
43
+ }
44
+ <% if (isStateful) { %>
45
+ async function signUpWithEmail(event: FormEvent<HTMLFormElement>) {
46
+ event.preventDefault();
47
+ setPending(true);
48
+ setError(null);
49
+ const result = await authClient.signUp.email({
50
+ name,
51
+ email,
52
+ password,
53
+ callbackURL: "/",
54
+ });
55
+ setPending(false);
56
+
57
+ if (result.error) {
58
+ setError(result.error.message ?? <% if (i18n) { %>t("emailSignUpFailed")<% } else { %>"Email sign-up failed."<% } %>);
59
+ }
60
+ }
61
+ <% } %>
62
+ return (
63
+ <main className="flex flex-1 items-center justify-center px-6 py-24">
64
+ <Card className="w-full max-w-md">
65
+ <CardHeader>
66
+ <CardTitle><% if (i18n) { %>{t("<% if (isStateful) { %>createAccountTitle<% } else { %>createAccountMicrosoftTitle<% } %>")}<% } else { %><% if (isStateful) { %>Create account<% } else { %>Create account with Microsoft<% } %><% } %></CardTitle>
67
+ <CardDescription>
68
+ <% if (i18n) { %>{t("<% if (isStateful) { %>createAccountDescriptionStateful<% } else { %>createAccountDescriptionStateless<% } %>")}<% } else { %><% if (isStateful) { %>Use Microsoft or email/password to create your account.<% } else { %>Account creation is handled by Microsoft Entra for this stateless setup.<% } %><% } %>
69
+ </CardDescription>
70
+ </CardHeader>
71
+ <CardContent className="space-y-6">
72
+ <Button
73
+ className="w-full"
74
+ type="button"
75
+ variant="outline"
76
+ onClick={signInWithMicrosoft}
77
+ disabled={pending}
78
+ >
79
+ <% if (i18n) { %>{t("continueWithMicrosoft")}<% } else { %>Continue with Microsoft<% } %>
80
+ </Button>
81
+ <% if (isStateful) { %>
82
+ <form className="space-y-4" onSubmit={signUpWithEmail}>
83
+ <div className="space-y-2">
84
+ <Label htmlFor="name"><% if (i18n) { %>{t("name")}<% } else { %>Name<% } %></Label>
85
+ <Input
86
+ id="name"
87
+ type="text"
88
+ autoComplete="name"
89
+ value={name}
90
+ onChange={(event) => setName(event.target.value)}
91
+ required
92
+ />
93
+ </div>
94
+ <div className="space-y-2">
95
+ <Label htmlFor="email"><% if (i18n) { %>{t("email")}<% } else { %>Email<% } %></Label>
96
+ <Input
97
+ id="email"
98
+ type="email"
99
+ autoComplete="email"
100
+ value={email}
101
+ onChange={(event) => setEmail(event.target.value)}
102
+ required
103
+ />
104
+ </div>
105
+ <div className="space-y-2">
106
+ <Label htmlFor="password"><% if (i18n) { %>{t("password")}<% } else { %>Password<% } %></Label>
107
+ <Input
108
+ id="password"
109
+ type="password"
110
+ autoComplete="new-password"
111
+ value={password}
112
+ onChange={(event) => setPassword(event.target.value)}
113
+ minLength={8}
114
+ required
115
+ />
116
+ </div>
117
+ <Button className="w-full" type="submit" disabled={pending}>
118
+ <% if (i18n) { %>{t("createAccountTitle")}<% } else { %>Create account<% } %>
119
+ </Button>
120
+ </form>
121
+ <% } %>
122
+ {error ? <p className="text-sm text-destructive">{error}</p> : null}
123
+ </CardContent>
124
+ <% if (isStateful) { %>
125
+ <CardFooter>
126
+ <p className="text-sm text-muted-foreground">
127
+ <% if (i18n) { %>{t("alreadyHaveAccount")}<% } else { %>Already have an account?<% } %>{" "}
128
+ <Link className="font-medium text-foreground underline" href="/sign-in">
129
+ <% if (i18n) { %>{t("signInTitle")}<% } else { %>Sign in<% } %>
130
+ </Link>
131
+ </p>
132
+ </CardFooter>
133
+ <% } %>
134
+ </Card>
135
+ </main>
136
+ );
137
+ }
@@ -0,0 +1,28 @@
1
+ <% if (i18n) { %>import { getTranslations } from "next-intl/server";
2
+ import { Link } from "@/i18n/navigation";
3
+ <% } else { %>import Link from "next/link";
4
+ <% } %>
5
+
6
+ import { Button } from "@/components/ui/button";
7
+
8
+ export default <% if (i18n) { %>async <% } %>function Unauthorized() {
9
+ <% if (i18n) { %> const t = await getTranslations("Auth");
10
+
11
+ <% } %>
12
+ return (
13
+ <main className="flex flex-1 items-center justify-center px-6 py-24">
14
+ <section className="mx-auto flex max-w-md flex-col items-center gap-4 text-center">
15
+ <p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
16
+ <% if (i18n) { %>{t("authenticationRequired")}<% } else { %>Authentication required<% } %>
17
+ </p>
18
+ <h1 className="text-4xl font-semibold tracking-tight"><% if (i18n) { %>{t("signInToContinue")}<% } else { %>Sign in to continue<% } %></h1>
19
+ <p className="text-muted-foreground">
20
+ <% if (i18n) { %>{t("protectedAction")}<% } else { %>This action is protected. Sign in and then try again.<% } %>
21
+ </p>
22
+ <Button asChild>
23
+ <Link href="/sign-in"><% if (i18n) { %>{t("goToSignIn")}<% } else { %>Go to sign in<% } %></Link>
24
+ </Button>
25
+ </section>
26
+ </main>
27
+ );
28
+ }
@@ -0,0 +1,36 @@
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "<%= projectName %>",
17
+ description: "Generated with Scale Stack",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html
27
+ lang="en"
28
+ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29
+ >
30
+ <body className="min-h-full flex flex-col">
31
+ {/* Scale Stack: wrap with NuqsAdapter and other root providers under src/app/_providers/ */}
32
+ {children}
33
+ </body>
34
+ </html>
35
+ );
36
+ }
@@ -0,0 +1,7 @@
1
+ export default function Loading() {
2
+ return (
3
+ <div className="flex flex-1 items-center justify-center p-8">
4
+ <p className="text-zinc-500 dark:text-zinc-400">Loading…</p>
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,33 @@
1
+ import "./src/env";
2
+ import type { NextConfig } from "next";
3
+ <% if (nextIntl) { %>import createNextIntlPlugin from "next-intl/plugin";
4
+ <% } %>
5
+ /**
6
+ * PRD §7.3 — Browser log forwarding uses `NODE_ENV` only:
7
+ * - `development` → all browser console output to the terminal
8
+ * - `production` → disabled
9
+ * - any other value (e.g. `staging`) → `"error"` only
10
+ */
11
+ function browserToTerminal(): boolean | "error" {
12
+ if (process.env.NODE_ENV === "development") return true;
13
+ if (process.env.NODE_ENV === "production") return false;
14
+ return "error";
15
+ }
16
+
17
+ const nextConfig: NextConfig = {
18
+ reactCompiler: true,
19
+ cacheComponents: true,
20
+ output: "standalone",
21
+ experimental: {
22
+ viewTransition: true,
23
+ <% if (authInterrupts) { %> authInterrupts: true,
24
+ <% } %> },
25
+ typedRoutes: true,
26
+ logging: {
27
+ browserToTerminal: browserToTerminal(),
28
+ },
29
+ };
30
+
31
+ <% if (nextIntl) { %>const withNextIntl = createNextIntlPlugin();
32
+
33
+ export default withNextIntl(nextConfig);<% } else { %>export default nextConfig;<% } %>
@@ -0,0 +1,5 @@
1
+ import { notFound } from "next/navigation";
2
+
3
+ export default function CatchAllNotFoundPage(): never {
4
+ notFound();
5
+ }
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ <% if (i18n) { %>import { useTranslations } from "next-intl";
4
+ <% } %>
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ export default function Error({
8
+ error,
9
+ reset,
10
+ }: {
11
+ error: Error & { digest?: string };
12
+ reset: () => void;
13
+ }) {
14
+ <% if (i18n) { %> const t = useTranslations("Errors");
15
+
16
+ <% } %>
17
+ return (
18
+ <main className="flex flex-1 items-center justify-center px-6 py-24">
19
+ <section className="mx-auto flex max-w-md flex-col items-center gap-4 text-center">
20
+ <p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
21
+ <% if (i18n) { %>{t("errorEyebrow")}<% } else { %>Error<% } %>
22
+ </p>
23
+ <h1 className="text-3xl font-semibold tracking-tight">
24
+ <% if (i18n) { %>{t("errorTitle")}<% } else { %>Something went wrong<% } %>
25
+ </h1>
26
+ <p className="text-muted-foreground">
27
+ {error.message || <% if (i18n) { %>t("unexpectedError")<% } else { %>"An unexpected error occurred."<% } %>}
28
+ </p>
29
+ <Button onClick={reset}><% if (i18n) { %>{t("tryAgain")}<% } else { %>Try again<% } %></Button>
30
+ </section>
31
+ </main>
32
+ );
33
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+
5
+ export default function GlobalError({
6
+ error,
7
+ reset,
8
+ }: {
9
+ error: Error & { digest?: string };
10
+ reset: () => void;
11
+ }) {
12
+ return (
13
+ <html lang="en">
14
+ <body>
15
+ <main className="flex min-h-screen items-center justify-center px-6 py-24">
16
+ <section className="mx-auto flex max-w-md flex-col items-center gap-4 text-center">
17
+ <p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
18
+ Global error
19
+ </p>
20
+ <h1 className="text-3xl font-semibold tracking-tight">
21
+ The app failed to load
22
+ </h1>
23
+ <p className="text-muted-foreground">
24
+ {error.message || "An unexpected root layout error occurred."}
25
+ </p>
26
+ <Button onClick={reset}>Try again</Button>
27
+ </section>
28
+ </main>
29
+ </body>
30
+ </html>
31
+ );
32
+ }
@@ -0,0 +1,28 @@
1
+ <% if (i18n) { %>import { getTranslations } from "next-intl/server";
2
+ import { Link } from "@/i18n/navigation";
3
+ <% } else { %>import Link from "next/link";
4
+ <% } %>
5
+
6
+ import { Button } from "@/components/ui/button";
7
+
8
+ export default <% if (i18n) { %>async <% } %>function NotFound() {
9
+ <% if (i18n) { %> const t = await getTranslations("Errors");
10
+
11
+ <% } %>
12
+ return (
13
+ <main className="flex flex-1 items-center justify-center px-6 py-24">
14
+ <section className="mx-auto flex max-w-md flex-col items-center gap-4 text-center">
15
+ <p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
16
+ <% if (i18n) { %>{t("notFoundEyebrow")}<% } else { %>Scale Stack<% } %>
17
+ </p>
18
+ <h1 className="text-4xl font-semibold tracking-tight"><% if (i18n) { %>{t("notFoundTitle")}<% } else { %>Page not found<% } %></h1>
19
+ <p className="text-muted-foreground">
20
+ <% if (i18n) { %>{t("notFoundDescription")}<% } else { %>The page you are looking for does not exist.<% } %>
21
+ </p>
22
+ <Button asChild>
23
+ <Link href="/"><% if (i18n) { %>{t("goHome")}<% } else { %>Go home<% } %></Link>
24
+ </Button>
25
+ </section>
26
+ </main>
27
+ );
28
+ }
@@ -0,0 +1,29 @@
1
+ # Build output
2
+ .next/
3
+ out/
4
+ build/
5
+ dist/
6
+ coverage/
7
+
8
+ # Dependencies
9
+ node_modules/
10
+ pnpm-lock.yaml
11
+
12
+ # Assets
13
+ public/
14
+
15
+ # Generated
16
+ next-env.d.ts
17
+ src/components/ui/
18
+ src/hooks/use-mobile.ts
19
+ <% if (ignoreAiElementsGeneratedOutput) { %>
20
+ src/components/ai-elements/
21
+ <% } %>
22
+ <% if (ignorePrismaGeneratedOutput) { %>prisma/generated/
23
+ <% } %>
24
+ # Scale Stack state
25
+ .scale-stack/
26
+
27
+ # Vendor skills
28
+ .agents/
29
+ skills-lock.json
@@ -0,0 +1,31 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+ import prettier from "eslint-config-prettier/flat";
5
+
6
+ const eslintConfig = defineConfig([
7
+ ...nextVitals,
8
+ ...nextTs,
9
+ prettier,
10
+ // Override default ignores of eslint-config-next.
11
+ globalIgnores([
12
+ // Default ignores of eslint-config-next:
13
+ ".next/**",
14
+ "out/**",
15
+ "build/**",
16
+ "next-env.d.ts",
17
+ // Generated by shadcn/ui when the UI phase runs.
18
+ "src/components/ui/**",
19
+ "src/hooks/use-mobile.ts",
20
+ <% if (ignoreAiElementsGeneratedOutput) { %> // Generated by AI Elements when the AI chat phase is selected.
21
+ "src/components/ai-elements/**",
22
+ <% } %>
23
+ ".scale-stack/**",
24
+ ".agents/**",
25
+ "skills-lock.json",
26
+ <% if (ignorePrismaGeneratedOutput) { %> // Generated by Prisma when the ORM phase is selected.
27
+ "prisma/generated/**",
28
+ <% } %> ]),
29
+ ]);
30
+
31
+ export default eslintConfig;
@@ -0,0 +1,33 @@
1
+ <% if (i18n) { %>import { getTranslations } from "next-intl/server";
2
+
3
+ <% } %>
4
+ import { ExampleForm } from "./_components/ExampleForm";
5
+
6
+ export default <% if (i18n) { %>async <% } %>function DashboardPage() {
7
+ <% if (i18n) { %> const t = await getTranslations("Dashboard");
8
+
9
+ <% } %>
10
+ return (
11
+ <main className="flex flex-1 flex-col px-6 py-16">
12
+ <section className="mx-auto flex w-full max-w-5xl flex-col gap-6">
13
+ <div className="space-y-4">
14
+ <p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
15
+ <% if (i18n) { %>{t("eyebrow")}<% } else { %>Dashboard<% } %>
16
+ </p>
17
+ <div className="space-y-3">
18
+ <h1 className="max-w-3xl text-3xl font-semibold tracking-tight text-balance">
19
+ <% if (i18n) { %>{t("title")}<% } else { %>React Hook Form and next-safe-action are wired together.<% } %>
20
+ </h1>
21
+ <p className="max-w-3xl text-muted-foreground text-balance">
22
+ <% if (i18n) { %>{t("description")}<% } else { %>This route is the single generated example for mutations: shadcn
23
+ field components render the UI, React Hook Form owns client state,
24
+ Zod validates both client and server input, and next-safe-action
25
+ returns typed data and mapped validation errors.<% } %>
26
+ </p>
27
+ </div>
28
+ </div>
29
+ <ExampleForm />
30
+ </section>
31
+ </main>
32
+ );
33
+ }
@@ -0,0 +1,50 @@
1
+ "use server";
2
+
3
+ import { <%= hasAuth ? "authActionClient as actionClient" : "actionClient" %> } from "@/lib/safe-action";
4
+ import { returnValidationErrors } from "next-safe-action";
5
+ import { z } from "zod";
6
+ import { exampleFormSchema, planValues } from "../_lib/example-form-schema";
7
+
8
+ const exampleFormOutputSchema = z.object({
9
+ message: z.string(),
10
+ workspace: z.string(),
11
+ plan: z.enum(planValues),
12
+ notificationCount: z.number().int().nonnegative(),
13
+ submittedAt: z.iso.datetime(),
14
+ });
15
+
16
+ type ExampleFormOutput = z.output<typeof exampleFormOutputSchema>;
17
+
18
+ export const exampleFormAction = actionClient
19
+ .metadata({ actionName: "dashboard.exampleForm" })
20
+ .inputSchema(exampleFormSchema)
21
+ .outputSchema(exampleFormOutputSchema)
22
+ .action(async ({ parsedInput }) => {
23
+ if (parsedInput.workspace === "admin") {
24
+ return returnValidationErrors(exampleFormSchema, {
25
+ workspace: {
26
+ _errors: ["The admin workspace slug is reserved."],
27
+ },
28
+ });
29
+ }
30
+
31
+ if (parsedInput.plan === "enterprise" && parsedInput.notes.length < 12) {
32
+ return returnValidationErrors(exampleFormSchema, {
33
+ notes: {
34
+ _errors: [
35
+ "Enterprise demos need a short note so the server can validate business rules.",
36
+ ],
37
+ },
38
+ });
39
+ }
40
+
41
+ const result: ExampleFormOutput = {
42
+ message: `Created a typed ${parsedInput.plan} demo request for ${parsedInput.name}.`,
43
+ workspace: parsedInput.workspace,
44
+ plan: parsedInput.plan,
45
+ notificationCount: parsedInput.notifications.length,
46
+ submittedAt: new Date().toISOString(),
47
+ };
48
+
49
+ return result;
50
+ });
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+
3
+ export const planValues = ["starter", "team", "enterprise"] as const;
4
+
5
+ export const planOptions = [
6
+ {
7
+ label: "Starter",
8
+ value: "starter",
9
+ description: "Validate the full stack with a small team workflow.",
10
+ },
11
+ {
12
+ label: "Team",
13
+ value: "team",
14
+ description: "Add collaboration settings and notification preferences.",
15
+ },
16
+ {
17
+ label: "Enterprise",
18
+ value: "enterprise",
19
+ description: "Exercise server-side business validation before submit.",
20
+ },
21
+ ] as const satisfies ReadonlyArray<{
22
+ label: string;
23
+ value: (typeof planValues)[number];
24
+ description: string;
25
+ }>;
26
+
27
+ export const notificationValues = ["product", "security", "billing"] as const;
28
+
29
+ export const notificationOptions = [
30
+ {
31
+ label: "Product updates",
32
+ value: "product",
33
+ description: "Release notes and feature announcements.",
34
+ },
35
+ {
36
+ label: "Security alerts",
37
+ value: "security",
38
+ description: "Actionable account and workspace security events.",
39
+ },
40
+ {
41
+ label: "Billing notices",
42
+ value: "billing",
43
+ description: "Plan, invoice, and payment notifications.",
44
+ },
45
+ ] as const satisfies ReadonlyArray<{
46
+ label: string;
47
+ value: (typeof notificationValues)[number];
48
+ description: string;
49
+ }>;
50
+
51
+ export const exampleFormSchema = z.object({
52
+ name: z
53
+ .string()
54
+ .trim()
55
+ .min(2, "Enter at least 2 characters.")
56
+ .max(80, "Name must be 80 characters or fewer."),
57
+ email: z
58
+ .string()
59
+ .email("Enter a valid email address.")
60
+ .max(120, "Email must be 120 characters or fewer."),
61
+ workspace: z
62
+ .string()
63
+ .trim()
64
+ .min(3, "Use at least 3 characters.")
65
+ .max(40, "Use 40 characters or fewer.")
66
+ .regex(/^[a-z0-9-]+$/, "Use lowercase letters, numbers, and hyphens only."),
67
+ plan: z.enum(planValues),
68
+ notifications: z
69
+ .array(z.enum(notificationValues))
70
+ .min(1, "Select at least one notification type."),
71
+ acceptTerms: z.boolean().refine((value) => value, {
72
+ message: "Accept the demo terms before submitting.",
73
+ }),
74
+ notes: z.string().max(280, "Keep notes under 280 characters."),
75
+ });
76
+
77
+ export type ExampleFormValues = z.infer<typeof exampleFormSchema>;
78
+
79
+ export const exampleFormDefaultValues: ExampleFormValues = {
80
+ name: "",
81
+ email: "",
82
+ workspace: "",
83
+ plan: "starter",
84
+ notifications: ["product"],
85
+ acceptTerms: false,
86
+ notes: "",
87
+ };