i18n: convert feature pages, dialogs, auth sub-pages and nav to t()

Move all hardcoded UI strings in the appointments, prescriptions, tasks,
messages, activity, analysis and patients views (plus their dialogs and
detail sheets), the remaining auth pages (verify-email, forgot/reset
password, accept-invite, onboarding), the clinic form and the sidebar user
menu into i18next keys in en/translation.json. Clinical option values
(appointment types, frequencies) stay canonical. English-only; no new
locale yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-07 21:57:00 +03:00
parent 288d14758f
commit e0de20b551
20 changed files with 837 additions and 319 deletions
@@ -2,6 +2,7 @@
import { CalendarDays, Search } from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TODAY } from "@/components/appointments/appointments-view";
import { Button } from "@/components/ui/button";
@@ -69,6 +70,7 @@ export function AddAppointmentDialog({
onOpenChange: (open: boolean) => void;
onAdd: (appt: NewAppointment) => void;
}) {
const { t } = useTranslation();
const [patients, setPatients] = useState<Patient[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Patient | null>(null);
@@ -118,7 +120,10 @@ export function AddAppointmentDialog({
const submit = (event: FormEvent) => {
event.preventDefault();
if (!selected) {
notify.error("Pick a patient", "Search and select a patient first.");
notify.error(
t("appointments.dialog.pickPatientTitle"),
t("appointments.dialog.pickPatientBody"),
);
return;
}
onAdd({
@@ -130,7 +135,10 @@ export function AddAppointmentDialog({
type,
provider: provider.trim() || selected.pcp,
});
notify.success("Appointment added", `${selected.name} at ${time}`);
notify.success(
t("appointments.dialog.addedTitle"),
`${selected.name} · ${time}`,
);
reset();
onOpenChange(false);
};
@@ -145,15 +153,15 @@ export function AddAppointmentDialog({
>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New appointment</DialogTitle>
<DialogTitle>{t("appointments.dialog.title")}</DialogTitle>
<DialogDescription>
Search for a patient by name or file number, then set the slot.
{t("appointments.dialog.description")}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label="Patient">
<Field label={t("appointments.dialog.patient")}>
{selected ? (
<div className="flex items-center justify-between gap-2 rounded-2xl border bg-input/30 px-3 py-2">
<div className="flex min-w-0 flex-col">
@@ -161,7 +169,9 @@ export function AddAppointmentDialog({
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
File #{selected.fileNumber}
{t("appointments.dialog.fileNumber", {
number: selected.fileNumber,
})}
</span>
</div>
<Button
@@ -173,7 +183,7 @@ export function AddAppointmentDialog({
type="button"
variant="ghost"
>
Change
{t("appointments.dialog.change")}
</Button>
</div>
) : (
@@ -184,7 +194,7 @@ export function AddAppointmentDialog({
autoFocus
className="pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search name or file number"
placeholder={t("appointments.dialog.searchPlaceholder")}
value={query}
/>
</div>
@@ -192,7 +202,7 @@ export function AddAppointmentDialog({
<div className="max-h-56 overflow-y-auto rounded-2xl border bg-popover p-1">
{matches.length === 0 ? (
<p className="px-2 py-2 text-muted-foreground text-sm">
No patients found.
{t("appointments.dialog.noPatients")}
</p>
) : (
matches.map((p) => (
@@ -222,7 +232,9 @@ export function AddAppointmentDialog({
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Date</span>
<span className="text-muted-foreground text-xs">
{t("appointments.dialog.date")}
</span>
<Popover onOpenChange={setDateOpen} open={dateOpen}>
<PopoverTrigger
render={
@@ -254,7 +266,7 @@ export function AddAppointmentDialog({
</PopoverPopup>
</Popover>
</div>
<Field label="Time">
<Field label={t("appointments.dialog.time")}>
<Input
onChange={(event) => setTime(event.target.value)}
type="time"
@@ -264,23 +276,23 @@ export function AddAppointmentDialog({
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Type">
<Field label={t("appointments.dialog.type")}>
<select
className={controlClass}
onChange={(event) => setType(event.target.value)}
value={type}
>
{TYPES.map((t) => (
<option key={t} value={t}>
{t}
{TYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
<Field label="Provider">
<Field label={t("appointments.dialog.provider")}>
<Input
onChange={(event) => setProvider(event.target.value)}
placeholder="e.g. Dr. Okafor"
placeholder={t("appointments.dialog.providerPlaceholder")}
value={provider}
/>
</Field>
@@ -289,10 +301,10 @@ export function AddAppointmentDialog({
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
{t("appointments.dialog.cancel")}
</DialogClose>
<Button disabled={!selected} type="submit">
Add appointment
{t("appointments.dialog.submit")}
</Button>
</DialogFooter>
</form>
@@ -10,6 +10,7 @@ import {
Users,
} from "lucide-react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AddAppointmentDialog,
@@ -60,13 +61,6 @@ const statusVariant: Record<
cancelled: "destructive",
};
const statusLabel: Record<ApptStatus, string> = {
"checked-in": "Checked in",
confirmed: "Confirmed",
completed: "Completed",
cancelled: "Cancelled",
};
// "2026-06-05" -> "Wednesday, June 5"
function formatDayKey(key: string): string {
return new Date(`${key}T00:00:00`).toLocaleDateString("en-US", {
@@ -105,6 +99,7 @@ function Kpi({
}
function ApptRow({ appt }: { appt: Appointment }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 px-4 py-3">
<span className="w-12 shrink-0 font-medium text-foreground text-sm tabular-nums">
@@ -121,7 +116,9 @@ function ApptRow({ appt }: { appt: Appointment }) {
{appt.type} · {appt.provider}
</span>
</div>
<Badge variant={statusVariant[appt.status]}>{statusLabel[appt.status]}</Badge>
<Badge variant={statusVariant[appt.status]}>
{t(`appointments.status.${appt.status}`)}
</Badge>
</div>
);
}
@@ -159,6 +156,7 @@ function Section({
}
export function AppointmentsView() {
const { t } = useTranslation();
const [addOpen, setAddOpen] = useState(false);
const [calendarOpen, setCalendarOpen] = useState(false);
const [appointments, setAppointments] = useState<Appointment[]>([]);
@@ -192,7 +190,10 @@ export function AppointmentsView() {
});
setAppointments((prev) => [...prev, created]);
} catch {
notify.error("Couldn't add appointment", "Please try again.");
notify.error(
t("appointments.addFailedTitle"),
t("appointments.addFailedBody"),
);
}
};
@@ -201,20 +202,20 @@ export function AppointmentsView() {
const today = appointments.filter((a) => a.date === TODAY);
const week = appointments.filter((a) => a.date >= start && a.date <= end);
return [
{ label: "Today", value: String(today.length), icon: CalendarClock },
{ label: "This week", value: String(week.length), icon: Users },
{ label: t("appointments.kpi.today"), value: String(today.length), icon: CalendarClock },
{ label: t("appointments.kpi.thisWeek"), value: String(week.length), icon: Users },
{
label: "Checked in",
label: t("appointments.kpi.checkedIn"),
value: String(today.filter((a) => a.status === "checked-in").length),
icon: Stethoscope,
},
{
label: "Completed",
label: t("appointments.kpi.completed"),
value: String(today.filter((a) => a.status === "completed").length),
icon: Clock,
},
];
}, [appointments]);
}, [appointments, t]);
const todayItems = useMemo(
() => appointments.filter((a) => a.date === TODAY).sort(byTime),
@@ -256,10 +257,10 @@ export function AppointmentsView() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">
Appointments &amp; Schedule
{t("appointments.title")}
</h1>
<p className="text-muted-foreground text-sm">
Today&apos;s clinic schedule and what&apos;s coming up.
{t("appointments.subtitle")}
</p>
</div>
<div className="flex items-center gap-2">
@@ -268,7 +269,7 @@ export function AppointmentsView() {
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search patient, type, provider"
placeholder={t("appointments.searchPlaceholder")}
value={query}
/>
</div>
@@ -279,7 +280,7 @@ export function AppointmentsView() {
variant="outline"
>
<CalendarDays className="size-4" />
Calendar
{t("appointments.calendar")}
</Button>
<Button
className="rounded-3xl"
@@ -287,7 +288,7 @@ export function AppointmentsView() {
type="button"
>
<Plus className="size-4" />
Add
{t("appointments.add")}
</Button>
</div>
</div>
@@ -301,7 +302,7 @@ export function AppointmentsView() {
))
) : (
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
No matching appointments.
{t("appointments.noMatches")}
</p>
)
) : (
@@ -312,12 +313,12 @@ export function AppointmentsView() {
))}
</div>
<Section description={formatDayKey(TODAY)} title="Today">
<Section description={formatDayKey(TODAY)} title={t("appointments.today")}>
{todayItems.length > 0 ? (
<ScheduleList items={todayItems} />
) : (
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
Nothing scheduled today.
{t("appointments.nothingToday")}
</p>
)}
</Section>
@@ -2,6 +2,7 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
type Appointment,
@@ -57,6 +58,7 @@ export function CalendarDialog({
onOpenChange: (open: boolean) => void;
appointments: Appointment[];
}) {
const { t } = useTranslation();
// First-of-month for the displayed month; defaults to TODAY's month.
const [viewMonth, setViewMonth] = useState<Date>(() => {
const t = parseKey(TODAY);
@@ -120,7 +122,7 @@ export function CalendarDialog({
type="button"
variant="outline"
>
Today
{t("appointments.calendarDialog.today")}
</Button>
<Button
aria-label="Previous month"
@@ -214,16 +216,16 @@ export function CalendarDialog({
{formatDayKey(selectedKey)}
</h3>
<p className="text-muted-foreground text-xs">
{selectedItems.length === 1
? "1 appointment"
: `${selectedItems.length} appointments`}
{t("appointments.calendarDialog.appointmentCount", {
count: selectedItems.length,
})}
</p>
</div>
{selectedItems.length > 0 ? (
<ScheduleList items={selectedItems} />
) : (
<div className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
No appointments on this day.
{t("appointments.calendarDialog.none")}
</div>
)}
</div>