From 780e9816c1f7ac949b2d8c9e2ef77fc864f31aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 11 Mar 2026 11:08:01 +0100 Subject: [PATCH] add onboarding flow with form, validation, db persistence Co-Authored-By: Claude Opus 4.6 --- biome.json | 18 +- bun.lock | 16 ++ package.json | 2 + .../onboarding/components/onboarding-form.tsx | 172 ++++++++++++++++++ src/features/onboarding/index.ts | 2 + src/features/onboarding/schema.test.ts | 37 ++++ src/features/onboarding/schema.ts | 12 ++ src/routeTree.gen.ts | 80 ++++---- src/routes/index.tsx | 25 ++- src/routes/onboarding/index.tsx | 6 + src/shared/components/ui/input.tsx | 21 +++ src/shared/components/ui/label.tsx | 20 ++ 12 files changed, 365 insertions(+), 46 deletions(-) create mode 100644 src/features/onboarding/components/onboarding-form.tsx create mode 100644 src/features/onboarding/index.ts create mode 100644 src/features/onboarding/schema.test.ts create mode 100644 src/features/onboarding/schema.ts create mode 100644 src/routes/onboarding/index.tsx create mode 100644 src/shared/components/ui/input.tsx create mode 100644 src/shared/components/ui/label.tsx diff --git a/biome.json b/biome.json index 09c7a78..689af8f 100644 --- a/biome.json +++ b/biome.json @@ -13,7 +13,21 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "a11y": { + "noLabelWithoutControl": "off" + } } - } + }, + "overrides": [ + { + "includes": ["src/routeTree.gen.ts"], + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + } + } + ] } diff --git a/bun.lock b/bun.lock index a84d24e..aa056b8 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,10 @@ "dependencies": { "@electric-sql/pglite": "^0.3.16", "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-form": "^1.28.4", "@tanstack/react-router": "^1.166.7", "@tanstack/router-plugin": "^1.166.7", + "@tanstack/zod-form-adapter": "^0.42.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", @@ -564,8 +566,16 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.1", "", {}, "sha512-GRxmPw4OHZ2oZeIEUkEwt/NDvuEqzEYRAjzUVMs+I0pd4C7k1ySOiuJK2CqF+K/yEAR3YZNkW3ExrpDarh9Vwg=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.28.4", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.9.1" } }, "sha512-2eox5ePrJ6kvA1DXD5QHk/GeGr3VFZ0uYR63UgQOe7bUg6h1JfXaIMqTjZK9sdGyE4oRNqFpoW54H0pZM7nObQ=="], + "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + + "@tanstack/react-form": ["@tanstack/react-form@1.28.4", "", { "dependencies": { "@tanstack/form-core": "1.28.4", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ZGBwl9JM2u0kol7jAWpqAkr2JSHfXJaLPsFDZWPf+ewpVkwngTTW/rGgtoDe5uVpHoDIpOhzpPCAh6O1SjGEOg=="], + "@tanstack/react-router": ["@tanstack/react-router@1.166.7", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.166.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw=="], "@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="], @@ -582,6 +592,8 @@ "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.4", "", {}, "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w=="], + "@tanstack/zod-form-adapter": ["@tanstack/zod-form-adapter@0.42.1", "", { "dependencies": { "@tanstack/form-core": "0.42.1" }, "peerDependencies": { "zod": "^3.x" } }, "sha512-hPRM0lawVKP64yurW4c6KHZH6altMo2MQN14hfi+GMBTKjO9S7bW1x5LPZ5cayoJE3mBvdlahpSGT5rYZtSbXQ=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], @@ -1408,6 +1420,8 @@ "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@tanstack/zod-form-adapter/@tanstack/form-core": ["@tanstack/form-core@0.42.1", "", { "dependencies": { "@tanstack/store": "^0.7.0" } }, "sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -1444,6 +1458,8 @@ "@rollup/plugin-node-resolve/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@tanstack/zod-form-adapter/@tanstack/form-core/@tanstack/store": ["@tanstack/store@0.7.7", "", {}, "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/package.json b/package.json index 94894d5..c3c9ce1 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "dependencies": { "@electric-sql/pglite": "^0.3.16", "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-form": "^1.28.4", "@tanstack/react-router": "^1.166.7", "@tanstack/router-plugin": "^1.166.7", + "@tanstack/zod-form-adapter": "^0.42.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", diff --git a/src/features/onboarding/components/onboarding-form.tsx b/src/features/onboarding/components/onboarding-form.tsx new file mode 100644 index 0000000..61c3361 --- /dev/null +++ b/src/features/onboarding/components/onboarding-form.tsx @@ -0,0 +1,172 @@ +import { useForm } from "@tanstack/react-form"; +import { useNavigate } from "@tanstack/react-router"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { Label } from "@/shared/components/ui/label"; +import type { ProzessSchritt } from "@/shared/db/schema"; +import { dbExec } from "@/shared/hooks/use-db"; +import { PROZESS_SCHRITTE } from "@/shared/lib/constants"; +import type { OnboardingData } from "../schema"; +import { onboardingSchema } from "../schema"; + +export function OnboardingForm() { + const navigate = useNavigate(); + + const form = useForm({ + defaultValues: { + name: "", + plz: "", + ort: "", + krankenkasse: "", + aktueller_schritt: "neu" as ProzessSchritt, + } satisfies OnboardingData, + validators: { + onSubmit: onboardingSchema, + }, + onSubmit: async ({ value }) => { + await dbExec( + `INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt) + VALUES ($1, $2, $3, $4, $5)`, + [ + value.name, + value.plz, + value.ort, + value.krankenkasse, + value.aktueller_schritt, + ], + ); + navigate({ to: "/prozess" as string }); + }, + }); + + return ( +
+
+
+

Willkommen bei TherapyFinder

+

+ Erzähl uns ein wenig über dich, damit wir dich auf deinem Weg zur + Therapie unterstützen können. +

+
+ +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="space-y-4" + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + placeholder="Max Mustermann" + /> + +
+ )} +
+ +
+ + {(field) => ( +
+ + field.handleChange(e.target.value)} + placeholder="10115" + inputMode="numeric" + maxLength={5} + /> + +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + placeholder="Berlin" + /> + +
+ )} +
+
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + placeholder="TK" + /> + +
+ )} +
+ + + {(field) => ( +
+ + + +
+ )} +
+ + +
+
+
+ ); +} + +function FieldErrors({ + errors, +}: { + errors: ReadonlyArray; +}) { + const messages = errors + .filter((e): e is string | { message: string } => e != null) + .map((e) => (typeof e === "string" ? e : e.message)); + if (messages.length === 0) return null; + return

{messages.join(", ")}

; +} diff --git a/src/features/onboarding/index.ts b/src/features/onboarding/index.ts new file mode 100644 index 0000000..9f03026 --- /dev/null +++ b/src/features/onboarding/index.ts @@ -0,0 +1,2 @@ +export { OnboardingForm } from "./components/onboarding-form"; +export { type OnboardingData, onboardingSchema } from "./schema"; diff --git a/src/features/onboarding/schema.test.ts b/src/features/onboarding/schema.test.ts new file mode 100644 index 0000000..bfb3c75 --- /dev/null +++ b/src/features/onboarding/schema.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { onboardingSchema } from "./schema"; + +describe("onboardingSchema", () => { + it("accepts valid data", () => { + const result = onboardingSchema.safeParse({ + name: "Max Mustermann", + plz: "10115", + ort: "Berlin", + krankenkasse: "TK", + aktueller_schritt: "neu", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid PLZ", () => { + const result = onboardingSchema.safeParse({ + name: "Max", + plz: "123", + ort: "Berlin", + krankenkasse: "TK", + aktueller_schritt: "neu", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty name", () => { + const result = onboardingSchema.safeParse({ + name: "", + plz: "10115", + ort: "Berlin", + krankenkasse: "TK", + aktueller_schritt: "neu", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/src/features/onboarding/schema.ts b/src/features/onboarding/schema.ts new file mode 100644 index 0000000..44a3a3c --- /dev/null +++ b/src/features/onboarding/schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { prozessSchrittEnum } from "@/shared/db/schema"; + +export const onboardingSchema = z.object({ + name: z.string().min(1, "Bitte gib deinen Namen ein."), + plz: z.string().regex(/^\d{5}$/, "Bitte gib eine gültige PLZ ein."), + ort: z.string().min(1, "Bitte gib deinen Ort ein."), + krankenkasse: z.string().min(1, "Bitte gib deine Krankenkasse ein."), + aktueller_schritt: prozessSchrittEnum, +}); + +export type OnboardingData = z.infer; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d204c26..74becb9 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -8,52 +8,70 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as IndexRouteImport } from './routes/index' +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as OnboardingIndexRouteImport } from "./routes/onboarding/index"; const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -} as any) + id: "/", + path: "/", + getParentRoute: () => rootRouteImport, +} as any); +const OnboardingIndexRoute = OnboardingIndexRouteImport.update({ + id: "/onboarding/", + path: "/onboarding/", + getParentRoute: () => rootRouteImport, +} as any); export interface FileRoutesByFullPath { - '/': typeof IndexRoute + "/": typeof IndexRoute; + "/onboarding/": typeof OnboardingIndexRoute; } export interface FileRoutesByTo { - '/': typeof IndexRoute + "/": typeof IndexRoute; + "/onboarding": typeof OnboardingIndexRoute; } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/': typeof IndexRoute + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/onboarding/": typeof OnboardingIndexRoute; } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' - fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: "/" | "/onboarding/"; + fileRoutesByTo: FileRoutesByTo; + to: "/" | "/onboarding"; + id: "__root__" | "/" | "/onboarding/"; + fileRoutesById: FileRoutesById; } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute + IndexRoute: typeof IndexRoute; + OnboardingIndexRoute: typeof OnboardingIndexRoute; } -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport - parentRoute: typeof rootRouteImport - } - } +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/onboarding/": { + id: "/onboarding/"; + path: "/onboarding"; + fullPath: "/onboarding/"; + preLoaderRoute: typeof OnboardingIndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + } } const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, -} + IndexRoute: IndexRoute, + OnboardingIndexRoute: OnboardingIndexRoute, +}; export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileChildren(rootRouteChildren) + ._addFileTypes(); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ffcaeb8..5bd0f60 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,15 +1,14 @@ -import { createFileRoute } from "@tanstack/react-router" -import { Button } from "@/shared/components/ui/button" +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { getDb } from "@/shared/db/client"; export const Route = createFileRoute("/")({ - component: IndexPage, -}) - -function IndexPage() { - return ( -
-

