Merge claude/onboarding-fixes into main
Persist values, tighten validation, and a11y on /onboarding
This commit is contained in:
@@ -16,21 +16,35 @@ import { prisma } from "@/lib/prisma";
|
||||
// — kein "halb-eingerichteter" Zwischenzustand.
|
||||
// =====================================================================
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
const onboardingSchema = z
|
||||
.object({
|
||||
kitaName: z
|
||||
.string()
|
||||
.min(2, "Mindestens 2 Zeichen.")
|
||||
.max(120, "Maximal 120 Zeichen.")
|
||||
.trim(),
|
||||
.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 ganze Zahl.")
|
||||
.min(0, "Nicht negativ.")
|
||||
.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."] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 <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 (
|
||||
<form action={formAction} className="space-y-8">
|
||||
{/* ----- Schritt 1: Name ----- */}
|
||||
@@ -27,14 +36,24 @@ export function OnboardingForm() {
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
|
||||
<Input
|
||||
key={`kitaName-${attemptKey}`}
|
||||
id="kitaName"
|
||||
name="kitaName"
|
||||
required
|
||||
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] && (
|
||||
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p>
|
||||
{kitaError && (
|
||||
<p
|
||||
id="kitaName-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{kitaError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -45,22 +64,25 @@ export function OnboardingForm() {
|
||||
Schritt 2 · Module aktivieren
|
||||
</legend>
|
||||
<ModuleCheckbox
|
||||
key={`notdienstModuleEnabled-${attemptKey}`}
|
||||
name="notdienstModuleEnabled"
|
||||
label="Notdienst-Planung"
|
||||
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
|
||||
defaultChecked
|
||||
defaultChecked={state.values?.notdienstModuleEnabled ?? true}
|
||||
/>
|
||||
<ModuleCheckbox
|
||||
key={`terminModuleEnabled-${attemptKey}`}
|
||||
name="terminModuleEnabled"
|
||||
label="Terminkalender"
|
||||
description="Kita-Feste, Schließtage und private Anfragen koordinieren."
|
||||
defaultChecked
|
||||
defaultChecked={state.values?.terminModuleEnabled ?? true}
|
||||
/>
|
||||
<ModuleCheckbox
|
||||
key={`adressbuchModuleEnabled-${attemptKey}`}
|
||||
name="adressbuchModuleEnabled"
|
||||
label="Eltern-Adressbuch"
|
||||
description="Eltern können sich auf Opt-In-Basis untereinander finden."
|
||||
defaultChecked
|
||||
defaultChecked={state.values?.adressbuchModuleEnabled ?? true}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@@ -74,29 +96,49 @@ export function OnboardingForm() {
|
||||
Mindest-Verfügbarkeiten pro Kind und Monat
|
||||
</Label>
|
||||
<Input
|
||||
key={`notdienstMinPerChildPerMonth-${attemptKey}`}
|
||||
id="notdienstMinPerChildPerMonth"
|
||||
name="notdienstMinPerChildPerMonth"
|
||||
type="number"
|
||||
min={0}
|
||||
max={31}
|
||||
defaultValue={2}
|
||||
defaultValue={state.values?.notdienstMinPerChildPerMonth ?? 2}
|
||||
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?
|
||||
Diesen Wert kannst du später jederzeit ändern.
|
||||
</p>
|
||||
{state.errors?.notdienstMinPerChildPerMonth?.[0] && (
|
||||
<p className="text-xs text-destructive">
|
||||
{state.errors.notdienstMinPerChildPerMonth[0]}
|
||||
)}
|
||||
{minError && (
|
||||
<p
|
||||
id="notdienstMinPerChildPerMonth-error"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{minError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{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]}
|
||||
</p>
|
||||
)}
|
||||
@@ -119,12 +161,34 @@ function ModuleCheckbox({
|
||||
description: string;
|
||||
defaultChecked?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const titleId = `${id}-title`;
|
||||
const descId = `${id}-desc`;
|
||||
return (
|
||||
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40">
|
||||
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" />
|
||||
// The wrapping <label htmlFor> keeps the whole card clickable to toggle
|
||||
// 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">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
<span id={titleId} className="text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<span id={descId} className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</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">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<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>
|
||||
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
|
||||
Einstellungen später noch anpassen.
|
||||
|
||||
@@ -24,7 +24,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
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>(
|
||||
|
||||
Reference in New Issue
Block a user