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:
@@ -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
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
import { useForm } from "@tanstack/react-form";
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
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 { useEffect, useState } from "react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { getDb } from "@/shared/db/client";
|
import { getDb } from "@/shared/db/client";
|
||||||
import type { ProzessSchritt } from "@/shared/db/schema";
|
|
||||||
import { dbExec } from "@/shared/hooks/use-db";
|
import { dbExec } from "@/shared/hooks/use-db";
|
||||||
import { PROZESS_SCHRITTE } from "@/shared/lib/constants";
|
|
||||||
import { GKV_KASSEN } from "@/shared/lib/gkv-kassen";
|
import { GKV_KASSEN } from "@/shared/lib/gkv-kassen";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
export function OnboardingForm() {
|
export function OnboardingForm() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
const [dbReady, setDbReady] = useState(false);
|
const [dbReady, setDbReady] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// Pre-warm DB on mount so submit is instant
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getDb().then(() => setDbReady(true));
|
getDb().then(() => setDbReady(true));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -27,21 +26,14 @@ export function OnboardingForm() {
|
|||||||
plz: "",
|
plz: "",
|
||||||
ort: "",
|
ort: "",
|
||||||
krankenkasse: "",
|
krankenkasse: "",
|
||||||
aktueller_schritt: "neu" as ProzessSchritt,
|
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await dbExec(
|
await dbExec(
|
||||||
`INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt)
|
`INSERT INTO nutzer (name, plz, ort, krankenkasse, aktueller_schritt)
|
||||||
VALUES ($1, $2, $3, $4, $5)`,
|
VALUES ($1, $2, $3, $4, 'neu')`,
|
||||||
[
|
[value.name, value.plz, value.ort, value.krankenkasse],
|
||||||
value.name,
|
|
||||||
value.plz,
|
|
||||||
value.ort,
|
|
||||||
value.krankenkasse,
|
|
||||||
value.aktueller_schritt,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
navigate({ to: "/prozess" });
|
navigate({ to: "/prozess" });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,141 +47,193 @@ export function OnboardingForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
<div className="flex min-h-[80vh] flex-col items-center justify-center">
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
<div className="space-y-2 text-center">
|
{step === 0 && <IntroScreen1 />}
|
||||||
<h1 className="text-2xl font-bold">Willkommen bei TherapyFinder</h1>
|
{step === 1 && <IntroScreen2 />}
|
||||||
<p className="text-muted-foreground">
|
{step === 2 && (
|
||||||
Erzähl uns ein wenig über dich, damit wir dich auf deinem Weg zur
|
<>
|
||||||
Therapie unterstützen können.
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</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[] }) {
|
function FieldErrors({ errors }: { errors: readonly unknown[] }) {
|
||||||
if (errors.length === 0) return null;
|
if (errors.length === 0) return null;
|
||||||
const messages = errors
|
const messages = errors
|
||||||
|
|||||||
Reference in New Issue
Block a user