scale-stack 0.0.1-alpha.2 → 0.0.2
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/CHANGELOG.md +34 -0
- package/README.md +67 -5
- package/dist/index.js +4081 -106
- package/package.json +7 -2
- package/templates/ai-chat/chat-panel.tsx.ejs +70 -0
- package/templates/ai-chat/layout.tsx.ejs +35 -0
- package/templates/ai-chat/page.tsx.ejs +37 -0
- package/templates/ai-chat/route.ts.ejs +68 -0
- package/templates/ai-chat/use-chat.ts.ejs +26 -0
- package/templates/analytics/analytics-provider.tsx.ejs +32 -0
- package/templates/analytics/analytics.ts.ejs +40 -0
- package/templates/auth/auth-client.ts.ejs +7 -0
- package/templates/auth/auth.ts.ejs +45 -0
- package/templates/auth/route.ts.ejs +4 -0
- package/templates/auth/sign-in-page.tsx.ejs +122 -0
- package/templates/auth/sign-up-page.tsx.ejs +137 -0
- package/templates/auth/unauthorized.tsx.ejs +28 -0
- package/templates/core/layout.tsx.ejs +46 -0
- package/templates/core/loading.tsx.ejs +7 -0
- package/templates/core/next.config.ts.ejs +33 -0
- package/templates/error-handling/catch-all-not-found-page.tsx.ejs +5 -0
- package/templates/error-handling/error.tsx.ejs +33 -0
- package/templates/error-handling/global-error.tsx.ejs +32 -0
- package/templates/error-handling/not-found.tsx.ejs +28 -0
- package/templates/eslint-prettier/.prettierignore.ejs +29 -0
- package/templates/eslint-prettier/eslint.config.mjs.ejs +31 -0
- package/templates/form-handling/dashboard-page.tsx.ejs +39 -0
- package/templates/form-handling/example-form-action.ts.ejs +50 -0
- package/templates/form-handling/example-form-schema.ts.ejs +87 -0
- package/templates/form-handling/example-form.tsx.ejs +428 -0
- package/templates/i18n/ar.json.ejs +77 -0
- package/templates/i18n/en.json.ejs +77 -0
- package/templates/i18n/locale-layout.tsx.ejs +81 -0
- package/templates/i18n/navigation.ts.ejs +5 -0
- package/templates/i18n/next-intl.d.ts.ejs +9 -0
- package/templates/i18n/request.ts.ejs +15 -0
- package/templates/i18n/routing.ts.ejs +7 -0
- package/templates/orm/prisma.config.ts.ejs +12 -0
- package/templates/orm/prisma.ts.ejs +17 -0
- package/templates/orm/schema.prisma.ejs +8 -0
- package/templates/pre-commit/prek.toml.ejs +35 -0
- package/templates/proxy/proxy.ts.ejs +81 -0
- package/templates/server-actions/safe-action.ts.ejs +51 -0
- package/templates/ui/client-side-wrappers.tsx.ejs +19 -0
- package/templates/ui/page.tsx.ejs +117 -0
- package/templates/utility-libs/date-fns.SKILL.md.ejs +34 -0
- package/templates/utility-libs/motion.SKILL.md.ejs +234 -0
- package/templates/utility-libs/ts-pattern.SKILL.md.ejs +44 -0
- package/templates/utility-libs/usehooks.SKILL.md.ejs +38 -0
|
@@ -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,46 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
<% if (includeClientSideWrappers) { %>
|
|
5
|
+
import { Suspense } from "react";
|
|
6
|
+
import { ClientSideWrappers } from "./_providers/client-side-wrappers";
|
|
7
|
+
<% } %>
|
|
8
|
+
|
|
9
|
+
const geistSans = Geist({
|
|
10
|
+
variable: "--font-geist-sans",
|
|
11
|
+
subsets: ["latin"],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const geistMono = Geist_Mono({
|
|
15
|
+
variable: "--font-geist-mono",
|
|
16
|
+
subsets: ["latin"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const metadata: Metadata = {
|
|
20
|
+
title: "<%= projectName %>",
|
|
21
|
+
description: "Generated with Scale Stack",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default function RootLayout({
|
|
25
|
+
children,
|
|
26
|
+
}: Readonly<{
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
}>) {
|
|
29
|
+
return (
|
|
30
|
+
<html
|
|
31
|
+
lang="en"
|
|
32
|
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
33
|
+
>
|
|
34
|
+
<body className="min-h-full flex flex-col">
|
|
35
|
+
<% if (includeClientSideWrappers) { %>
|
|
36
|
+
<Suspense>
|
|
37
|
+
<ClientSideWrappers>{children}</ClientSideWrappers>
|
|
38
|
+
</Suspense>
|
|
39
|
+
<% } else { %>
|
|
40
|
+
{/* Scale Stack: wrap with NuqsAdapter and other root providers under src/app/_providers/ */}
|
|
41
|
+
{children}
|
|
42
|
+
<% } %>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -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,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,39 @@
|
|
|
1
|
+
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
2
|
+
import type { Locale } from "next-intl";
|
|
3
|
+
|
|
4
|
+
<% } %>
|
|
5
|
+
import { ExampleForm } from "./_components/ExampleForm";
|
|
6
|
+
|
|
7
|
+
<% if (i18n) { %>type DashboardPageProps = Readonly<{
|
|
8
|
+
params: Promise<{ locale: Locale }>;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
<% } %>export default <% if (i18n) { %>async <% } %>function DashboardPage(<% if (i18n) { %>{ params }: DashboardPageProps<% } %>) {
|
|
12
|
+
<% if (i18n) { %> const { locale } = await params;
|
|
13
|
+
const t = await getTranslations({ locale, namespace: "Dashboard" });
|
|
14
|
+
|
|
15
|
+
<% } %>
|
|
16
|
+
return (
|
|
17
|
+
<main className="flex flex-1 flex-col px-6 py-16">
|
|
18
|
+
<section className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
|
19
|
+
<div className="space-y-4">
|
|
20
|
+
<p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
|
|
21
|
+
<% if (i18n) { %>{t("eyebrow")}<% } else { %>Dashboard<% } %>
|
|
22
|
+
</p>
|
|
23
|
+
<div className="space-y-3">
|
|
24
|
+
<h1 className="max-w-3xl text-3xl font-semibold tracking-tight text-balance">
|
|
25
|
+
<% if (i18n) { %>{t("title")}<% } else { %>React Hook Form and next-safe-action are wired together.<% } %>
|
|
26
|
+
</h1>
|
|
27
|
+
<p className="max-w-3xl text-muted-foreground text-balance">
|
|
28
|
+
<% if (i18n) { %>{t("description")}<% } else { %>This route is the single generated example for mutations: shadcn
|
|
29
|
+
field components render the UI, React Hook Form owns client state,
|
|
30
|
+
Zod validates both client and server input, and next-safe-action
|
|
31
|
+
returns typed data and mapped validation errors.<% } %>
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<ExampleForm />
|
|
36
|
+
</section>
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -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
|
+
};
|