Say when the onboarding profile step could not save a field

The baseline step submitted what you filled in and ignored the answer.
The profile write is field-independent: it stores everything that
validated and reports back what it declined. Nobody read that report
here, so a value the server refused vanished between the input and the
next screen and you moved on believing it was saved.

It now says which field did not make it, using the name that sits next
to the input you typed into, and keeps going with the rest that did
save. When nothing could be saved at all, the step stays put and names
the field that blocked it instead of a shrug. The wording and the
field-naming come from the same place the account form in Settings
uses, so the same refusal reads the same wherever you hit it.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 07:58:45 +02:00
1 parent 340d1db6d9
commit 8e6880b04c
7 files changed
+463 -58

No files matched your search

@@ -1,8 +1,6 @@
import { describe, it, expect } from "vitest";
import {
describeRejectedProfileField,
resolveInitialTimezone,
} from "../account-section-utils";
import { describeRejectedProfileField } from "@/lib/profile/rejected-fields";
import { resolveInitialTimezone } from "../account-section-utils";
describe("describeRejectedProfileField", () => {
const t = (key: string) => `translated:${key}`;
@@ -61,44 +61,9 @@ export function statusText(
return "key" in msg ? t(msg.key, msg.params) : msg.text;
}
/** A single rejected-field entry from `applyProfileUpdate`'s wire shape
* (`details.issues` on failure, `data.rejectedFields` on a partial
* success) — `path` is the schema field name, never shown verbatim. */
export interface RejectedProfileField {
path: string;
code: string;
message?: string;
}
/**
* Maps a rejected field's schema `path` to the same i18n label already
* shown next to that input on this screen. The server only ever knows
* the field by its schema key (`heightCm`, `gender`, ...) — naming it
* in a person-facing sentence is the client's job, using labels that
* already exist and are already localized for this form.
* Naming a rejected profile field is shared with the onboarding
* baseline step — see `src/lib/profile/rejected-fields.ts`. This screen
* uses the default label map, which points at the labels these inputs
* already carry.
*/
const PROFILE_FIELD_LABEL_KEYS: Record<string, string> = {
email: "auth.email",
heightCm: "settings.height",
dateOfBirth: "settings.dateOfBirth",
gender: "settings.gender",
fullName: "settings.identity.fullName",
insurerName: "settings.identity.insurer",
insuranceNumber: "settings.identity.insuranceNumber",
};
/**
* Renders the first rejected field's label, falling back to its raw
* schema key for a field this screen has no input for (defensive —
* every field `applyProfileUpdate` accepts from this form is mapped
* above).
*/
export function describeRejectedProfileField(
fields: RejectedProfileField[] | undefined,
t: (key: string) => string,
): string | null {
const first = fields?.[0];
if (!first) return null;
const labelKey = PROFILE_FIELD_LABEL_KEYS[first.path];
return labelKey ? t(labelKey) : first.path;
}
@@ -39,9 +39,11 @@ import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
describeRejectedProfileField,
type RejectedProfileField,
} from "@/lib/profile/rejected-fields";
import {
resolveInitialTimezone,
statusText,
type RejectedProfileField,
type StatusMessage,
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";