mirror of
https://github.com/temetro/temetro.git
synced 2026-08-30 12:19:07 +00:00
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:
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user