add onboarding flow with form, validation, db persistence
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OnboardingForm } from "./components/onboarding-form";
|
||||
export { type OnboardingData, onboardingSchema } from "./schema";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user