From af2c3b245abf0e7211920b47c7f8692d132cd7d4 Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Sun, 17 May 2026 20:46:31 +0200 Subject: [PATCH] Persist values, tighten validation, and a11y on /onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server action echoes kitaName, the three module booleans, and the Mindest-Verfügbarkeiten value on every error path so failed submits no longer wipe what the user typed; OnboardingState gains an `attempt` counter that drives a per-field remount key on the client. - Schema now rejects min=0 when the Notdienst module is active (via superRefine) — previously a kita could be created with 0 minimum days, which is fachlich sinnlos. When the module is off, 0 stays valid. - Validation error messages reworded from terse fragments ("Nicht negativ.", "Bitte ganze Zahl.") to full sentences. - Field error

elements gain role="alert" + aria-live="polite" so screen readers announce them on submit. - CardTitle now accepts as="h1" and the onboarding page uses it for "Willkommen, …" — the page now has a real top-level heading. - ModuleCheckbox uses aria-labelledby + aria-describedby so the accessible name is just the module title; the longer description is exposed as secondary info instead of being concatenated into the AN. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/onboarding/actions.ts | 80 +++++++++++++---- src/app/onboarding/onboarding-form.tsx | 114 +++++++++++++++++++------ src/app/onboarding/page.tsx | 4 +- src/components/ui/card.tsx | 2 +- 4 files changed, 156 insertions(+), 44 deletions(-) diff --git a/src/app/onboarding/actions.ts b/src/app/onboarding/actions.ts index c65e0ad..2011398 100644 --- a/src/app/onboarding/actions.ts +++ b/src/app/onboarding/actions.ts @@ -16,21 +16,35 @@ import { prisma } from "@/lib/prisma"; // — kein "halb-eingerichteter" Zwischenzustand. // ===================================================================== -const onboardingSchema = z.object({ - kitaName: z - .string() - .min(2, "Mindestens 2 Zeichen.") - .max(120, "Maximal 120 Zeichen.") - .trim(), - notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), - terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), - adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), - notdienstMinPerChildPerMonth: z.coerce - .number() - .int("Bitte ganze Zahl.") - .min(0, "Nicht negativ.") - .max(31, "Maximal 31 Tage pro Monat."), -}); +const onboardingSchema = z + .object({ + kitaName: z + .string() + .trim() + .min(2, "Bitte mindestens 2 Zeichen angeben.") + .max(120, "Maximal 120 Zeichen."), + notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), + terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), + adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), + notdienstMinPerChildPerMonth: z.coerce + .number() + .int("Bitte eine ganze Zahl angeben.") + .min(0, "Bitte einen Wert von 0 oder mehr angeben.") + .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 { // HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData. @@ -38,6 +52,17 @@ function checkboxToBool(value: unknown): boolean { } 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?: { kitaName?: 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( - _prev: OnboardingState, + prev: OnboardingState, formData: FormData, ): Promise { + const attempt = prev.attempt + 1; const session = await auth(); if (!session?.user) { redirect("/login"); @@ -61,7 +101,11 @@ export async function completeOnboardingAction( const parsed = onboardingSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { - return { errors: parsed.error.flatten().fieldErrors }; + return { + attempt, + values: echoValues(formData), + errors: parsed.error.flatten().fieldErrors, + }; } const data = parsed.data; @@ -99,6 +143,8 @@ export async function completeOnboardingAction( } if (err instanceof Prisma.PrismaClientKnownRequestError) { return { + attempt, + values: echoValues(formData), errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] }, }; } diff --git a/src/app/onboarding/onboarding-form.tsx b/src/app/onboarding/onboarding-form.tsx index 61b0030..00a4ce7 100644 --- a/src/app/onboarding/onboarding-form.tsx +++ b/src/app/onboarding/onboarding-form.tsx @@ -1,22 +1,31 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useId } from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { completeOnboardingAction, type OnboardingState } from "./actions"; - -const initialState: OnboardingState = {}; +import { + completeOnboardingAction, + initialOnboardingState, +} from "./actions"; export function OnboardingForm() { const [state, formAction, pending] = useActionState( completeOnboardingAction, - initialState, + initialOnboardingState, ); + // Bumping the key on each attempt forces uncontrolled inputs to remount with + // the echoed defaultValue from the server — otherwise React's

+ // 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 ( {/* ----- Schritt 1: Name ----- */} @@ -27,14 +36,24 @@ export function OnboardingForm() {
- {state.errors?.kitaName?.[0] && ( -

{state.errors.kitaName[0]}

+ {kitaError && ( + )}
@@ -45,22 +64,25 @@ export function OnboardingForm() { Schritt 2 · Module aktivieren @@ -74,29 +96,49 @@ export function OnboardingForm() { Mindest-Verfügbarkeiten pro Kind und Monat -

- Wie viele Tage müssen Eltern pro Monat als verfügbar markieren? - Diesen Wert kannst du später jederzeit ändern. -

- {state.errors?.notdienstMinPerChildPerMonth?.[0] && ( -

- {state.errors.notdienstMinPerChildPerMonth[0]} + {!minError && ( +

+ Wie viele Tage müssen Eltern pro Monat als verfügbar markieren? + Diesen Wert kannst du später jederzeit ändern. +

+ )} + {minError && ( + )} {state.errors?._form?.[0] && ( -

+

{state.errors._form[0]}

)} @@ -119,12 +161,34 @@ function ModuleCheckbox({ description: string; defaultChecked?: boolean; }) { + const id = useId(); + const titleId = `${id}-title`; + const descId = `${id}-desc`; return ( -