i18n: convert settings, chat, patient records and notes; update docs

Finish the i18n pass: settings panels (profile/care-team/signing), the
chat heading + input, the patient cards / detail / create-edit form, and
the notes page + rich-text editor are all keyed in en/translation.json.
All 526 static t() keys resolve. Document the new backend resources +
Socket.io realtime (backend README/CLAUDE) and the i18n coverage
(frontend CLAUDE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-08 02:23:20 +03:00
parent e0de20b551
commit ab2f10bffc
17 changed files with 850 additions and 364 deletions
@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -11,45 +13,55 @@ import {
} from "@/components/settings/settings-parts";
export function SigningPanel() {
const { t } = useTranslation();
return (
<>
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold tracking-tight">Signing key</h3>
<Badge className="bg-emerald-500/15 text-emerald-400">Active</Badge>
<h3 className="text-xl font-semibold tracking-tight">
{t("settings.signing.keyTitle")}
</h3>
<Badge className="bg-emerald-500/15 text-emerald-400">
{t("settings.signing.active")}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Every change you make to a patient record is signed with this key, so patients
can verify it came from you before approving it.
{t("settings.signing.keyDescription")}
</p>
<Button className={cn("rounded-lg", whiteButton)}>Rotate key</Button>
<Button className={cn("rounded-lg", whiteButton)}>
{t("settings.signing.rotateKey")}
</Button>
</div>
<div className="sm:text-right">
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
<p className="text-sm text-muted-foreground">Created May 28, 2026</p>
<p className="text-sm text-muted-foreground">
{t("settings.signing.createdAt")}
</p>
</div>
</SettingsCard>
<SettingsSection
description="The public key patients use to verify your signatures"
title="Signing identity"
description={t("settings.signing.identityDescription")}
title={t("settings.signing.identityTitle")}
>
<SettingsCard className="p-5">
<CopyField
description="Share or publish this fingerprint so patients can trust your changes"
label="Public key fingerprint"
description={t("settings.signing.fingerprintDescription")}
label={t("settings.signing.fingerprintLabel")}
value="ed25519:9f86 d081 884c 7d65 9a2f eaa0 c55a d015"
/>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Changes you've signed that are waiting on the patient's approval"
title="Signed records"
description={t("settings.signing.signedRecordsDescription")}
title={t("settings.signing.signedRecordsTitle")}
>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">No pending signatures</p>
<p className="text-sm text-muted-foreground">
{t("settings.signing.noPending")}
</p>
</SettingsCard>
</SettingsSection>
</>
@@ -2,6 +2,7 @@
import { X } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
SettingsCard,
@@ -48,6 +49,7 @@ function initials(name?: string | null, email?: string | null): string {
}
export function CareTeamPanel() {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const [members, setMembers] = useState<Member[]>([]);
const [invites, setInvites] = useState<Invite[]>([]);
@@ -63,7 +65,7 @@ export function CareTeamPanel() {
const { data, error: err } =
await authClient.organization.getFullOrganization();
if (err || !data) {
setError(err?.message ?? "Could not load the care team.");
setError(err?.message ?? t("settings.careTeam.loadError"));
setLoading(false);
return;
}
@@ -96,11 +98,11 @@ export function CareTeamPanel() {
});
setInviting(false);
if (err) {
setError(err.message ?? "Could not send the invitation.");
setError(err.message ?? t("settings.careTeam.inviteError"));
return;
}
setEmail("");
setNotice(`Invitation sent to ${email.trim()}.`);
setNotice(t("settings.careTeam.inviteSent", { email: email.trim() }));
void load();
};
@@ -116,8 +118,8 @@ export function CareTeamPanel() {
return (
<SettingsSection
description="Clinicians with access to this clinic"
title="Care team"
description={t("settings.careTeam.description")}
title={t("settings.careTeam.title")}
>
{error && (
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-sm text-destructive">
@@ -139,7 +141,7 @@ export function CareTeamPanel() {
<Input
className="flex-1"
onChange={(e) => setEmail(e.target.value)}
placeholder="colleague@clinic.org"
placeholder={t("settings.careTeam.invitePlaceholder")}
required
type="email"
value={email}
@@ -158,7 +160,9 @@ export function CareTeamPanel() {
))}
</select>
<Button disabled={inviting} type="submit">
{inviting ? "Sending…" : "Invite"}
{inviting
? t("settings.careTeam.inviting")
: t("settings.careTeam.invite")}
</Button>
</form>
</SettingsCard>
@@ -167,7 +171,7 @@ export function CareTeamPanel() {
<SettingsCard className="divide-y divide-border">
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">
Loading care team
{t("settings.careTeam.loading")}
</p>
) : (
members.map((m) => {
@@ -184,7 +188,7 @@ export function CareTeamPanel() {
{m.user?.name || m.user?.email || m.userId}
{isSelf && (
<span className="ml-1 text-xs text-muted-foreground">
(you)
{t("settings.careTeam.you")}
</span>
)}
</p>
@@ -199,7 +203,7 @@ export function CareTeamPanel() {
</Badge>
{canManage && !isSelf && m.role !== "owner" && (
<Button
aria-label="Remove member"
aria-label={t("settings.careTeam.removeMember")}
onClick={() => removeMember(m.id)}
size="icon-sm"
type="button"
@@ -217,7 +221,7 @@ export function CareTeamPanel() {
{invites.length > 0 && (
<SettingsCard className="divide-y divide-border">
<p className="px-4 py-2.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
Pending invitations
{t("settings.careTeam.pendingInvitations")}
</p>
{invites.map((inv) => (
<div className="flex items-center gap-3 px-4 py-3" key={inv.id}>
@@ -229,7 +233,7 @@ export function CareTeamPanel() {
</Badge>
{canManage && (
<Button
aria-label="Cancel invitation"
aria-label={t("settings.careTeam.cancelInvitation")}
onClick={() => cancelInvite(inv.id)}
size="icon-sm"
type="button"
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
@@ -78,6 +79,7 @@ export function CopyField({
description?: string;
value: string;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-0.5">
@@ -95,7 +97,7 @@ export function CopyField({
type="button"
>
<Copy className="size-3.5" />
Copy
{t("settings.copy")}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
"use client";
import { ChevronDown, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@@ -13,52 +14,36 @@ import {
ToggleRow,
} from "@/components/settings/settings-parts";
// Keys into settings.profile.notif.* — these toggles are illustrative.
const patientNotifications = [
{
title: "New lab result",
description: "Sent when a new lab result is available on a patient's chart",
},
{
title: "Record updated",
description: "Sent when a patient's record is updated by a member of the care team",
},
{
title: "Approval requested",
description: "Sent when a signed change is awaiting the patient's approval",
},
{
title: "Change approved",
description: "Sent when a patient approves a pending change to their record",
},
{
title: "New message",
description: "Sent when a patient or another clinician sends a message",
},
{
title: "Visit scheduled",
description: "Sent when an upcoming visit is added to a patient's record",
},
];
{ titleKey: "newLab", descKey: "newLabDesc" },
{ titleKey: "recordUpdated", descKey: "recordUpdatedDesc" },
{ titleKey: "approvalRequested", descKey: "approvalRequestedDesc" },
{ titleKey: "changeApproved", descKey: "changeApprovedDesc" },
{ titleKey: "newMessage", descKey: "newMessageDesc" },
{ titleKey: "visitScheduled", descKey: "visitScheduledDesc" },
] as const;
export function ProfilePanel() {
const { t } = useTranslation();
return (
<>
<SettingsSection title="Clinician profile">
<SettingsSection title={t("settings.profile.sectionTitle")}>
<SettingsCard className="space-y-6 p-5">
<CopyField
description="Your unique clinician identifier, used when signing records"
label="Clinician ID"
description={t("settings.profile.clinicianIdDescription")}
label={t("settings.profile.clinicianIdLabel")}
value="62a5278f-91c6-4912-b711-ee1c9c2f0a73"
/>
<CopyField
description="Used in your public profile and the patient portal"
label="Handle"
description={t("settings.profile.handleDescription")}
label={t("settings.profile.handleLabel")}
value="dr-khalid"
/>
<div className="flex items-end gap-4">
<div className="space-y-1.5">
<FieldLabel>Avatar</FieldLabel>
<FieldLabel>{t("settings.profile.avatar")}</FieldLabel>
<Avatar className="size-10 rounded-xl">
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
K
@@ -66,112 +51,118 @@ export function ProfilePanel() {
</Avatar>
</div>
<div className="flex-1 space-y-1.5">
<FieldLabel required>Display name</FieldLabel>
<FieldLabel required>
{t("settings.profile.displayName")}
</FieldLabel>
<Input defaultValue="Dr. Khalid" />
</div>
</div>
<div className="space-y-1.5">
<FieldLabel>Specialty</FieldLabel>
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
<button
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
type="button"
>
Select specialty
{t("settings.profile.selectSpecialty")}
<ChevronDown className="size-4" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel>Clinic / practice</FieldLabel>
<Input placeholder="e.g. Main Hospital" />
<FieldLabel>{t("settings.profile.clinic")}</FieldLabel>
<Input placeholder={t("settings.profile.clinicPlaceholder")} />
</div>
<div className="space-y-1.5">
<FieldLabel required>Contact email</FieldLabel>
<Input placeholder="clinician@example.org" />
<FieldLabel required>
{t("settings.profile.contactEmail")}
</FieldLabel>
<Input
placeholder={t("settings.profile.contactEmailPlaceholder")}
/>
</div>
</div>
<div className="space-y-2.5">
<div className="space-y-0.5">
<FieldLabel>Professional links</FieldLabel>
<FieldLabel>{t("settings.profile.professionalLinks")}</FieldLabel>
<p className="text-xs text-muted-foreground">
Registry or institutional profiles used to verify your identity. They are
never shown to patients.
{t("settings.profile.professionalLinksHint")}
</p>
</div>
<Button className="rounded-lg" size="sm" variant="outline">
<Plus className="size-4" />
Add link
{t("settings.profile.addLink")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Emails sent to patients about their records, results, and pending approvals"
title="Patient notifications"
description={t("settings.profile.patientNotificationsDescription")}
title={t("settings.profile.patientNotifications")}
>
<div className="space-y-3">
{patientNotifications.map((item) => (
<ToggleRow
defaultChecked
description={item.description}
key={item.title}
title={item.title}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</div>
</SettingsSection>
<SettingsSection
description="Notifications sent to you about your patients and the care team"
title="Account notifications"
description={t("settings.profile.accountNotificationsDescription")}
title={t("settings.profile.accountNotifications")}
>
<div className="space-y-3">
<ToggleRow
defaultChecked
description="Notify me when a patient approves or rejects a pending change"
title="Pending approvals"
description={t("settings.profile.notif.pendingApprovalsDesc")}
title={t("settings.profile.notif.pendingApprovals")}
/>
<ToggleRow
defaultChecked
description="Notify me when a patient shares a record with me"
title="Records shared with me"
description={t("settings.profile.notif.recordsSharedDesc")}
title={t("settings.profile.notif.recordsShared")}
/>
</div>
</SettingsSection>
<SettingsSection
description="Manage alpha & beta features for your account"
title="Features"
description={t("settings.profile.featuresDescription")}
title={t("settings.profile.features")}
>
<div className="space-y-3">
<ToggleRow
description="Write records to the patient's own device instead of your database"
title="Patient-owned storage (beta)"
description={t("settings.profile.notif.patientStorageDesc")}
title={t("settings.profile.notif.patientStorage")}
/>
<ToggleRow
description="Require a signature on every change you make to a patient record"
title="Require signed records"
description={t("settings.profile.notif.requireSignedDesc")}
title={t("settings.profile.notif.requireSigned")}
/>
</div>
</SettingsSection>
<SettingsSection
description="Irreversible actions for your account"
title="Danger Zone"
description={t("settings.profile.dangerZoneDescription")}
title={t("settings.profile.dangerZone")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Delete account</p>
<p className="text-sm font-medium">
{t("settings.profile.deleteAccount")}
</p>
<p className="text-sm text-muted-foreground">
Permanently delete your temetro account and any locally stored signing
keys. This action cannot be undone.
{t("settings.profile.deleteAccountDescription")}
</p>
</div>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">{t("settings.profile.delete")}</Button>
</SettingsCard>
</SettingsSection>
</>