diff --git a/src/app/register/actions.ts b/src/app/register/actions.ts index c705ae8..dde2166 100644 --- a/src/app/register/actions.ts +++ b/src/app/register/actions.ts @@ -32,6 +32,16 @@ const registerSchema = z.object({ const PRIVACY_POLICY_VERSION = "2026-05-01"; export type RegisterState = { + // 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 (sans password) so failed submits don't wipe the form. + values?: { + firstName?: string; + lastName?: string; + email?: string; + acceptPrivacyPolicy?: boolean; + }; errors?: { email?: string[]; password?: string[]; @@ -42,13 +52,29 @@ export type RegisterState = { }; }; +export const initialRegisterState: RegisterState = { attempt: 0 }; + +function echoValues(formData: FormData): RegisterState["values"] { + return { + firstName: String(formData.get("firstName") ?? ""), + lastName: String(formData.get("lastName") ?? ""), + email: String(formData.get("email") ?? ""), + acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on", + }; +} + export async function registerAction( - _prevState: RegisterState, + prevState: RegisterState, formData: FormData, ): Promise { + const attempt = prevState.attempt + 1; const parsed = registerSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { - return { errors: parsed.error.flatten().fieldErrors }; + return { + attempt, + values: echoValues(formData), + errors: parsed.error.flatten().fieldErrors, + }; } const { email, password, firstName, lastName } = parsed.data; @@ -77,6 +103,8 @@ export async function registerAction( err.code === "P2002" ) { return { + attempt, + values: echoValues(formData), errors: { email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."], }, @@ -93,5 +121,5 @@ export async function registerAction( }); // Unreachable – signIn redirected. - return {}; + return { attempt }; } diff --git a/src/app/register/register-form.tsx b/src/app/register/register-form.tsx index 30289a7..486b98d 100644 --- a/src/app/register/register-form.tsx +++ b/src/app/register/register-form.tsx @@ -7,45 +7,58 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { registerAction, type RegisterState } from "./actions"; - -const initialState: RegisterState = {}; +import { initialRegisterState, registerAction } from "./actions"; export function RegisterForm() { - const [state, formAction, pending] = useActionState(registerAction, initialState); + const [state, formAction, pending] = useActionState( + registerAction, + initialRegisterState, + ); + + // 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; return (
- +
{state.errors?.acceptPrivacyPolicy?.[0] && ( -

{state.errors.acceptPrivacyPolicy[0]}

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

+

{state.errors._form[0]}

)} @@ -93,6 +134,7 @@ function FormField({ required, autoComplete, helperText, + defaultValue, error, }: { id: string; @@ -102,8 +144,11 @@ function FormField({ required?: boolean; autoComplete?: string; helperText?: string; + defaultValue?: string; error?: string; }) { + const errorId = error ? `${id}-error` : undefined; + const helperId = !error && helperText ? `${id}-helper` : undefined; return (
@@ -113,12 +158,23 @@ function FormField({ type={type} required={required} autoComplete={autoComplete} + defaultValue={defaultValue} aria-invalid={!!error} + aria-describedby={errorId ?? helperId} /> {error ? ( -

{error}

+ ) : helperText ? ( -

{helperText}

+

+ {helperText} +

) : null}
);