Merge claude/onboarding-fixes into main

Persist values, tighten validation, and a11y on /onboarding
This commit is contained in:
t.indorf
2026-05-17 23:53:57 +02:00
4 changed files with 156 additions and 44 deletions
+54 -8
View File
@@ -16,20 +16,34 @@ import { prisma } from "@/lib/prisma";
// — kein "halb-eingerichteter" Zwischenzustand. // — kein "halb-eingerichteter" Zwischenzustand.
// ===================================================================== // =====================================================================
const onboardingSchema = z.object({ const onboardingSchema = z
.object({
kitaName: z kitaName: z
.string() .string()
.min(2, "Mindestens 2 Zeichen.") .trim()
.max(120, "Maximal 120 Zeichen.") .min(2, "Bitte mindestens 2 Zeichen angeben.")
.trim(), .max(120, "Maximal 120 Zeichen."),
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
notdienstMinPerChildPerMonth: z.coerce notdienstMinPerChildPerMonth: z.coerce
.number() .number()
.int("Bitte ganze Zahl.") .int("Bitte eine ganze Zahl angeben.")
.min(0, "Nicht negativ.") .min(0, "Bitte einen Wert von 0 oder mehr angeben.")
.max(31, "Maximal 31 Tage pro Monat."), .max(31, "Maximal 31 Tage pro Monat."),
})
.superRefine((data, ctx) => {
// Mindest-Verfügbarkeit nur relevant, wenn das Notdienst-Modul aktiv ist.
// Bei aktivem Modul muss mindestens 1 Tag eingetragen sein — 0 wäre
// fachlich sinnlos und würde den Planer leerlaufen lassen.
if (data.notdienstModuleEnabled && data.notdienstMinPerChildPerMonth < 1) {
ctx.addIssue({
path: ["notdienstMinPerChildPerMonth"],
code: "custom",
message:
"Bei aktivem Notdienst-Modul: mindestens 1 Tag pro Monat angeben.",
});
}
}); });
function checkboxToBool(value: unknown): boolean { function checkboxToBool(value: unknown): boolean {
@@ -38,6 +52,17 @@ function checkboxToBool(value: unknown): boolean {
} }
export type OnboardingState = { export type OnboardingState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs so a failed submit doesn't wipe the form.
values?: {
kitaName?: string;
notdienstModuleEnabled?: boolean;
terminModuleEnabled?: boolean;
adressbuchModuleEnabled?: boolean;
notdienstMinPerChildPerMonth?: number;
};
errors?: { errors?: {
kitaName?: string[]; kitaName?: string[];
notdienstMinPerChildPerMonth?: string[]; notdienstMinPerChildPerMonth?: string[];
@@ -45,10 +70,25 @@ export type OnboardingState = {
}; };
}; };
export const initialOnboardingState: OnboardingState = { attempt: 0 };
function echoValues(formData: FormData): OnboardingState["values"] {
const min = formData.get("notdienstMinPerChildPerMonth");
return {
kitaName: String(formData.get("kitaName") ?? ""),
notdienstModuleEnabled: formData.get("notdienstModuleEnabled") === "on",
terminModuleEnabled: formData.get("terminModuleEnabled") === "on",
adressbuchModuleEnabled: formData.get("adressbuchModuleEnabled") === "on",
notdienstMinPerChildPerMonth:
min === null || min === "" ? undefined : Number(min),
};
}
export async function completeOnboardingAction( export async function completeOnboardingAction(
_prev: OnboardingState, prev: OnboardingState,
formData: FormData, formData: FormData,
): Promise<OnboardingState> { ): Promise<OnboardingState> {
const attempt = prev.attempt + 1;
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login");
@@ -61,7 +101,11 @@ export async function completeOnboardingAction(
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData)); const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
} }
const data = parsed.data; const data = parsed.data;
@@ -99,6 +143,8 @@ export async function completeOnboardingAction(
} }
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
return { return {
attempt,
values: echoValues(formData),
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] }, errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
}; };
} }
+86 -22
View File
@@ -1,22 +1,31 @@
"use client"; "use client";
import { useActionState } from "react"; import { useActionState, useId } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions"; import {
completeOnboardingAction,
const initialState: OnboardingState = {}; initialOnboardingState,
} from "./actions";
export function OnboardingForm() { export function OnboardingForm() {
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
completeOnboardingAction, completeOnboardingAction,
initialState, initialOnboardingState,
); );
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
const kitaError = state.errors?.kitaName?.[0];
const minError = state.errors?.notdienstMinPerChildPerMonth?.[0];
return ( return (
<form action={formAction} className="space-y-8"> <form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */} {/* ----- Schritt 1: Name ----- */}
@@ -27,14 +36,24 @@ export function OnboardingForm() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label> <Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input <Input
key={`kitaName-${attemptKey}`}
id="kitaName" id="kitaName"
name="kitaName" name="kitaName"
required required
placeholder="z.B. Waldameisen e.V." placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName} defaultValue={state.values?.kitaName ?? ""}
aria-invalid={!!kitaError}
aria-describedby={kitaError ? "kitaName-error" : undefined}
/> />
{state.errors?.kitaName?.[0] && ( {kitaError && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p> <p
id="kitaName-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{kitaError}
</p>
)} )}
</div> </div>
</fieldset> </fieldset>
@@ -45,22 +64,25 @@ export function OnboardingForm() {
Schritt 2 · Module aktivieren Schritt 2 · Module aktivieren
</legend> </legend>
<ModuleCheckbox <ModuleCheckbox
key={`notdienstModuleEnabled-${attemptKey}`}
name="notdienstModuleEnabled" name="notdienstModuleEnabled"
label="Notdienst-Planung" label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren." description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked defaultChecked={state.values?.notdienstModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`terminModuleEnabled-${attemptKey}`}
name="terminModuleEnabled" name="terminModuleEnabled"
label="Terminkalender" label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren." description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked defaultChecked={state.values?.terminModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`adressbuchModuleEnabled-${attemptKey}`}
name="adressbuchModuleEnabled" name="adressbuchModuleEnabled"
label="Eltern-Adressbuch" label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden." description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked defaultChecked={state.values?.adressbuchModuleEnabled ?? true}
/> />
</fieldset> </fieldset>
@@ -74,29 +96,49 @@ export function OnboardingForm() {
Mindest-Verfügbarkeiten pro Kind und Monat Mindest-Verfügbarkeiten pro Kind und Monat
</Label> </Label>
<Input <Input
key={`notdienstMinPerChildPerMonth-${attemptKey}`}
id="notdienstMinPerChildPerMonth" id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth" name="notdienstMinPerChildPerMonth"
type="number" type="number"
min={0} min={0}
max={31} max={31}
defaultValue={2} defaultValue={state.values?.notdienstMinPerChildPerMonth ?? 2}
required required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth} aria-invalid={!!minError}
aria-describedby={
minError
? "notdienstMinPerChildPerMonth-error"
: "notdienstMinPerChildPerMonth-helper"
}
/> />
<p className="text-xs text-muted-foreground"> {!minError && (
<p
id="notdienstMinPerChildPerMonth-helper"
className="text-xs text-muted-foreground"
>
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren? Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
Diesen Wert kannst du später jederzeit ändern. Diesen Wert kannst du später jederzeit ändern.
</p> </p>
{state.errors?.notdienstMinPerChildPerMonth?.[0] && ( )}
<p className="text-xs text-destructive"> {minError && (
{state.errors.notdienstMinPerChildPerMonth[0]} <p
id="notdienstMinPerChildPerMonth-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{minError}
</p> </p>
)} )}
</div> </div>
</fieldset> </fieldset>
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -119,12 +161,34 @@ function ModuleCheckbox({
description: string; description: string;
defaultChecked?: boolean; defaultChecked?: boolean;
}) { }) {
const id = useId();
const titleId = `${id}-title`;
const descId = `${id}-desc`;
return ( return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"> // The wrapping <label htmlFor> keeps the whole card clickable to toggle
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" /> // the checkbox. aria-labelledby narrows the accessible name to just the
// module title, with the description exposed via aria-describedby so
// screen readers can present it as secondary info rather than as part of
// the primary label.
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"
>
<Checkbox
id={id}
name={name}
defaultChecked={defaultChecked}
aria-labelledby={titleId}
aria-describedby={descId}
className="mt-0.5"
/>
<div className="grid gap-1 leading-none"> <div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span> <span id={titleId} className="text-sm font-medium">
<span className="text-xs text-muted-foreground">{description}</span> {label}
</span>
<span id={descId} className="text-xs text-muted-foreground">
{description}
</span>
</div> </div>
</label> </label>
); );
+3 -1
View File
@@ -30,7 +30,9 @@ export default async function OnboardingPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl"> <Card className="w-full max-w-2xl">
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle> <CardTitle as="h1">
Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!
</CardTitle>
<CardDescription> <CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen. Einstellungen später noch anpassen.
+1 -1
View File
@@ -24,7 +24,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
CardHeader.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
type CardTitleProps = React.HTMLAttributes<HTMLElement> & { type CardTitleProps = React.HTMLAttributes<HTMLElement> & {
as?: "div" | "h2" | "h3" | "h4" | "h5" | "h6"; as?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
}; };
const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>( const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(