add onboarding flow with form, validation, db persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 11:08:01 +01:00
parent e39d758d39
commit 780e9816c1
12 changed files with 365 additions and 46 deletions
@@ -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 (
<div className="flex min-h-[80vh] flex-col items-center justify-center">
<div className="w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Willkommen bei TherapyFinder</h1>
<p className="text-muted-foreground">
Erzähl uns ein wenig über dich, damit wir dich auf deinem Weg zur
Therapie unterstützen können.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="space-y-4"
>
<form.Field name="name">
{(field) => (
<div className="space-y-1">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Max Mustermann"
/>
<FieldErrors errors={field.state.meta.errors} />
</div>
)}
</form.Field>
<div className="grid grid-cols-2 gap-4">
<form.Field name="plz">
{(field) => (
<div className="space-y-1">
<Label htmlFor="plz">PLZ</Label>
<Input
id="plz"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="10115"
inputMode="numeric"
maxLength={5}
/>
<FieldErrors errors={field.state.meta.errors} />
</div>
)}
</form.Field>
<form.Field name="ort">
{(field) => (
<div className="space-y-1">
<Label htmlFor="ort">Ort</Label>
<Input
id="ort"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Berlin"
/>
<FieldErrors errors={field.state.meta.errors} />
</div>
)}
</form.Field>
</div>
<form.Field name="krankenkasse">
{(field) => (
<div className="space-y-1">
<Label htmlFor="krankenkasse">Krankenkasse</Label>
<Input
id="krankenkasse"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="TK"
/>
<FieldErrors errors={field.state.meta.errors} />
</div>
)}
</form.Field>
<form.Field name="aktueller_schritt">
{(field) => (
<div className="space-y-1">
<Label htmlFor="aktueller_schritt">Aktueller Schritt</Label>
<select
id="aktueller_schritt"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange(e.target.value as ProzessSchritt)
}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
>
{PROZESS_SCHRITTE.map((schritt) => (
<option key={schritt.key} value={schritt.key}>
{schritt.label}
</option>
))}
</select>
<FieldErrors errors={field.state.meta.errors} />
</div>
)}
</form.Field>
<Button type="submit" className="w-full">
Weiter
</Button>
</form>
</div>
</div>
);
}
function FieldErrors({
errors,
}: {
errors: ReadonlyArray<string | undefined | { message: string }>;
}) {
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 <p className="text-sm text-destructive">{messages.join(", ")}</p>;
}
+2
View File
@@ -0,0 +1,2 @@
export { OnboardingForm } from "./components/onboarding-form";
export { type OnboardingData, onboardingSchema } from "./schema";
+37
View File
@@ -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);
});
});
+12
View File
@@ -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<typeof onboardingSchema>;