@yosr-ai/template-next-fullstack 0.0.0-stage → 0.1.0-alpha.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 (125) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +15 -2
  3. package/features/api-trpc/files/src/app/api/trpc/[trpc]/route.ts +15 -0
  4. package/features/api-trpc/files/src/app/trpc-provider.tsx +27 -0
  5. package/features/api-trpc/files/src/features/notes/components/note-form.tsx +48 -0
  6. package/features/api-trpc/files/src/server/api/context.ts +10 -0
  7. package/features/api-trpc/files/src/server/api/notes.test.ts +30 -0
  8. package/features/api-trpc/files/src/server/api/notes.ts +42 -0
  9. package/features/api-trpc/files/src/server/api/root.ts +8 -0
  10. package/features/api-trpc/files/src/server/api/trpc.ts +8 -0
  11. package/features/api-trpc/snippets/providers-imports.tsx +2 -0
  12. package/features/api-trpc/snippets/providers.tsx +1 -0
  13. package/features/auth-authjs/files/e2e/sign-in.spec.ts +10 -0
  14. package/features/auth-authjs/files/src/app/api/auth/[...nextauth]/route.ts +12 -0
  15. package/features/auth-authjs/files/src/app/auth-session-provider.tsx +8 -0
  16. package/features/auth-authjs/files/src/app/sign-in/page.tsx +19 -0
  17. package/features/auth-authjs/files/src/server/auth/config.ts +28 -0
  18. package/features/auth-authjs/files/src/server/auth/index.ts +15 -0
  19. package/features/auth-authjs/snippets/providers-imports.tsx +2 -0
  20. package/features/auth-authjs/snippets/providers.tsx +1 -0
  21. package/features/auth-authjs-drizzle/files/src/server/db/auth-tables.ts +86 -0
  22. package/features/auth-authjs-drizzle/snippets/adapter-db-imports.ts +8 -0
  23. package/features/auth-authjs-drizzle/snippets/adapter-imports.ts +1 -0
  24. package/features/auth-authjs-drizzle/snippets/adapter.ts +7 -0
  25. package/features/auth-authjs-drizzle/snippets/auth-tables.ts +7 -0
  26. package/features/auth-authjs-prisma/snippets/adapter-db-imports.ts +1 -0
  27. package/features/auth-authjs-prisma/snippets/adapter-imports.ts +1 -0
  28. package/features/auth-authjs-prisma/snippets/adapter.ts +1 -0
  29. package/features/auth-authjs-prisma/snippets/auth-models.prisma +58 -0
  30. package/features/auth-clerk/files/e2e/sign-in.spec.ts +15 -0
  31. package/features/auth-clerk/files/src/app/clerk-app-provider.tsx +13 -0
  32. package/features/auth-clerk/files/src/app/sign-in/[[...sign-in]]/page.tsx +18 -0
  33. package/features/auth-clerk/files/src/app/sign-up/[[...sign-up]]/page.tsx +18 -0
  34. package/features/auth-clerk/files/src/proxy.ts +24 -0
  35. package/features/auth-clerk/files/src/server/auth/index.ts +11 -0
  36. package/features/auth-clerk/snippets/providers-imports.tsx +2 -0
  37. package/features/auth-clerk/snippets/providers.tsx +1 -0
  38. package/features/deploy-docker/files/.dockerignore +6 -0
  39. package/features/deploy-docker/files/Dockerfile +24 -0
  40. package/features/deploy-docker/files/compose.yaml +6 -0
  41. package/features/deploy-docker-npm/files/Dockerfile +24 -0
  42. package/features/deploy-docker-postgres/snippets/postgres-service.yaml +4 -0
  43. package/features/deploy-vercel/snippets/readme-deploy.md +3 -0
  44. package/features/orm-drizzle/files/drizzle.config.ts +8 -0
  45. package/features/orm-drizzle/files/src/features/notes/notes-store.ts +21 -0
  46. package/features/orm-drizzle/files/src/server/db/index.ts +20 -0
  47. package/features/orm-drizzle/files/src/server/db/schema.ts +11 -0
  48. package/features/orm-drizzle/snippets/ci-e2e-setup.yaml +1 -0
  49. package/features/orm-drizzle/snippets/ci-job.yaml +11 -0
  50. package/features/orm-drizzle/snippets/readme-db.md +4 -0
  51. package/features/orm-prisma/files/prisma/schema.prisma +18 -0
  52. package/features/orm-prisma/files/prisma.config.ts +13 -0
  53. package/features/orm-prisma/files/src/features/notes/notes-store.ts +13 -0
  54. package/features/orm-prisma/files/src/server/db/index.ts +20 -0
  55. package/features/orm-prisma/snippets/ci-e2e-setup.yaml +1 -0
  56. package/features/orm-prisma/snippets/ci-job.yaml +11 -0
  57. package/features/orm-prisma/snippets/readme-db.md +4 -0
  58. package/features/payments-stripe/files/src/app/api/checkout/route.ts +27 -0
  59. package/features/payments-stripe/files/src/app/api/webhooks/stripe/route.ts +18 -0
  60. package/features/payments-stripe/files/src/server/billing/billing.test.ts +113 -0
  61. package/features/payments-stripe/files/src/server/billing/index.ts +102 -0
  62. package/features/payments-stripe/files/src/server/billing/stripe.ts +10 -0
  63. package/features/payments-stripe-drizzle/files/src/server/billing/store.ts +39 -0
  64. package/features/payments-stripe-drizzle/snippets/billing-tables.ts +13 -0
  65. package/features/payments-stripe-prisma/files/src/server/billing/store.ts +30 -0
  66. package/features/payments-stripe-prisma/snippets/billing-models.prisma +17 -0
  67. package/files/.env.example +1 -0
  68. package/files/.github/workflows/ci.yml +25 -0
  69. package/files/.prettierignore +7 -0
  70. package/files/.prettierrc.json +1 -0
  71. package/files/README.md +18 -0
  72. package/files/components.json +20 -0
  73. package/files/e2e/smoke.spec.ts +16 -0
  74. package/files/eslint.config.mjs +21 -0
  75. package/files/gitignore +25 -0
  76. package/files/next.config.ts +7 -0
  77. package/files/package.json +42 -0
  78. package/files/playwright.config.ts +20 -0
  79. package/files/pnpm-workspace.yaml +16 -0
  80. package/files/postcss.config.mjs +7 -0
  81. package/files/src/app/globals.css +68 -0
  82. package/files/src/app/layout.tsx +22 -0
  83. package/files/src/app/page.tsx +21 -0
  84. package/files/src/app/providers.tsx +15 -0
  85. package/files/src/components/ui/button.tsx +59 -0
  86. package/files/src/components/ui/input.tsx +21 -0
  87. package/files/src/features/notes/actions.ts +22 -0
  88. package/files/src/features/notes/components/note-form.tsx +32 -0
  89. package/files/src/features/notes/components/note-list.tsx +23 -0
  90. package/files/src/features/notes/create-note.test.ts +27 -0
  91. package/files/src/features/notes/create-note.ts +23 -0
  92. package/files/src/features/notes/note-schema.ts +13 -0
  93. package/files/src/features/notes/notes-store.ts +17 -0
  94. package/files/src/shared/utils.test.ts +13 -0
  95. package/files/src/shared/utils.ts +6 -0
  96. package/files/tsconfig.json +35 -0
  97. package/files/vitest.config.mts +9 -0
  98. package/package.json +26 -4
  99. package/rules/api-server-actions.md +1 -0
  100. package/rules/api-trpc.md +1 -0
  101. package/rules/architecture.md +11 -0
  102. package/rules/auth-authjs.md +1 -0
  103. package/rules/auth-clerk.md +1 -0
  104. package/rules/commands.md +5 -0
  105. package/rules/data-drizzle.md +1 -0
  106. package/rules/data-none.md +1 -0
  107. package/rules/data-prisma.md +1 -0
  108. package/rules/data-server-first.md +1 -0
  109. package/rules/errors-result.md +1 -0
  110. package/rules/errors-throw.md +1 -0
  111. package/rules/forms-zod.md +1 -0
  112. package/rules/layout-feature.md +5 -0
  113. package/rules/layout-flat.md +5 -0
  114. package/rules/layout-layer.md +6 -0
  115. package/rules/payments-stripe.md +1 -0
  116. package/rules/project-overview.md +5 -0
  117. package/rules/tests-colocated.md +1 -0
  118. package/rules/tests-folder.md +1 -0
  119. package/rules/tests-separate.md +1 -0
  120. package/rules/ui-shadcn.md +1 -0
  121. package/seed.template.json +1714 -0
  122. package/style/errors-result/files/src/features/notes/actions.ts +18 -0
  123. package/style/errors-result/files/src/features/notes/create-note.test.ts +30 -0
  124. package/style/errors-result/files/src/features/notes/create-note.ts +16 -0
  125. package/style/errors-result/files/src/shared/result.ts +12 -0