TherapyFinder

- -
- ) -} + beforeLoad: async () => { + const db = await getDb(); + const result = await db.query("SELECT id FROM nutzer LIMIT 1"); + if (result.rows.length === 0) { + throw redirect({ to: "/onboarding" }); + } + // /prozess route will be added in a future task + throw redirect({ to: "/onboarding" as string }); + }, +}); diff --git a/src/routes/onboarding/index.tsx b/src/routes/onboarding/index.tsx new file mode 100644 index 0000000..8793bd8 --- /dev/null +++ b/src/routes/onboarding/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { OnboardingForm } from "@/features/onboarding/components/onboarding-form"; + +export const Route = createFileRoute("/onboarding/")({ + component: () => , +}); diff --git a/src/shared/components/ui/input.tsx b/src/shared/components/ui/input.tsx new file mode 100644 index 0000000..94bc44d --- /dev/null +++ b/src/shared/components/ui/input.tsx @@ -0,0 +1,21 @@ +import { type ComponentProps, forwardRef } from "react"; +import { cn } from "@/shared/lib/utils"; + +const Input = forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/shared/components/ui/label.tsx b/src/shared/components/ui/label.tsx new file mode 100644 index 0000000..27f2e54 --- /dev/null +++ b/src/shared/components/ui/label.tsx @@ -0,0 +1,20 @@ +import { type ComponentProps, forwardRef } from "react"; +import { cn } from "@/shared/lib/utils"; + +const Label = forwardRef>( + ({ className, ...props }, ref) => { + return ( +