- 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>
200 lines
5.7 KiB
TypeScript
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>;
|
|
}
|