Persist values, tighten validation, and a11y on /onboarding

- 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 <p> 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) <noreply@anthropic.com>
This commit is contained in:
t.indorf
2026-05-17 20:46:31 +02:00
parent 380c67963b
commit af2c3b245a
4 changed files with 156 additions and 44 deletions
+63 -17
View File
@@ -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<OnboardingState> {
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."] },
};
}