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:
@@ -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."] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user