Give the settings profile form the same per-field rejection slots

It shares the route and had the same gap: the banner named the first
refused field and nothing marked the input that held the value. Each
of the seven submitted fields now carries the reason in its own slot,
through the same component the onboarding step uses.
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 23:22:14 +02:00
1 parent 36cbf48286
commit e74987e819
1 file changed
+49
@@ -37,8 +37,10 @@ import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
describeRejectedProfileField,
describeRejectedProfileFields,
type RejectedProfileField,
} from "@/lib/profile/rejected-fields";
import { FieldError } from "@/components/forms/field-error";
import {
resolveInitialTimezone,
statusText,
@@ -90,6 +92,11 @@ export function AccountSection() {
const [saveMsgType, setSaveMsgType] = useState<
"success" | "warning" | "error" | null
>(null);
// One sentence per field the last save refused, keyed by the schema
// name the server sent. The banner above the button says a save was
// partial; these say which value was not stored and why, under the
// input holding it. Cleared at the start of every save.
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
// v1.23 — passkey + second-factor management moved to the dedicated
// /settings/security hub so "how I secure my account" reads as one place.
@@ -172,6 +179,7 @@ export function AccountSection() {
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
setFieldErrors({});
const savedProfile = {
email: email.trim(),
height,
@@ -234,6 +242,7 @@ export function AccountSection() {
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
});
setSaveMsgType("warning");
setFieldErrors(describeRejectedProfileFields(rejected, t));
await refetch();
setSeededUserId(null);
} else {
@@ -272,6 +281,7 @@ export function AccountSection() {
} | null;
const errorCode = json?.meta?.errorCode;
const field = describeRejectedProfileField(json?.details?.issues, t);
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
} else if (errorCode) {
@@ -341,7 +351,10 @@ export function AccountSection() {
maxLength={320}
autoComplete="email"
enterKeyHint="next"
aria-invalid={fieldErrors.email ? true : undefined}
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
</div>
</div>
@@ -352,6 +365,10 @@ export function AccountSection() {
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
aria-invalid={fieldErrors.gender ? true : undefined}
aria-describedby={
fieldErrors.gender ? "gender-error" : undefined
}
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
@@ -361,6 +378,7 @@ export function AccountSection() {
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
<FieldError id="gender-error" message={fieldErrors.gender} />
</div>
<div className="space-y-2">
<Label htmlFor="height">
@@ -374,7 +392,10 @@ export function AccountSection() {
value={height}
onChange={setHeight}
enterKeyHint="next"
invalid={Boolean(fieldErrors.heightCm)}
describedBy={fieldErrors.heightCm ? "height-error" : undefined}
/>
<FieldError id="height-error" message={fieldErrors.heightCm} />
</div>
</div>
@@ -392,10 +413,15 @@ export function AccountSection() {
value={dateOfBirth}
onChange={setDateOfBirth}
max={new Date().toISOString().slice(0, 10)}
aria-invalid={fieldErrors.dateOfBirth ? true : undefined}
aria-describedby={
fieldErrors.dateOfBirth ? "dob-error" : undefined
}
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
@@ -455,7 +481,12 @@ export function AccountSection() {
placeholder={t("settings.identity.fullNamePlaceholder")}
maxLength={120}
autoComplete="name"
aria-invalid={fieldErrors.fullName ? true : undefined}
aria-describedby={
fieldErrors.fullName ? "full-name-error" : undefined
}
/>
<FieldError id="full-name-error" message={fieldErrors.fullName} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -468,6 +499,14 @@ export function AccountSection() {
onChange={(e) => setInsurerName(e.target.value)}
placeholder={t("settings.identity.insurerPlaceholder")}
maxLength={120}
aria-invalid={fieldErrors.insurerName ? true : undefined}
aria-describedby={
fieldErrors.insurerName ? "insurer-error" : undefined
}
/>
<FieldError
id="insurer-error"
message={fieldErrors.insurerName}
/>
</div>
<div className="space-y-2">
@@ -484,10 +523,20 @@ export function AccountSection() {
maxLength={10}
autoCapitalize="characters"
spellCheck={false}
aria-invalid={fieldErrors.insuranceNumber ? true : undefined}
aria-describedby={
fieldErrors.insuranceNumber
? "insurance-number-error"
: undefined
}
/>
<p className="text-muted-foreground text-xs">
{t("settings.identity.insuranceNumberHint")}
</p>
<FieldError
id="insurance-number-error"
message={fieldErrors.insuranceNumber}
/>
</div>
</div>
</div>