From ae20ee3d9dd446e5562d4087c67f632c96e3de62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 23:16:50 +0200 Subject: [PATCH 1/6] Split the onboarding baseline inputs into their own component The step's profile fieldset moves to `baseline-fields.tsx` unchanged. The markup can now be rendered against a server answer on its own, which is where the next commit puts a test. --- src/components/onboarding/baseline-fields.tsx | 143 ++++++++++++++++++ src/components/onboarding/baseline-form.tsx | 102 +------------ 2 files changed, 149 insertions(+), 96 deletions(-) create mode 100644 src/components/onboarding/baseline-fields.tsx diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx new file mode 100644 index 000000000..59ef5930a --- /dev/null +++ b/src/components/onboarding/baseline-fields.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { DateField } from "@/components/ui/date-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { HeightFieldControl } from "@/components/profile/height-field-control"; +import { useTranslations } from "@/lib/i18n/context"; +import type { + HeightDraft, + HeightUnitAdapter, +} from "@/lib/profile/height-unit-display"; + +/** + * The four profile inputs of the onboarding baseline step. + * + * Split out of `baseline-form.tsx` so the markup can be rendered on its + * own against a server answer. The step's whole defect lived between + * the response and these inputs — the server named a field it refused + * and nothing on screen changed — so the seam a test needs is exactly + * here: field values in, markup out. + */ + +export interface BaselineFieldValues { + displayName: string; + height: HeightDraft; + dateOfBirth: string; + gender: string; +} + +const IDS = { + displayName: "ob-baseline-display-name", + heightCm: "ob-baseline-height", + dateOfBirth: "ob-baseline-dob", + gender: "ob-baseline-gender", +} as const; + +export function BaselineFields({ + value, + onChange, + heightAdapter, +}: { + value: BaselineFieldValues; + onChange: ( + key: K, + next: BaselineFieldValues[K], + ) => void; + heightAdapter: HeightUnitAdapter; +}) { + const { t } = useTranslations(); + + return ( +
+ {t("onboarding.baseline.title")} + + + onChange("displayName", e.target.value)} + autoComplete="nickname" + maxLength={50} + placeholder={t("onboarding.baseline.displayNamePlaceholder")} + /> + + +
+ + onChange("height", next)} + autoComplete="off" + /> + + + + +
+ + + onChange("dateOfBirth", next)} + max={new Date().toISOString().slice(0, 10)} + autoComplete="bday" + /> + +
+ ); +} diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 7576be392..8d81811dc 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -8,17 +8,7 @@ import { toast } from "sonner"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { DateField } from "@/components/ui/date-field"; -import { FieldGroup } from "@/components/ui/field-group"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { HeightFieldControl } from "@/components/profile/height-field-control"; +import { BaselineFields } from "@/components/onboarding/baseline-fields"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { EMPTY_HEIGHT_DRAFT, @@ -201,91 +191,11 @@ export function BaselineForm() {

-
- {t("onboarding.baseline.title")} - - - patch("displayName", e.target.value)} - autoComplete="nickname" - maxLength={50} - placeholder={t("onboarding.baseline.displayNamePlaceholder")} - /> - - -
- - patch("height", next)} - autoComplete="off" - /> - - - - -
- - - patch("dateOfBirth", value)} - max={new Date().toISOString().slice(0, 10)} - autoComplete="bday" - /> - -
+ Date: Wed, 9 Sep 2026 23:21:21 +0200 Subject: [PATCH 2/6] Show a refused profile field under the input it belongs to The onboarding baseline step flashed a toast naming one refused field and walked on to the done screen, stamping the account as set up over a value the server never stored. The value the person typed was gone with the step. A refused field now writes its reason into that field's own error slot, marks the control invalid, and holds the step until the person corrects it. Every refused field is named, not only the first, and the reason comes from the validator code rather than the validator's own prose, in all seven shipped languages. --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/ko.json | 8 + messages/pl.json | 8 + src/components/forms/field-error.tsx | 32 ++++ .../baseline-fields-rejection-slots.test.tsx | 175 ++++++++++++++++++ .../baseline-form-rejected-fields.test.ts | 19 +- src/components/onboarding/baseline-fields.tsx | 36 +++- .../onboarding/baseline-form-utils.ts | 47 +++-- src/components/onboarding/baseline-form.tsx | 31 +++- .../profile/height-field-control.tsx | 12 ++ src/components/ui/field-group.tsx | 7 + src/lib/profile/rejected-fields.ts | 49 +++++ 16 files changed, 438 insertions(+), 26 deletions(-) create mode 100644 src/components/forms/field-error.tsx create mode 100644 src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx diff --git a/messages/de.json b/messages/de.json index eef3089fe..2c77763c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profil gespeichert", "profilePartiallySaved": "Profil gespeichert, aber {field} konnte nicht aktualisiert werden.", "profileNothingSaved": "Es wurde nichts gespeichert. Bitte {field} korrigieren und erneut versuchen.", + "profileRejection": { + "tooBig": "Der Wert liegt über dem Bereich, den dieses Feld annimmt.", + "tooSmall": "Der Wert liegt unter dem Bereich, den dieses Feld annimmt.", + "invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.", + "invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.", + "invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.", + "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen." + }, "avatar": { "title": "Profilfoto", "description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.", diff --git a/messages/en.json b/messages/en.json index e75e342a8..f4d924b7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profile saved", "profilePartiallySaved": "Profile saved, but {field} could not be updated.", "profileNothingSaved": "Nothing was saved. Fix {field} and try again.", + "profileRejection": { + "tooBig": "That value is above the range this field accepts.", + "tooSmall": "That value is below the range this field accepts.", + "invalidValue": "That is not one of the answers this field accepts.", + "invalidFormat": "That is not written in a form this field can read.", + "invalidType": "That value did not arrive in a form this field can store.", + "other": "This value was not accepted. Check it and try again." + }, "avatar": { "title": "Profile photo", "description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.", diff --git a/messages/es.json b/messages/es.json index 91f6187d8..da24ec7e2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5353,6 +5353,14 @@ "profileSaved": "Perfil guardado", "profilePartiallySaved": "Perfil guardado, pero no se pudo actualizar {field}.", "profileNothingSaved": "No se guardó nada. Corrige {field} e inténtalo de nuevo.", + "profileRejection": { + "tooBig": "El valor está por encima del rango que acepta este campo.", + "tooSmall": "El valor está por debajo del rango que acepta este campo.", + "invalidValue": "Esa no es una de las respuestas que acepta este campo.", + "invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.", + "invalidType": "El valor no llegó en un formato que este campo pueda guardar.", + "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo." + }, "avatar": { "title": "Foto de perfil", "description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.", diff --git a/messages/fr.json b/messages/fr.json index 23921c63d..f3f88618f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profil enregistré", "profilePartiallySaved": "Profil enregistré, mais {field} n'a pas pu être mis à jour.", "profileNothingSaved": "Rien n'a été enregistré. Corrigez {field} et réessayez.", + "profileRejection": { + "tooBig": "La valeur dépasse la plage acceptée par ce champ.", + "tooSmall": "La valeur est en dessous de la plage acceptée par ce champ.", + "invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.", + "invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.", + "invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.", + "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez." + }, "avatar": { "title": "Photo de profil", "description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.", diff --git a/messages/it.json b/messages/it.json index e17e31091..a805995dc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profilo salvato", "profilePartiallySaved": "Profilo salvato, ma non è stato possibile aggiornare {field}.", "profileNothingSaved": "Non è stato salvato nulla. Correggi {field} e riprova.", + "profileRejection": { + "tooBig": "Il valore supera l'intervallo accettato da questo campo.", + "tooSmall": "Il valore è inferiore all'intervallo accettato da questo campo.", + "invalidValue": "Questa non è una delle risposte accettate da questo campo.", + "invalidFormat": "Non è scritto in un formato che questo campo possa leggere.", + "invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.", + "other": "Questo valore non è stato accettato. Controllalo e riprova." + }, "avatar": { "title": "Foto del profilo", "description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.", diff --git a/messages/ko.json b/messages/ko.json index 66d228404..c382284b8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5353,6 +5353,14 @@ "profileSaved": "프로필을 저장했어요", "profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.", "profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.", + "profileRejection": { + "tooBig": "이 항목이 받는 범위보다 큰 값이에요.", + "tooSmall": "이 항목이 받는 범위보다 작은 값이에요.", + "invalidValue": "이 항목이 받는 답변이 아니에요.", + "invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.", + "invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.", + "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요." + }, "avatar": { "title": "프로필 사진", "description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.", diff --git a/messages/pl.json b/messages/pl.json index 7ec4d5cbc..b05aea094 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5353,6 +5353,14 @@ "profileSaved": "Profil zapisany", "profilePartiallySaved": "Profil zapisany, ale nie udało się zaktualizować pola {field}.", "profileNothingSaved": "Nic nie zostało zapisane. Popraw {field} i spróbuj ponownie.", + "profileRejection": { + "tooBig": "Wartość jest powyżej zakresu, który przyjmuje to pole.", + "tooSmall": "Wartość jest poniżej zakresu, który przyjmuje to pole.", + "invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.", + "invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.", + "invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.", + "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie." + }, "avatar": { "title": "Zdjęcie profilowe", "description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.", diff --git a/src/components/forms/field-error.tsx b/src/components/forms/field-error.tsx new file mode 100644 index 000000000..5a720a786 --- /dev/null +++ b/src/components/forms/field-error.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +/** + * The error slot that belongs to one input. + * + * Every form that writes the profile shows a refused field the same + * way, or the same rejection reads differently depending on where the + * person happened to be standing. This is that one way: a destructive + * sentence directly under the control, carrying an id the control + * points `aria-describedby` at, so the reason is announced with the + * field rather than only painted next to it. + * + * `role="alert"` is deliberate. The sentence appears in response to a + * save the person just asked for, and a person who has already moved + * focus past the field would otherwise never learn the value did not + * land. + */ +export function FieldError({ + id, + message, +}: { + /** Conventionally `-error`. */ + id: string; + message?: ReactNode; +}) { + if (!message) return null; + return ( + + ); +} diff --git a/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx new file mode 100644 index 000000000..57aec24f9 --- /dev/null +++ b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx @@ -0,0 +1,175 @@ +/** + * A rejection the server names has to reach the input it belongs to. + * + * `PUT /api/auth/profile` writes field by field and reports the fields + * it refused. The onboarding step read that report only far enough to + * flash a toast and then walked the person to the next screen, so an + * out-of-range height was gone by the time anyone could act on it and + * the wizard counted the account as set up regardless. + * + * This runs the whole strip — build the body, PUT it over a stubbed + * `fetch`, read the answer, render the real inputs with what came back + * — because the gap was in the join, not in either end. The English + * bundle is the shipped one, so a sentence that reads as a key or + * leaks validator prose fails here. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { getCachedMessages } from "@/lib/i18n/load-locale"; +import { resolveKey } from "@/lib/i18n/resolve-key"; +import { + EMPTY_HEIGHT_DRAFT, + resolveHeightUnitAdapter, +} from "@/lib/profile/height-unit-display"; +import { BaselineFields } from "../baseline-fields"; +import { + baselineFieldLabelKeys, + buildBaselineProfileBody, + describeBaselineSaveOutcome, + putBaselineProfile, +} from "../baseline-form-utils"; + +/** A `t` backed by the shipped bundle — no stand-in strings. */ +function translator(locale: "en" | "de") { + const bundle = getCachedMessages(locale); + if (!bundle) throw new Error(`No cached bundle for ${locale}`); + return (key: string, params?: Record) => { + const raw = resolveKey(bundle as Record, key); + if (raw === undefined) return key; + return Object.entries(params ?? {}).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + raw, + ); + }; +} + +const FILLED_FORM = { + displayName: "Robin", + dateOfBirth: "1988-04-02", + gender: "OTHER", +}; + +function stubProfilePut(status: number, body: unknown) { + vi.stubGlobal("fetch", () => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ), + ); +} + +/** Render the step's inputs exactly as the step renders them. */ +function renderFields(errors: Record) { + return renderToStaticMarkup( + + + , + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("a refused baseline field reaches its own input", () => { + it("paints the reason in the height field's slot and holds the step", async () => { + stubProfilePut(200, { + data: { + id: "u1", + displayName: "Robin", + rejectedFields: [{ path: "heightCm", code: "too_big" }], + }, + error: null, + }); + + const en = translator("en"); + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 940)), + en, + baselineFieldLabelKeys(false), + ); + + // A field was refused: the wizard stays put and does not stamp the + // account as finished behind the person's back. + expect(outcome.advance).toBe(false); + expect(outcome.fieldErrors).toEqual({ + heightCm: en("settings.profileRejection.tooBig"), + }); + + // What the outcome produced is what the input renders — no second + // mapping in between for a rename to fall through. + const html = renderFields(outcome.fieldErrors); + expect(html).toContain(en("settings.profileRejection.tooBig")); + // Neither a bare key nor the validator's own vocabulary. + expect(html).not.toContain("settings.profileRejection"); + expect(html).not.toContain("too_big"); + // Announced with the input, not merely painted beside it. + expect(html).toContain('id="ob-baseline-height-error"'); + expect(html).toContain('aria-describedby="ob-baseline-height-error"'); + expect(html).toContain('aria-invalid="true"'); + }); + + it("leaves every accepted field's slot empty", async () => { + stubProfilePut(200, { data: { id: "u1" }, error: null }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)), + translator("en"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(true); + expect(outcome.fieldErrors).toEqual({}); + // (`aria-invalid:` also appears inside the primitives' Tailwind + // class strings, so the assertion is on the rendered attribute.) + const html = renderFields({}); + expect(html).not.toContain('aria-invalid="true"'); + expect(html).not.toContain('role="alert"'); + }); + + it("names every refused field, not only the first", async () => { + stubProfilePut(422, { + data: null, + error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.', + meta: { errorCode: "profile.update.nothingSaved" }, + details: { + issues: [ + { path: "dateOfBirth", code: "invalid_value" }, + { path: "gender", code: "invalid_value" }, + ], + }, + }); + + const outcome = describeBaselineSaveOutcome( + await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)), + translator("de"), + baselineFieldLabelKeys(false), + ); + + expect(outcome.advance).toBe(false); + expect(Object.keys(outcome.fieldErrors).sort()).toEqual([ + "dateOfBirth", + "gender", + ]); + // The person's language, not the validator's. + const de = translator("de"); + expect(outcome.fieldErrors.gender).toBe( + de("settings.profileRejection.invalidValue"), + ); + expect(outcome.fieldErrors.gender).not.toContain("invalid_value"); + }); +}); diff --git a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts index 198ed664f..8094ddbe0 100644 --- a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts +++ b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts @@ -60,7 +60,7 @@ afterEach(() => { }); describe("onboarding baseline profile save", () => { - it("names the rejected field and still lets the accepted ones stand", async () => { + it("names the rejected field, keeps the accepted ones, and holds the step", async () => { const calls = stubProfilePut(200, { data: { id: "u1", @@ -88,14 +88,21 @@ describe("onboarding baseline profile save", () => { gender: "OTHER", }); - // The accepted fields are saved and the wizard moves on... - expect(outcome.advance).toBe(true); - // ...but the declined one is said out loud, by the name this screen - // put next to the input. + // The accepted fields are saved, and the declined one is said out + // loud by the name this screen put next to the input. expect(outcome.notice).toEqual({ tone: "warning", message: "Profile saved, but Height (cm) could not be updated.", }); + // This assertion was `true` until the rejection reached the input + // it belongs to. Advancing here posted `step: 4`, stamped the + // account as set up and replaced the only screen that could show + // the refused value — a toast on the way out was the whole notice + // the person got. The step now waits for a correction. + expect(outcome.advance).toBe(false); + expect(outcome.fieldErrors.heightCm).toBe( + "That value is above the range this field accepts.", + ); }); it("says nothing when every field was accepted", async () => { @@ -107,7 +114,7 @@ describe("onboarding baseline profile save", () => { baselineFieldLabelKeys(false), ); - expect(outcome).toEqual({ advance: true, notice: null }); + expect(outcome).toEqual({ advance: true, notice: null, fieldErrors: {} }); }); it("names the blocking field and holds the step when nothing was saved", async () => { diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx index 59ef5930a..5eaf113e5 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -24,7 +24,12 @@ import type { * own against a server answer. The step's whole defect lived between * the response and these inputs — the server named a field it refused * and nothing on screen changed — so the seam a test needs is exactly - * here: field values in, markup out. + * here: field values in, field rejections in, markup out. + * + * Each field id doubles as the id of its error paragraph + * (`-error`), which is what the control points `aria-describedby` + * at, so a rejection is announced with the input rather than only + * painted next to it. */ export interface BaselineFieldValues { @@ -34,6 +39,15 @@ export interface BaselineFieldValues { gender: string; } +/** + * Per-field sentences keyed by the SCHEMA field name the server uses + * (`heightCm`, not `height`) — the key the rejection arrives under, so + * nothing has to be renamed between the wire and the slot. + */ +export type BaselineFieldErrors = Partial< + Record<"displayName" | "heightCm" | "dateOfBirth" | "gender", string> +>; + const IDS = { displayName: "ob-baseline-display-name", heightCm: "ob-baseline-height", @@ -45,6 +59,7 @@ export function BaselineFields({ value, onChange, heightAdapter, + errors, }: { value: BaselineFieldValues; onChange: ( @@ -52,6 +67,7 @@ export function BaselineFields({ next: BaselineFieldValues[K], ) => void; heightAdapter: HeightUnitAdapter; + errors: BaselineFieldErrors; }) { const { t } = useTranslations(); @@ -63,6 +79,7 @@ export function BaselineFields({ htmlFor={IDS.displayName} label={t("onboarding.baseline.displayNameLabel")} hint={t("onboarding.baseline.displayNameHint")} + error={errors.displayName} > @@ -82,6 +103,7 @@ export function BaselineFields({ ? t("onboarding.baseline.heightLabelFtIn") : t("onboarding.baseline.heightLabel") } + error={errors.heightCm} > onChange("height", next)} autoComplete="off" + invalid={Boolean(errors.heightCm)} + describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined} /> setFullName(e.target.value)} + onChange={(e) => { + setFullName(e.target.value); + clearFieldError("fullName"); + }} placeholder={t("settings.identity.fullNamePlaceholder")} maxLength={120} autoComplete="name" @@ -496,7 +521,10 @@ export function AccountSection() { setInsurerName(e.target.value)} + onChange={(e) => { + setInsurerName(e.target.value); + clearFieldError("insurerName"); + }} placeholder={t("settings.identity.insurerPlaceholder")} maxLength={120} aria-invalid={fieldErrors.insurerName ? true : undefined} @@ -516,9 +544,10 @@ export function AccountSection() { - setInsuranceNumber(e.target.value.toUpperCase()) - } + onChange={(e) => { + setInsuranceNumber(e.target.value.toUpperCase()); + clearFieldError("insuranceNumber"); + }} placeholder="A123456780" maxLength={10} autoCapitalize="characters"