Files
tpf/src/features/onboarding/components/onboarding-form.tsx
Felix Förtsch 16de72c017 add settings page, contact editing, process flow, PWA updates
- settings tab with theme picker, data management, developer mode (mock data), PWA version/update
- contact detail page with inline editing of therapist info and contact attempts
- quick-action buttons on contact cards (update latest kontakt instead of creating duplicates)
- process stepper with inline step actions, Erstgespräch form embedded in step 1
- sticky bottom tab bar, Switch component, .htaccess in build output
- pre-warm PGlite on onboarding mount, spinner feedback on submit
- move DB init out of beforeLoad for faster initial page render
- bump to 2026.03.11.1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:08:48 +01:00

200 lines
5.7 KiB
TypeScript

import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { getDb } from "@/shared/db/client";
import type { ProzessSchritt } from "@/shared/db/schema";
import { dbExec } from "@/shared/hooks/use-db";
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
export function OnboardingForm() {
const navigate = useNavigate();
const [dbReady, setDbReady] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Pre-warm DB on mount so submit is instant
useEffect(() => {
getDb().then(() => setDbReady(true));
}, []);
const form = useForm({
defaultValues: {
name: "",
plz: "",
ort: "",
krankenkasse: "",
aktueller_schritt: "neu" as ProzessSchritt,
},
onSubmit: async ({ value }) => {
setSubmitting(true);
try {
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" });
} finally {
setSubmitting(false);
}
},
});
const isDisabled = !dbReady || submitting;
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" disabled={isDisabled}>
{submitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Wird gespeichert
</>
) : !dbReady ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Datenbank wird geladen
</>
) : (
"Weiter"
)}
</Button>
</form>
</div>
</div>
);
}
function FieldErrors({ errors }: { errors: readonly unknown[] }) {
if (errors.length === 0) return null;
const messages = errors
.filter((e): e is string | { message: string } => e != null)
.map((e) =>
typeof e === "string"
? e
: typeof e === "object" && "message" in (e as Record<string, unknown>)
? (e as { message: string }).message
: String(e),
);
if (messages.length === 0) return null;
return <p className="text-sm text-destructive">{messages.join(", ")}</p>;
}