rework onboarding as 3-screen wizard, mask PGlite download

two intro screens explain the app purpose, third screen is the
form. remove step selector, always start at neu. dot indicators
for progress, "App wird vorbereitet" while DB loads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 14:43:14 +01:00
parent 45bee68f52
commit 88933e9c13
2 changed files with 223 additions and 141 deletions

View File

@@ -0,0 +1,38 @@
# Onboarding Redesign (Issue #2, Sub-project 1)
## Goal
Replace the single-screen onboarding form with a 3-screen wizard that explains the app's purpose and masks the PGlite download time with static content.
## Screen flow
3 screens with dot progress indicators and a "Weiter" button. Forward-only navigation.
### Screen 1 — Was ist TherapyFinder?
Headline + 2-3 sentences: the app helps organize your path to therapy — tracking contacts, documenting rejections, preparing your Kostenerstattungsantrag.
### Screen 2 — Warum dieses Tool?
Headline + 2-3 sentences: the process is long and bureaucratic, this tool keeps track so you don't have to. Data stays on device.
### Screen 3 — Über dich
Form fields (no step selector):
- Name
- PLZ + Ort (grid, 2 columns)
- Krankenkasse (with GKV datalist autocomplete)
- "Weiter" button — creates nutzer with `aktueller_schritt = 'neu'`, navigates to `/prozess`
- Button shows "App wird vorbereitet…" if DB isn't ready yet
### PGlite masking
`getDb()` is already called on component mount. Screens 1-2 provide reading time. No changes to loading logic needed.
### Implementation
Rewrite `OnboardingForm` as a stateful wizard (`step` state: 0, 1, 2). Screens 0-1 are static JSX. Screen 2 is the form. Dot indicators at the bottom (3 dots, active one highlighted). Remove `aktueller_schritt` form field and its import of `PROZESS_SCHRITTE`.
## Files changed
- **Modify:** `src/features/onboarding/components/onboarding-form.tsx` — wizard with 3 screens

View File

@@ -1,22 +1,21 @@
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "@tanstack/react-router";
import { Loader2 } from "lucide-react";
import { ClipboardList, Loader2, Lock, Route } 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";
import { GKV_KASSEN } from "@/shared/lib/gkv-kassen";
import { cn } from "@/shared/lib/utils";
export function OnboardingForm() {
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [dbReady, setDbReady] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Pre-warm DB on mount so submit is instant
useEffect(() => {
getDb().then(() => setDbReady(true));
}, []);
@@ -27,21 +26,14 @@ export function OnboardingForm() {
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,
],
VALUES ($1, $2, $3, $4, 'neu')`,
[value.name, value.plz, value.ort, value.krankenkasse],
);
navigate({ to: "/prozess" });
} finally {
@@ -55,141 +47,193 @@ export function OnboardingForm() {
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.
{step === 0 && <IntroScreen1 />}
{step === 1 && <IntroScreen2 />}
{step === 2 && (
<>
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Über dich</h1>
<p className="text-muted-foreground">
Diese Angaben helfen uns, deinen Kostenerstattungsantrag
vorzubereiten.
</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"
list="gkv-kassen"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="z.B. Techniker Krankenkasse"
/>
<datalist id="gkv-kassen">
{GKV_KASSEN.map((k) => (
<option key={k} value={k} />
))}
</datalist>
<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" />
App wird vorbereitet
</>
) : (
"Weiter"
)}
</Button>
</form>
</>
)}
{step < 2 && (
<Button className="w-full" onClick={() => setStep(step + 1)}>
Weiter
</Button>
)}
<DotIndicator current={step} />
</div>
</div>
);
}
function IntroScreen1() {
return (
<div className="space-y-4 text-center">
<div className="mx-auto flex size-16 items-center justify-center rounded-full bg-primary/10">
<Route className="size-8 text-primary" />
</div>
<h1 className="text-2xl font-bold">Willkommen bei TherapyFinder</h1>
<p className="text-muted-foreground">
TherapyFinder begleitet dich auf dem Weg zur Psychotherapie. Die App
hilft dir, Kontaktversuche zu dokumentieren, Absagen festzuhalten und
deinen Kostenerstattungsantrag vorzubereiten.
</p>
<div className="flex flex-col gap-3 pt-2 text-left">
<div className="flex items-start gap-3">
<ClipboardList className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm text-muted-foreground">
Verwalte deine Therapeutensuche an einem Ort vom Erstgespräch bis
zum fertigen Antrag.
</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"
list="gkv-kassen"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="z.B. Techniker Krankenkasse"
/>
<datalist id="gkv-kassen">
{GKV_KASSEN.map((k) => (
<option key={k} value={k} />
))}
</datalist>
<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 IntroScreen2() {
return (
<div className="space-y-4 text-center">
<div className="mx-auto flex size-16 items-center justify-center rounded-full bg-primary/10">
<Lock className="size-8 text-primary" />
</div>
<h1 className="text-2xl font-bold">Dein Begleiter im Prozess</h1>
<p className="text-muted-foreground">
Einen Therapieplatz zu finden dauert oft Wochen oder Monate. Der Weg ist
bürokratisch und braucht Ausdauer. TherapyFinder behält den Überblick,
damit du dich auf das Wesentliche konzentrieren kannst.
</p>
<p className="text-sm text-muted-foreground">
Alle Daten bleiben auf deinem Gerät es gibt keine Accounts und keine
Cloud.
</p>
</div>
);
}
function DotIndicator({ current }: { current: number }) {
const dots = ["intro", "motivation", "form"] as const;
return (
<div className="flex justify-center gap-2">
{dots.map((id, i) => (
<div
key={id}
className={cn(
"size-2 rounded-full transition-colors",
i === current ? "bg-primary" : "bg-muted-foreground/30",
)}
/>
))}
</div>
);
}
function FieldErrors({ errors }: { errors: readonly unknown[] }) {
if (errors.length === 0) return null;
const messages = errors