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 */}