refactor(ui): adopt shared readError + ResearchModeStatus + design-system Select

- advanced-section: drops the local ResearchModeStatus interface in
  favour of the shared DTO, replaces the nested ternary that built the
  toggle's status caption with a researchModeStatusLabel helper
  threaded through the shared gate-state classifier.
- WelcomeCarousel / GoalsChipPicker / SourceCardGrid / BaselineForm:
  import the shared readError helper instead of the four hand-rolled
  copies — the request-failed fallback shape now evolves in one place.
- BaselineForm: swap the gender native <select> for the
  shadcn-radix Select component so the picker matches the rest of the
  form library (focus rings, keyboard nav, theme tokens).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:04:37 +02:00
1 parent d6456a30eb
commit 2b712429c0
5 files changed
+64 -73

No files matched your search

+33 -20
View File
@@ -11,7 +11,15 @@ import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
/**
* v1.4.25 W14b-Content — onboarding step 3 (baseline).
@@ -166,16 +174,32 @@ export function BaselineForm() {
<Label htmlFor="ob-baseline-gender">
{t("onboarding.baseline.genderLabel")}
</Label>
<select
id="ob-baseline-gender"
value={form.gender}
onChange={(e) => patch("gender", e.target.value)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
<Select
// The design system's Radix Select uses an empty-string
// sentinel to mean "no selection"; map back and forth so
// the form state ("") and the Select's value (undefined-
// adjacent) stay aligned. v1.4.25 W21 Fix-N (design-M1).
value={form.gender === "" ? undefined : form.gender}
onValueChange={(next) => patch("gender", next)}
>
<option value="">{t("onboarding.baseline.genderNone")}</option>
<option value="MALE">{t("onboarding.baseline.genderMale")}</option>
<option value="FEMALE">{t("onboarding.baseline.genderFemale")}</option>
</select>
<SelectTrigger
id="ob-baseline-gender"
className="w-full"
data-slot="onboarding-baseline-gender"
>
<SelectValue
placeholder={t("onboarding.baseline.genderNone")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="MALE">
{t("onboarding.baseline.genderMale")}
</SelectItem>
<SelectItem value="FEMALE">
{t("onboarding.baseline.genderFemale")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
@@ -225,14 +249,3 @@ export function BaselineForm() {
);
}
async function readError(res: Response): Promise<string> {
try {
const json = (await res.json()) as { error?: string };
if (typeof json.error === "string" && json.error.length > 0) {
return json.error;
}
} catch {
/* fall through */
}
return `Request failed (${res.status})`;
}
+1 -11
View File
@@ -17,6 +17,7 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
/**
@@ -230,14 +231,3 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
);
}
async function readError(res: Response): Promise<string> {
try {
const json = (await res.json()) as { error?: string };
if (typeof json.error === "string" && json.error.length > 0) {
return json.error;
}
} catch {
/* fall through */
}
return `Request failed (${res.status})`;
}
+1 -11
View File
@@ -15,6 +15,7 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
/**
@@ -309,14 +310,3 @@ function SourceCardItem({
);
}
async function readError(res: Response): Promise<string> {
try {
const json = (await res.json()) as { error?: string };
if (typeof json.error === "string" && json.error.length > 0) {
return json.error;
}
} catch {
/* fall through */
}
return `Request failed (${res.status})`;
}
+1 -11
View File
@@ -15,6 +15,7 @@ import {
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { readError } from "@/lib/api/read-error";
import { cn } from "@/lib/utils";
/**
@@ -294,14 +295,3 @@ export function WelcomeCarousel() {
);
}
async function readError(res: Response): Promise<string> {
try {
const json = (await res.json()) as { error?: string };
if (typeof json.error === "string" && json.error.length > 0) {
return json.error;
}
} catch {
/* fall through */
}
return `Request failed (${res.status})`;
}
+28 -20
View File
@@ -20,6 +20,10 @@ import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/ResearchModeAcknowledgmentDialog";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import {
type ResearchModeStatus,
researchModeGateState,
} from "@/lib/medications/research-mode-types";
/**
* Settings → Advanced.
@@ -67,11 +71,27 @@ export function AdvancedSection() {
);
}
interface ResearchModeStatus {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
currentDisclaimerVersion: string;
/**
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
* the toggle's caption out of the JSX. The four-way ladder (loading /
* enabled-open / enabled-stale / disabled) is dense to read inline,
* and the surface stays open to a fifth state ("never queried") when
* we wire the offline shell in v1.4.26.
*/
function researchModeStatusLabel(
status: ResearchModeStatus | null | undefined,
isLoading: boolean,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (isLoading) return t("common.loading");
const gate = researchModeGateState(status);
if (gate === "off") return t("settings.researchMode.disabledStatus");
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
return t("settings.researchMode.acknowledgedOn", {
date: status?.acknowledgedAt
? formatDateTime(status.acknowledgedAt)
: "—",
});
}
function ResearchModeCard() {
@@ -92,10 +112,8 @@ function ResearchModeCard() {
staleTime: 60 * 1000,
});
const versionsAligned =
!!status &&
status.acknowledgedVersion === status.currentDisclaimerVersion;
const showRePrompt = !!status?.enabled && !versionsAligned;
const gateState = researchModeGateState(status);
const showRePrompt = gateState === "stale";
// The Switch's `checked` mirrors the server flag exactly — even when
// the version is stale we keep the toggle "on" so the user sees that
// their previous choice is preserved; the banner above the toggle
@@ -178,17 +196,7 @@ function ResearchModeCard() {
className="text-muted-foreground text-xs"
data-slot="settings-research-mode-status"
>
{isLoading
? t("common.loading")
: status?.enabled
? versionsAligned
? t("settings.researchMode.acknowledgedOn", {
date: status.acknowledgedAt
? formatDateTime(status.acknowledgedAt)
: "—",
})
: t("settings.researchMode.enabledStaleStatus")
: t("settings.researchMode.disabledStatus")}
{researchModeStatusLabel(status, isLoading, t)}
</p>
</div>
<Switch