From e0de20b551db205e7fe0840ed33bcff1fdaeb533 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sun, 7 Jun 2026 21:57:00 +0300 Subject: [PATCH] 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 --- frontend/app/(auth)/accept-invite/page.tsx | 28 +- frontend/app/(auth)/forgot-password/page.tsx | 28 +- frontend/app/(auth)/onboarding/page.tsx | 6 +- frontend/app/(auth)/reset-password/page.tsx | 40 ++- frontend/app/(auth)/verify-email/page.tsx | 26 +- .../components/activity/activity-view.tsx | 29 +- .../components/analysis/analysis-view.tsx | 80 +++-- .../appointments/add-appointment-dialog.tsx | 50 ++- .../appointments/appointments-view.tsx | 45 +-- .../appointments/calendar-dialog.tsx | 12 +- .../components/clinic/create-clinic-form.tsx | 24 +- .../components/messages/messages-view.tsx | 46 ++- .../components/patients/patients-view.tsx | 40 ++- .../prescriptions/add-prescription-dialog.tsx | 64 ++-- .../prescription-detail-sheet.tsx | 55 ++- .../prescriptions/prescriptions-view.tsx | 39 +- frontend/components/sidebar-02/nav-user.tsx | 40 ++- .../components/tasks/task-detail-sheet.tsx | 45 ++- frontend/components/tasks/tasks-view.tsx | 119 +++--- frontend/lib/i18n/locales/en/translation.json | 340 +++++++++++++++++- 20 files changed, 837 insertions(+), 319 deletions(-) diff --git a/frontend/app/(auth)/accept-invite/page.tsx b/frontend/app/(auth)/accept-invite/page.tsx index aaa6ddb..e9495b3 100644 --- a/frontend/app/(auth)/accept-invite/page.tsx +++ b/frontend/app/(auth)/accept-invite/page.tsx @@ -3,12 +3,14 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; import { AuthShell, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; function AcceptInviteInner() { + const { t } = useTranslation(); const router = useRouter(); const params = useSearchParams(); const invitationId = params.get("id") ?? ""; @@ -24,7 +26,7 @@ function AcceptInviteInner() { invitationId, }); if (err || !data) { - setError(err?.message ?? "This invitation is invalid or has expired."); + setError(err?.message ?? t("auth.acceptInvite.error")); setAccepting(false); return; } @@ -37,8 +39,8 @@ function AcceptInviteInner() { if (!invitationId) { return ( - - This invitation link is missing or invalid. + + {t("auth.acceptInvite.notFoundBody")} ); } @@ -48,8 +50,8 @@ function AcceptInviteInner() { const back = `/accept-invite?id=${encodeURIComponent(invitationId)}`; return (

- After signing in, reopen{" "} + {t("auth.acceptInvite.reopenPrefix")}{" "} - this invitation link + {t("auth.acceptInvite.reopenLink")} .

@@ -81,8 +83,8 @@ function AcceptInviteInner() { return (
{error && {error}} @@ -92,7 +94,9 @@ function AcceptInviteInner() { onClick={accept} type="button" > - {accepting ? "Joining…" : "Accept invitation"} + {accepting + ? t("auth.acceptInvite.accepting") + : t("auth.acceptInvite.accept")}
diff --git a/frontend/app/(auth)/forgot-password/page.tsx b/frontend/app/(auth)/forgot-password/page.tsx index c3b421e..c10b7c2 100644 --- a/frontend/app/(auth)/forgot-password/page.tsx +++ b/frontend/app/(auth)/forgot-password/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; @@ -10,6 +11,7 @@ import { authClient } from "@/lib/auth-client"; import { notify } from "@/lib/toast"; export default function ForgotPasswordPage() { + const { t } = useTranslation(); const [email, setEmail] = useState(""); const [sent, setSent] = useState(false); const [error, setError] = useState(null); @@ -28,12 +30,15 @@ export default function ForgotPasswordPage() { setSubmitting(false); if (err) { - const message = err.message ?? "Could not send the reset email."; + const message = err.message ?? t("auth.forgotPassword.error"); setError(message); - notify.error("Couldn't send reset link", message); + notify.error(t("auth.forgotPassword.failedToastTitle"), message); return; } - notify.success("Reset link sent", "Check your inbox for the link."); + notify.success( + t("auth.forgotPassword.sentToastTitle"), + t("auth.forgotPassword.sentToastBody"), + ); setSent(true); }; @@ -41,33 +46,34 @@ export default function ForgotPasswordPage() { - Back to sign in + {t("common.backToSignIn")} } - subtitle="We'll email you a link to reset your password" - title="Reset your password" + subtitle={t("auth.forgotPassword.subtitle")} + title={t("auth.forgotPassword.title")} > {sent ? ( - If an account exists for {email}, a reset link is on its way. Check - your inbox. + {t("auth.forgotPassword.sent", { email })} ) : (
{error && {error}} - + setEmail(e.target.value)} - placeholder="you@clinic.org" + placeholder={t("auth.forgotPassword.emailPlaceholder")} required type="email" value={email} /> )} diff --git a/frontend/app/(auth)/onboarding/page.tsx b/frontend/app/(auth)/onboarding/page.tsx index 36412ce..429e566 100644 --- a/frontend/app/(auth)/onboarding/page.tsx +++ b/frontend/app/(auth)/onboarding/page.tsx @@ -2,12 +2,14 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { AuthShell } from "@/components/auth/auth-ui"; import { CreateClinicForm } from "@/components/clinic/create-clinic-form"; import { authClient } from "@/lib/auth-client"; export default function OnboardingPage() { + const { t } = useTranslation(); const router = useRouter(); const { data: session, isPending } = authClient.useSession(); @@ -20,8 +22,8 @@ export default function OnboardingPage() { return ( router.push("/")} /> diff --git a/frontend/app/(auth)/reset-password/page.tsx b/frontend/app/(auth)/reset-password/page.tsx index 7259e56..6fe1d15 100644 --- a/frontend/app/(auth)/reset-password/page.tsx +++ b/frontend/app/(auth)/reset-password/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { type FormEvent, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; @@ -13,6 +14,7 @@ import { notify } from "@/lib/toast"; const MIN_PASSWORD = 12; function ResetPasswordInner() { + const { t } = useTranslation(); const router = useRouter(); const params = useSearchParams(); const token = params.get("token") ?? ""; @@ -28,15 +30,15 @@ function ResetPasswordInner() { setError(null); if (!token) { - setError("This reset link is invalid or has expired."); + setError(t("auth.resetPassword.invalidToken")); return; } if (password.length < MIN_PASSWORD) { - setError(`Password must be at least ${MIN_PASSWORD} characters.`); + setError(t("auth.resetPassword.tooShort", { count: MIN_PASSWORD })); return; } if (password !== confirm) { - setError("Passwords do not match."); + setError(t("auth.resetPassword.mismatch")); return; } @@ -47,12 +49,15 @@ function ResetPasswordInner() { }); setSubmitting(false); if (err) { - const message = err.message ?? "Could not reset your password."; + const message = err.message ?? t("auth.resetPassword.error"); setError(message); - notify.error("Couldn't reset password", message); + notify.error(t("auth.resetPassword.failedToastTitle"), message); return; } - notify.success("Password updated", "Redirecting you to sign in…"); + notify.success( + t("auth.resetPassword.successToastTitle"), + t("auth.resetPassword.successToastBody"), + ); setDone(true); setTimeout(() => router.push("/login"), 1500); }; @@ -61,23 +66,21 @@ function ResetPasswordInner() { - Back to sign in + {t("common.backToSignIn")} } - subtitle="Choose a new password for your account" - title="Set a new password" + subtitle={t("auth.resetPassword.subtitle")} + title={t("auth.resetPassword.title")} > {done ? ( - - Your password has been reset. Redirecting you to sign in… - + {t("auth.resetPassword.done")} ) : (
{error && {error}} - + )} diff --git a/frontend/app/(auth)/verify-email/page.tsx b/frontend/app/(auth)/verify-email/page.tsx index 3aa4f83..bca4469 100644 --- a/frontend/app/(auth)/verify-email/page.tsx +++ b/frontend/app/(auth)/verify-email/page.tsx @@ -3,12 +3,14 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { AuthShell, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; function VerifyEmailInner() { + const { t } = useTranslation(); const router = useRouter(); const params = useSearchParams(); const email = params.get("email") ?? ""; @@ -34,31 +36,25 @@ function VerifyEmailInner() { }); setSending(false); if (err) { - setError(err.message ?? "Could not resend the verification email."); + setError(err.message ?? t("auth.verifyEmail.resendError")); return; } - setNotice("Verification email sent. Check your inbox."); + setNotice(t("auth.verifyEmail.resent")); }; return ( - Back to sign in + {t("common.backToSignIn")} } subtitle={ - email ? ( - <> - We sent a verification link to{" "} - {email}. Open it to activate - your account. - - ) : ( - "Open the verification link we emailed you to activate your account." - ) + email + ? t("auth.verifyEmail.subtitle", { email }) + : t("auth.verifyEmail.subtitleNoEmail") } - title="Check your inbox" + title={t("auth.verifyEmail.title")} >
{notice && {notice}} @@ -68,7 +64,7 @@ function VerifyEmailInner() { onClick={() => router.push("/")} type="button" > - I've verified — continue + {t("auth.verifyEmail.continue")} {email && ( )}
diff --git a/frontend/components/activity/activity-view.tsx b/frontend/components/activity/activity-view.tsx index 7eeee22..0a160d1 100644 --- a/frontend/components/activity/activity-view.tsx +++ b/frontend/components/activity/activity-view.tsx @@ -13,6 +13,7 @@ import { Stethoscope, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Card } from "@/components/ui/card"; @@ -80,6 +81,7 @@ function Kpi({ } export function ActivityView() { + const { t } = useTranslation(); const [entries, setEntries] = useState([]); useEffect(() => { @@ -108,19 +110,27 @@ export function ActivityView() { const today = entries.filter((e) => new Date(e.createdAt) >= startOfToday); const week = entries.filter((e) => new Date(e.createdAt) >= startOfWeek); return [ - { label: "Changes today", value: String(today.length), icon: ActivityIcon }, - { label: "This week", value: String(week.length), icon: CalendarDays }, - { label: "Total recorded", value: String(entries.length), icon: Hash }, + { + label: t("activity.changesToday"), + value: String(today.length), + icon: ActivityIcon, + }, + { label: t("activity.thisWeek"), value: String(week.length), icon: CalendarDays }, + { + label: t("activity.totalRecorded"), + value: String(entries.length), + icon: Hash, + }, ]; - }, [entries]); + }, [entries, t]); return (
-

Activity

-

- An audit log of record changes across the clinic. -

+

+ {t("activity.title")} +

+

{t("activity.subtitle")}

@@ -131,8 +141,7 @@ export function ActivityView() { {entries.length === 0 ? (
- No activity yet. Changes to patients, notes, appointments, - prescriptions and tasks will appear here. + {t("activity.empty")}
) : (
    diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index 8ccd6de..107c865 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -1,6 +1,7 @@ "use client"; import { type ReactNode, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Card } from "@/components/ui/card"; import { type Analytics, getAnalytics } from "@/lib/analytics"; @@ -45,6 +46,7 @@ function Section({ } export function AnalysisView() { + const { t } = useTranslation(); const [data, setData] = useState(null); useEffect(() => { @@ -66,39 +68,75 @@ export function AnalysisView() { return (
    -

    Analysis

    -

    - Clinic performance at a glance, computed from your clinic's data. -

    +

    + {t("analysis.title")} +

    +

    {t("analysis.subtitle")}

    - - - + + +
    - - - - + + + +
    -
    - - +
    + +
    -
    - - +
    + +
    ); diff --git a/frontend/components/appointments/add-appointment-dialog.tsx b/frontend/components/appointments/add-appointment-dialog.tsx index 1b1d8f1..0e609ef 100644 --- a/frontend/components/appointments/add-appointment-dialog.tsx +++ b/frontend/components/appointments/add-appointment-dialog.tsx @@ -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([]); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(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({ > - New appointment + {t("appointments.dialog.title")} - Search for a patient by name or file number, then set the slot. + {t("appointments.dialog.description")}
    - + {selected ? (
    @@ -161,7 +169,9 @@ export function AddAppointmentDialog({ {selected.name} - File #{selected.fileNumber} + {t("appointments.dialog.fileNumber", { + number: selected.fileNumber, + })}
    ) : ( @@ -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} />
@@ -192,7 +202,7 @@ export function AddAppointmentDialog({
{matches.length === 0 ? (

- No patients found. + {t("appointments.dialog.noPatients")}

) : ( matches.map((p) => ( @@ -222,7 +232,9 @@ export function AddAppointmentDialog({
- Date + + {t("appointments.dialog.date")} +
- + setTime(event.target.value)} type="time" @@ -264,23 +276,23 @@ export function AddAppointmentDialog({
- + - + setProvider(event.target.value)} - placeholder="e.g. Dr. Okafor" + placeholder={t("appointments.dialog.providerPlaceholder")} value={provider} /> @@ -289,10 +301,10 @@ export function AddAppointmentDialog({ }> - Cancel + {t("appointments.dialog.cancel")} diff --git a/frontend/components/appointments/appointments-view.tsx b/frontend/components/appointments/appointments-view.tsx index b781dc9..4bef873 100644 --- a/frontend/components/appointments/appointments-view.tsx +++ b/frontend/components/appointments/appointments-view.tsx @@ -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 = { - "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 (
@@ -121,7 +116,9 @@ function ApptRow({ appt }: { appt: Appointment }) { {appt.type} · {appt.provider}
- {statusLabel[appt.status]} + + {t(`appointments.status.${appt.status}`)} +
); } @@ -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([]); @@ -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() {

- Appointments & Schedule + {t("appointments.title")}

- Today's clinic schedule and what's coming up. + {t("appointments.subtitle")}

@@ -268,7 +269,7 @@ export function AppointmentsView() { setQuery(event.target.value)} - placeholder="Search patient, type, provider" + placeholder={t("appointments.searchPlaceholder")} value={query} />
@@ -279,7 +280,7 @@ export function AppointmentsView() { variant="outline" > - Calendar + {t("appointments.calendar")}
@@ -301,7 +302,7 @@ export function AppointmentsView() { )) ) : (

- No matching appointments. + {t("appointments.noMatches")}

) ) : ( @@ -312,12 +313,12 @@ export function AppointmentsView() { ))}
-
+
{todayItems.length > 0 ? ( ) : (

- Nothing scheduled today. + {t("appointments.nothingToday")}

)}
diff --git a/frontend/components/appointments/calendar-dialog.tsx b/frontend/components/appointments/calendar-dialog.tsx index b1f1dc3..865f008 100644 --- a/frontend/components/appointments/calendar-dialog.tsx +++ b/frontend/components/appointments/calendar-dialog.tsx @@ -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(() => { const t = parseKey(TODAY); @@ -120,7 +122,7 @@ export function CalendarDialog({ type="button" variant="outline" > - Today + {t("appointments.calendarDialog.today")}
{selectedItems.length > 0 ? ( ) : (
- No appointments on this day. + {t("appointments.calendarDialog.none")}
)} diff --git a/frontend/components/clinic/create-clinic-form.tsx b/frontend/components/clinic/create-clinic-form.tsx index 8f689e4..7d0e2cc 100644 --- a/frontend/components/clinic/create-clinic-form.tsx +++ b/frontend/components/clinic/create-clinic-form.tsx @@ -1,6 +1,7 @@ "use client"; import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,11 +21,12 @@ function slugify(value: string): string { // clinic menu's "Create clinic" dialog. export function CreateClinicForm({ onCreated, - submitLabel = "Create clinic", + submitLabel, }: { onCreated?: (org: { id: string; name: string }) => void; submitLabel?: string; }) { + const { t } = useTranslation(); const [name, setName] = useState(""); const [slug, setSlug] = useState(""); const [slugEdited, setSlugEdited] = useState(false); @@ -42,15 +44,15 @@ export function CreateClinicForm({ await authClient.organization.create({ name: name.trim(), slug: finalSlug }); if (createErr || !org) { - const message = createErr?.message ?? "Could not create the clinic."; + const message = createErr?.message ?? t("clinic.createError"); setError(message); - notify.error("Couldn't create clinic", message); + notify.error(t("clinic.createFailedTitle"), message); setSubmitting(false); return; } await authClient.organization.setActive({ organizationId: org.id }); - notify.success("Clinic created", `${org.name} is ready.`); + notify.success(t("clinic.createdTitle"), t("clinic.createdBody", { name: org.name })); onCreated?.(org); }; @@ -66,7 +68,7 @@ export function CreateClinicForm({ className="font-medium text-foreground text-sm" htmlFor="clinic-name" > - Clinic name + {t("clinic.nameLabel")} @@ -84,7 +86,7 @@ export function CreateClinicForm({ className="font-medium text-foreground text-sm" htmlFor="clinic-slug" > - Clinic URL slug + {t("clinic.slugLabel")} -

- Used in links and invitations. Lowercase letters, numbers and dashes. -

+

{t("clinic.slugHint")}

); diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index bdf6994..301e14a 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -8,6 +8,7 @@ import { useRef, useState, } from "react"; +import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -59,6 +60,7 @@ function formatTime(iso: string): string { } export function MessagesView() { + const { t } = useTranslation(); const { data: session } = authClient.useSession(); const myId = session?.user?.id ?? ""; @@ -182,7 +184,10 @@ export function MessagesView() { setComposeOpen(false); open(conv.id); } catch { - notify.error("Couldn't start conversation", "Please try again."); + notify.error( + t("messages.startFailedTitle"), + t("messages.startFailedBody"), + ); } }; @@ -191,7 +196,9 @@ export function MessagesView() { {/* Left: conversation list */}