@@ -0,0 +1,18 @@
1
+ # __seed_projectName__
2
+
3
+ Generated by [Yosr Seed](https://yosr.ai) from the `__seed_templateName__@__seed_templateVersion__` template.
4
+
5
+ ## Commands
6
+
7
+ | Task | Command |
8
+ | --- | --- |
9
+ | Dev server | `__seed_pmRun__ dev` |
10
+ | Lint | `__seed_pmRun__ lint` |
11
+ | Type-check | `__seed_pmRun__ typecheck` |
12
+ | Unit tests (Vitest) | `__seed_pmRun__ test` |
13
+ | E2E smoke test (Playwright) | `__seed_pmRun__ test:e2e` |
14
+ | Format | `__seed_pmRun__ format` |
15
+
16
+ Agent rules live in `AGENTS.md` (also read by Claude Code via `CLAUDE.md` and by Cursor via `.cursor/rules/`). The architecture note is in `docs/architecture.md`.
17
+
18
+ <!-- @seed:anchor readme-sections -->
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@shared/utils",
16
+ "ui": "@ui",
17
+ "lib": "@shared",
18
+ "hooks": "@/hooks"
19
+ }
20
+ }
@@ -0,0 +1,16 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test("home page renders", async ({ page }) => {
4
+ await page.goto("/");
5
+ await expect(page.getByRole("heading", { level: 1 })).toHaveText(
6
+ "__seed_projectName__",
7
+ );
8
+ });
9
+
10
+ test("adds a note through the server action", async ({ page }) => {
11
+ const title = `Smoke ${Date.now()}`;
12
+ await page.goto("/");
13
+ await page.getByLabel("Note title").fill(title);
14
+ await page.getByRole("button", { name: "Add" }).click();
15
+ await expect(page.getByText(title)).toBeVisible();
16
+ });
@@ -0,0 +1,21 @@
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
+ // @seed:anchor eslint-style-imports
6
+
7
+ export default defineConfig([
8
+ ...nextVitals,
9
+ ...nextTs,
10
+ // @seed:anchor eslint-style
11
+ prettier,
12
+ globalIgnores([
13
+ ".next/**",
14
+ "out/**",
15
+ "build/**",
16
+ "next-env.d.ts",
17
+ "playwright-report/**",
18
+ "test-results/**",
19
+ "src/generated/prisma/**",
20
+ ]),
21
+ ]);
@@ -0,0 +1,25 @@
1
+ # dependencies
2
+ node_modules/
3
+
4
+ # next.js
5
+ .next/
6
+ out/
7
+ build/
8
+ next-env.d.ts
9
+ *.tsbuildinfo
10
+
11
+ # testing
12
+ coverage/
13
+ playwright-report/
14
+ test-results/
15
+
16
+ # env files (keep .env.example)
17
+ .env
18
+ .env*.local
19
+
20
+ # prisma client (generated on install)
21
+ src/generated/prisma/
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
@@ -0,0 +1,7 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ // @seed:anchor next-config
5
+ };
6
+
7
+ export default nextConfig;
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "__seed_projectSlug__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint .",
10
+ "typecheck": "next typegen && tsc --noEmit",
11
+ "test": "vitest run",
12
+ "test:e2e": "playwright test",
13
+ "format": "prettier --write .",
14
+ "format:check": "prettier --check ."
15
+ },
16
+ "dependencies": {
17
+ "@radix-ui/react-slot": "1.5.0",
18
+ "class-variance-authority": "0.7.1",
19
+ "clsx": "2.1.1",
20
+ "next": "16.4.2",
21
+ "react": "19.3.1",
22
+ "react-dom": "19.3.1",
23
+ "tailwind-merge": "3.7.0",
24
+ "zod": "4.6.7"
25
+ },
26
+ "devDependencies": {
27
+ "@playwright/test": "1.64.1",
28
+ "@tailwindcss/postcss": "4.3.3",
29
+ "@types/node": "24.19.2",
30
+ "@types/react": "19.3.0",
31
+ "@types/react-dom": "19.3.0",
32
+ "eslint": "10.13.0",
33
+ "eslint-config-next": "16.4.2",
34
+ "eslint-config-prettier": "10.1.8",
35
+ "prettier": "3.9.10",
36
+ "tailwindcss": "4.3.3",
37
+ "tw-animate-css": "1.4.0",
38
+ "typescript": "6.0.3",
39
+ "vite": "8.3.4",
40
+ "vitest": "5.0.5"
41
+ }
42
+ }
@@ -0,0 +1,20 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+
3
+ const port = 3100;
4
+
5
+ export default defineConfig({
6
+ testDir: "./e2e",
7
+ forbidOnly: Boolean(process.env.CI),
8
+ retries: process.env.CI ? 2 : 0,
9
+ use: {
10
+ baseURL: `http://localhost:${port}`,
11
+ trace: "on-first-retry",
12
+ },
13
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
14
+ webServer: {
15
+ command: "__seed_pmRun__ start",
16
+ env: { PORT: String(port) },
17
+ url: `http://localhost:${port}`,
18
+ reuseExistingServer: !process.env.CI,
19
+ },
20
+ });
@@ -0,0 +1,16 @@
1
+ # pnpm settings (pnpm >= 11 reads them from here; npm ignores this file).
2
+ # Dependency install scripts are denied unless listed as true.
3
+ # esbuild and unrs-resolver ship their binaries as optional platform
4
+ # packages, so their scripts stay false. prisma (preinstall) and
5
+ # @prisma/engines (postinstall) must run so the CLI can generate a client
6
+ # and push a schema.
7
+ #
8
+ # nodeLinker: hoisted — Turbopack's production build is ~2× slower against
9
+ # pnpm's default isolated/symlink layout than against a flat tree (ADR 0009,
10
+ # issue #23). Phantom deps are caught by import-x/no-extraneous-dependencies.
11
+ allowBuilds:
12
+ "@prisma/engines": true
13
+ esbuild: false
14
+ prisma: true
15
+ unrs-resolver: false
16
+ nodeLinker: hoisted
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,68 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ @custom-variant dark (&:is(.dark *));
5
+
6
+ @theme inline {
7
+ --color-background: var(--background);
8
+ --color-foreground: var(--foreground);
9
+ --color-primary: var(--primary);
10
+ --color-primary-foreground: var(--primary-foreground);
11
+ --color-secondary: var(--secondary);
12
+ --color-secondary-foreground: var(--secondary-foreground);
13
+ --color-muted: var(--muted);
14
+ --color-muted-foreground: var(--muted-foreground);
15
+ --color-accent: var(--accent);
16
+ --color-accent-foreground: var(--accent-foreground);
17
+ --color-destructive: var(--destructive);
18
+ --color-border: var(--border);
19
+ --color-input: var(--input);
20
+ --color-ring: var(--ring);
21
+ --radius-sm: calc(var(--radius) - 4px);
22
+ --radius-md: calc(var(--radius) - 2px);
23
+ --radius-lg: var(--radius);
24
+ }
25
+
26
+ :root {
27
+ --radius: 0.625rem;
28
+ --background: oklch(1 0 0);
29
+ --foreground: oklch(0.145 0 0);
30
+ --primary: oklch(0.205 0 0);
31
+ --primary-foreground: oklch(0.985 0 0);
32
+ --secondary: oklch(0.97 0 0);
33
+ --secondary-foreground: oklch(0.205 0 0);
34
+ --muted: oklch(0.97 0 0);
35
+ --muted-foreground: oklch(0.556 0 0);
36
+ --accent: oklch(0.97 0 0);
37
+ --accent-foreground: oklch(0.205 0 0);
38
+ --destructive: oklch(0.577 0.245 27.325);
39
+ --border: oklch(0.922 0 0);
40
+ --input: oklch(0.922 0 0);
41
+ --ring: oklch(0.708 0 0);
42
+ }
43
+
44
+ .dark {
45
+ --background: oklch(0.145 0 0);
46
+ --foreground: oklch(0.985 0 0);
47
+ --primary: oklch(0.922 0 0);
48
+ --primary-foreground: oklch(0.205 0 0);
49
+ --secondary: oklch(0.269 0 0);
50
+ --secondary-foreground: oklch(0.985 0 0);
51
+ --muted: oklch(0.269 0 0);
52
+ --muted-foreground: oklch(0.708 0 0);
53
+ --accent: oklch(0.269 0 0);
54
+ --accent-foreground: oklch(0.985 0 0);
55
+ --destructive: oklch(0.704 0.191 22.216);
56
+ --border: oklch(1 0 0 / 10%);
57
+ --input: oklch(1 0 0 / 15%);
58
+ --ring: oklch(0.556 0 0);
59
+ }
60
+
61
+ @layer base {
62
+ * {
63
+ @apply border-border outline-ring/50;
64
+ }
65
+ body {
66
+ @apply bg-background text-foreground;
67
+ }
68
+ }
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from "next";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { Providers } from "./providers";
5
+ import "./globals.css";
6
+
7
+ export const metadata: Metadata = {
8
+ title: "__seed_projectName__",
9
+ description: "Generated by Yosr Seed from next-fullstack",
10
+ };
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: Readonly<{ children: ReactNode }>) {
15
+ return (
16
+ <html lang="en">
17
+ <body className="antialiased">
18
+ <Providers>{children}</Providers>
19
+ </body>
20
+ </html>
21
+ );
22
+ }
@@ -0,0 +1,21 @@
1
+ import { connection } from "next/server";
2
+
3
+ import { listNotes } from "@domain/notes/notes-store";
4
+ import { NoteForm } from "@featureUi/note-form";
5
+ import { NoteList } from "@featureUi/note-list";
6
+
7
+ export default async function HomePage() {
8
+ await connection();
9
+ const notes = await listNotes();
10
+
11
+ return (
12
+ <main className="mx-auto flex max-w-xl flex-col gap-6 p-8">
13
+ <h1 className="text-2xl font-semibold">__seed_projectName__</h1>
14
+ <p className="text-sm text-muted-foreground">
15
+ Example feature: notes, stored in __seed_storageLabel__.
16
+ </p>
17
+ <NoteForm />
18
+ <NoteList notes={notes} />
19
+ </main>
20
+ );
21
+ }
@@ -0,0 +1,15 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ // @seed:anchor providers-imports
3
+
4
+ // Features register app-wide providers here (outermost first). The list is
5
+ // composed below so a feature can add a wrapper with a one-line insert.
6
+ const providers: ComponentType<{ children: ReactNode }>[] = [
7
+ // @seed:anchor providers
8
+ ];
9
+
10
+ export function Providers({ children }: { children: ReactNode }) {
11
+ return providers.reduceRight<ReactNode>(
12
+ (tree, Provider) => <Provider>{tree}</Provider>,
13
+ children,
14
+ );
15
+ }
@@ -0,0 +1,59 @@
1
+ import { Slot } from "@radix-ui/react-slot";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import * as React from "react";
4
+
5
+ import { cn } from "@shared/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
16
+ outline:
17
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20
+ ghost:
21
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22
+ link: "text-primary underline-offset-4 hover:underline",
23
+ },
24
+ size: {
25
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
26
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
27
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28
+ icon: "size-9",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ variant: "default",
33
+ size: "default",
34
+ },
35
+ },
36
+ );
37
+
38
+ function Button({
39
+ className,
40
+ variant,
41
+ size,
42
+ asChild = false,
43
+ ...props
44
+ }: React.ComponentProps<"button"> &
45
+ VariantProps<typeof buttonVariants> & {
46
+ asChild?: boolean;
47
+ }) {
48
+ const Comp = asChild ? Slot : "button";
49
+
50
+ return (
51
+ <Comp
52
+ data-slot="button"
53
+ className={cn(buttonVariants({ variant, size, className }))}
54
+ {...props}
55
+ />
56
+ );
57
+ }
58
+
59
+ export { Button, buttonVariants };
@@ -0,0 +1,21 @@
1
+ import * as React from "react";
2
+
3
+ import { cn } from "@shared/utils";
4
+
5
+ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6
+ return (
7
+ <input
8
+ type={type}
9
+ data-slot="input"
10
+ className={cn(
11
+ "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
12
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
13
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ );
19
+ }
20
+
21
+ export { Input };
@@ -0,0 +1,22 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+
5
+ import { createNote, NoteValidationError } from "./create-note";
6
+
7
+ /** Server action for the note form. Returns an error message or null. */
8
+ export async function createNoteAction(
9
+ _previous: string | null,
10
+ formData: FormData,
11
+ ): Promise<string | null> {
12
+ try {
13
+ await createNote({ title: formData.get("title") });
14
+ } catch (error) {
15
+ if (error instanceof NoteValidationError) {
16
+ return error.message;
17
+ }
18
+ throw error;
19
+ }
20
+ revalidatePath("/");
21
+ return null;
22
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import { useActionState } from "react";
4
+
5
+ import { createNoteAction } from "@domain/notes/actions";
6
+ import { Button } from "@ui/button";
7
+ import { Input } from "@ui/input";
8
+
9
+ export function NoteForm() {
10
+ const [error, formAction, pending] = useActionState(createNoteAction, null);
11
+
12
+ return (
13
+ <form action={formAction} className="flex flex-col gap-2">
14
+ <div className="flex gap-2">
15
+ <Input
16
+ name="title"
17
+ placeholder="Write a note"
18
+ aria-label="Note title"
19
+ maxLength={120}
20
+ />
21
+ <Button type="submit" disabled={pending}>
22
+ Add
23
+ </Button>
24
+ </div>
25
+ {error ? (
26
+ <p role="alert" className="text-sm text-destructive">
27
+ {error}
28
+ </p>
29
+ ) : null}
30
+ </form>
31
+ );
32
+ }
@@ -0,0 +1,23 @@
1
+ import type { Note } from "@domain/notes/note-schema";
2
+
3
+ export function NoteList({ notes }: { notes: Note[] }) {
4
+ if (notes.length === 0) {
5
+ return <p className="text-sm text-muted-foreground">No notes yet.</p>;
6
+ }
7
+
8
+ return (
9
+ <ul className="flex flex-col gap-2">
10
+ {notes.map((note) => (
11
+ <li key={note.id} className="rounded-md border p-3">
12
+ <p className="font-medium">{note.title}</p>
13
+ <time
14
+ dateTime={note.createdAt.toISOString()}
15
+ className="text-xs text-muted-foreground"
16
+ >
17
+ {note.createdAt.toISOString()}
18
+ </time>
19
+ </li>
20
+ ))}
21
+ </ul>
22
+ );
23
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createNote, NoteValidationError } from "@domain/notes/create-note";
4
+
5
+ describe("createNote", () => {
6
+ it("stores a trimmed, valid note", async () => {
7
+ const insert = vi.fn(async (input: { title: string }) => ({
8
+ id: "1",
9
+ title: input.title,
10
+ createdAt: new Date(0),
11
+ }));
12
+
13
+ const note = await createNote({ title: " Buy milk " }, insert);
14
+
15
+ expect(insert).toHaveBeenCalledWith({ title: "Buy milk" });
16
+ expect(note.title).toBe("Buy milk");
17
+ });
18
+
19
+ it("rejects an empty title", async () => {
20
+ const insert = vi.fn();
21
+
22
+ await expect(createNote({ title: " " }, insert)).rejects.toBeInstanceOf(
23
+ NoteValidationError,
24
+ );
25
+ expect(insert).not.toHaveBeenCalled();
26
+ });
27
+ });
@@ -0,0 +1,23 @@
1
+ import { type Note, noteInputSchema } from "./note-schema";
2
+ import { insertNote } from "./notes-store";
3
+
4
+ export class NoteValidationError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = "NoteValidationError";
8
+ }
9
+ }
10
+
11
+ /** Validates input and stores a note. Throws NoteValidationError on bad input. */
12
+ export async function createNote(
13
+ input: unknown,
14
+ insert: typeof insertNote = insertNote,
15
+ ): Promise<Note> {
16
+ const parsed = noteInputSchema.safeParse(input);
17
+ if (!parsed.success) {
18
+ throw new NoteValidationError(
19
+ parsed.error.issues[0]?.message ?? "Invalid note",
20
+ );
21
+ }
22
+ return insert(parsed.data);
23
+ }
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+
3
+ export const noteInputSchema = z.object({
4
+ title: z.string().trim().min(1, "Title is required").max(120),
5
+ });
6
+
7
+ export const noteSchema = noteInputSchema.extend({
8
+ id: z.string(),
9
+ createdAt: z.date(),
10
+ });
11
+
12
+ export type NoteInput = z.infer<typeof noteInputSchema>;
13
+ export type Note = z.infer<typeof noteSchema>;
@@ -0,0 +1,17 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type { Note, NoteInput } from "./note-schema";
4
+
5
+ // In-memory store used when the project has no database. Data resets when the
6
+ // server restarts. The Postgres overlay replaces this file.
7
+ const notes: Note[] = [];
8
+
9
+ export async function insertNote(input: NoteInput): Promise<Note> {
10
+ const note = { id: randomUUID(), title: input.title, createdAt: new Date() };
11
+ notes.unshift(note);
12
+ return note;
13
+ }
14
+
15
+ export async function listNotes(): Promise<Note[]> {
16
+ return [...notes];
17
+ }
@@ -0,0 +1,13 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { cn } from "@shared/utils";
4
+
5
+ describe("cn", () => {
6
+ it("merges conflicting Tailwind classes, last one wins", () => {
7
+ expect(cn("px-2 py-1", "px-4")).toBe("py-1 px-4");
8
+ });
9
+
10
+ it("drops falsy values", () => {
11
+ expect(cn("a", false, undefined, "b")).toBe("a b");
12
+ });
13
+ });
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": {
18
+ "@/*": ["./src/*"],
19
+ "@domain/*": ["./src/features/*"],
20
+ "@featureUi/*": ["./src/features/notes/components/*"],
21
+ "@server/*": ["./src/server/*"],
22
+ "@ui/*": ["./src/components/ui/*"],
23
+ "@shared/*": ["./src/shared/*"]
24
+ }
25
+ },
26
+ "include": [
27
+ "next-env.d.ts",
28
+ "**/*.ts",
29
+ "**/*.tsx",
30
+ "**/*.mts",
31
+ ".next/types/**/*.ts",
32
+ ".next/dev/types/**/*.ts"
33
+ ],
34
+ "exclude": ["node_modules"]
35
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ resolve: { tsconfigPaths: true },
5
+ test: {
6
+ environment: "node",
7
+ include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
8
+ },
9
+ });