Compare commits

...

4 Commits

Author SHA1 Message Date
Khalid Abdi ab17978b5c feat: portal doctor booking, Arabic RTL fix, wallet portal QR (v0.10.0)
backend: public GET /api/portal/:clinic/doctors and /availability, and
thread a chosen provider into portal bookings (conflict check unchanged).

frontend: fix site-wide Arabic RTL — anchor the sidebar right for RTL and
mirror the Switch thumb. Add a Patient Portal section (open/copy/QR) to
Settings → Signing and a "Use my current location" GPS button to the clinic
location editor. New i18n keys across all five locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:31:20 +03:00
Khalid Abdi 36461a5498 feat: patient blood type & phone + clinic location setting (v0.9.0)
Patient record:
- Add `bloodType` and `phone` to the patient model (schema, canonical types on
  both backend + frontend, zod validation). `phone` is a demographic field
  (reception can read/write); `bloodType` is clinical PHI, redacted for the
  reception role. Surface both in the record sheet, chat summary card, and the
  add/edit patient form. Migration 0033.

Clinic location:
- New org-scoped `clinic_settings` table (address/city/country + optional
  lat/long), service, and routes: GET /api/clinic/settings (any clinician) and
  PUT /api/clinic/location (owner/admin). Edited in Settings → Signing → Clinic
  location. Consumed later by the wallet app. Migration 0034.

i18n:
- Translate all new keys into every shipped locale (en/de/fr/ar/so) and document
  the "translate into every locale" rule in frontend/CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:07:27 +03:00
Khalid Abdi 01dbc07e92 fix: default RELAY_URL to the hosted relay (v0.8.2)
The default http://localhost:8080 silently failed for clinics that joined
the network without setting RELAY_URL — inside Docker localhost is the
container itself, so the hub connection never reached the relay (endless
"relay unreachable" retries) and pairing QRs encoded an unreachable
localhost. Default to https://network.temetro.com so "Join Temetro Network"
works out of the box; self-hosters running their own relay still override
it. Also (re)ensure the hub is connected before pre-registering a pairing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:50:46 +03:00
Khalid Abdi 233ce9f854 fix: QR wallet pairing broken by multi-clinic routing (v0.8.1)
QR "scan to connect" pairing has no wallet number, so nothing registered
the request with the relay and the scanning device's response was rejected
("unknown or expired request"). The backend now pre-registers the pairing
request over the relay's new hub:expect event on POST /pair, and
re-registers still-pending requests on hub (re)connect so routing survives
a relay restart. /pair now requires the clinic to have joined the network
(clear 409, surfaced in the import dialog, localized in all five langs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:16:33 +03:00
41 changed files with 10302 additions and 21 deletions
+71 -1
View File
@@ -7,7 +7,77 @@ for how releases are cut and published.
## [Unreleased]
## [0.8.0] — 2026-07-05
## [0.10.0] — 2026-07-07
### Added
- **Patient Portal doctor picker & availability.** The portal now lists the clinic's doctors and
books against a chosen provider, showing only free slots. New public endpoints
`GET /api/portal/:clinic/doctors` and `GET /api/portal/:clinic/availability?provider=&date=`
(display-safe fields only), and `POST /api/portal/:clinic/appointments` accepts an optional
`provider` (`backend/src/routes/portal.ts`). The existing 409 conflict check stays authoritative.
- **Patient Portal links in Settings.** Settings → Signing → Patient Portal adds **open**, **copy
link**, and **QR code** actions (`components/settings/settings-portal.tsx`); the QR carries the
backend base (`?api=`) so the patient wallet app can book natively when it scans it.
- **Clinic location "Use my current location".** The location editor fills map coordinates from the
browser's geolocation (`components/settings/settings-location.tsx`).
- **Wallet app native Patient Portal.** Scanning a clinic's portal QR opens a native booking screen
(doctor list → free-slot picker → confirm) in the patient wallet app.
### Fixed
- **Arabic (RTL) layout.** The sidebar now anchors to the **right** for RTL locales and the toggle
switch mirrors correctly, instead of leaving the shell misaligned
(`components/sidebar-02/app-sidebar.tsx`, `components/ui/switch.tsx`).
- **Wallet app:** record-card **bottom sheet no longer freezes** the app (dropped the per-frame
animated blur overlay for HeroUI's built-in overlay); **Reset wallet** now confirms in a native
HeroUI dialog with Liquid Glass actions; fixed the **white edge flash** on screen transitions in
dark mode; the home/onboarding/register **logo** is now the Temetro mark.
### Changed
- New i18n keys (`settings.portal.*`, geolocation strings) are translated into **all** shipped
locales (en, de, fr, ar, so).
## [0.9.0] — 2026-07-06
### Added
- **Patient blood type & phone number.** The patient record now carries a `bloodType` (e.g. `O+`)
and a `phone` number. Both are shown in the record sheet and chat summary card and are editable in
the add/edit patient form. `phone` is a demographic/contact field (visible to and editable by the
**reception** role); `bloodType` is treated as clinical PHI and is **redacted for reception** (like
allergies/vitals). New columns `patients.phone` / `patients.blood_type` (migration `0033`).
- **Clinic location setting.** A new org-scoped `clinic_settings` table (migration `0034`) stores the
clinic's address (address / city / country) plus optional map coordinates (latitude / longitude),
set in **Settings → Signing → Clinic location** (owner/admin only). New endpoints
`GET /api/clinic/settings` (any clinician) and `PUT /api/clinic/location` (owner/admin). This will
be surfaced in the patient wallet app to show a clinic's location.
### Changed
- New i18n keys for the above are translated into **all** shipped locales (en, de, fr, ar, so), per
the coverage rule now documented in `frontend/CLAUDE.md`.
## [0.8.2] — 2026-07-05
### Fixed
- **`RELAY_URL` now defaults to the hosted relay** (`https://network.temetro.com`) instead of
`http://localhost:8080`. The old default silently failed for anyone who joined the network without
explicitly setting `RELAY_URL` — the backend's hub connection could never reach the relay (inside
Docker `localhost` is the container itself), so it never authenticated and QR pairing generated a
QR pointing at an unreachable `localhost`. Self-hosters running their own relay still override
`RELAY_URL`. Updated `.env.example` accordingly.
### Changed
- Generating a pairing QR (`POST /api/patients/wallet/pair`) now ensures the clinic's relay hub is
connected before pre-registering the request, so the routing is set up even if the connection was
opened lazily.
### Fixed
- **QR "scan to connect" pairing** was broken by the multi-clinic relay routing (v0.8.0): pairing
has no wallet number, so the clinic never sent a `wallet:send` to register the request, and the
relay rejected the scanning device's response as "unknown or expired". The clinic now
**pre-registers** the pairing request with the relay (a new `hub:expect { requestId }` event on
`POST /api/patients/wallet/pair`), so the device's response routes back correctly. On hub
(re)connect the backend re-registers its still-pending requests, so routing also survives a relay
restart. `POST /pair` now also requires the clinic to have joined the network (clear 409 instead of
a dead QR), surfaced in the import dialog.
### Added
- **Multi-clinic Temetro Network.** The relay now serves many self-hosted clinics at once. Each
+5 -1
View File
@@ -40,7 +40,11 @@ FRONTEND_PORT=3000
# /hub with its own Ed25519 signing key, so no shared secret is needed.
# RELAY_TOKEN is OPTIONAL/LEGACY — set it only for a private relay that also
# gates on a shared token (then use the SAME value here and on the relay).
RELAY_URL=http://localhost:8080
# Defaults to the hosted relay (https://network.temetro.com) when unset, so
# "Join Temetro Network" works out of the box; set RELAY_URL only to point at
# your own relay. Do NOT use http://localhost — inside Docker that's the
# container itself and the relay connection will silently fail.
RELAY_URL=https://network.temetro.com
RELAY_TOKEN=
# (Legacy, pre-relay self-hosting.) A phone-reachable URL for the QR when NOT
@@ -0,0 +1,2 @@
ALTER TABLE "patients" ADD COLUMN "phone" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "patients" ADD COLUMN "blood_type" text DEFAULT '' NOT NULL;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "clinic_settings" (
"organization_id" text PRIMARY KEY NOT NULL,
"address" text DEFAULT '' NOT NULL,
"city" text DEFAULT '' NOT NULL,
"country" text DEFAULT '' NOT NULL,
"latitude" double precision,
"longitude" double precision,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "clinic_settings" ADD CONSTRAINT "clinic_settings_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -232,6 +232,20 @@
"when": 1783263738631,
"tag": "0032_closed_dakota_north",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1783362745730,
"tag": "0033_ambitious_reavers",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1783363217049,
"tag": "0034_chunky_blacklash",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.8.0",
"version": "0.10.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+25
View File
@@ -0,0 +1,25 @@
import { doublePrecision, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { organization } from "./auth.js";
// Per-clinic (organization) settings. Currently holds the clinic's physical
// location — a free-text address plus optional map coordinates — set in
// Settings → Location by an owner/admin and surfaced to patients in the wallet
// app later (e.g. a map pin for a clinic that shared a record). One row per org
// (PK = organizationId), mirroring `clinic_signing_keys`.
export const clinicSettings = pgTable("clinic_settings", {
organizationId: text("organization_id")
.primaryKey()
.references(() => organization.id, { onDelete: "cascade" }),
address: text("address").notNull().default(""),
city: text("city").notNull().default(""),
country: text("country").notNull().default(""),
// Optional map coordinates (WGS84). Null until the clinic sets them.
latitude: doublePrecision("latitude"),
longitude: doublePrecision("longitude"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
+1
View File
@@ -20,6 +20,7 @@ export * from "./integrations.js";
export * from "./staff-profile.js";
export * from "./meetings.js";
export * from "./signing.js";
export * from "./clinic-settings.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
export * from "./fhir-keys.js";
+5
View File
@@ -36,6 +36,11 @@ export const patients = pgTable(
pcp: text("pcp").notNull(),
status: text("status").$type<PatientStatus>().notNull(),
initials: text("initials").notNull(),
// Contact + clinical demographics. `phone` is a contact/registration field
// (reception may read/write it); `bloodType` is clinical (redacted for the
// reception role, like allergies/vitals).
phone: text("phone").notNull().default(""),
bloodType: text("blood_type").notNull().default(""),
alerts: jsonb("alerts").$type<string[]>().notNull(),
vitalsBp: text("vitals_bp").notNull(),
vitalsHr: text("vitals_hr").notNull(),
+5 -1
View File
@@ -35,7 +35,11 @@ const schema = z.object({
// its own Ed25519 signing key, so no shared secret is needed. RELAY_TOKEN is
// now *optional/legacy* — set it only for a private relay that also gates on a
// shared token (must then match the relay's RELAY_TOKEN).
RELAY_URL: z.string().min(1).default("http://localhost:8080"),
//
// Defaults to the hosted relay so "Join Temetro Network" works out of the box;
// override only when running your own relay. (A `localhost` default silently
// fails inside Docker, where localhost is the container itself.)
RELAY_URL: z.string().min(1).default("https://network.temetro.com"),
RELAY_TOKEN: z.string().default(""),
// Public, device-reachable URL of this backend's wallet relay, baked into the
// QR a patient scans. Optional — when unset we derive it from the request host
+2
View File
@@ -16,6 +16,7 @@ import { analyticsRouter } from "./routes/analytics.js";
import { attachmentsRouter } from "./routes/attachments.js";
import { appointmentsRouter } from "./routes/appointments.js";
import { chatRouter } from "./routes/chat.js";
import { clinicRouter } from "./routes/clinic.js";
import { conversationsRouter } from "./routes/conversations.js";
import { dispensesRouter } from "./routes/dispenses.js";
import { fhirRouter } from "./routes/fhir.js";
@@ -90,6 +91,7 @@ app.use("/api/network", networkRouter);
app.use("/api/patients/wallet", patientsWalletRouter);
app.use("/api/patients", patientsRouter);
app.use("/api/signing", signingRouter);
app.use("/api/clinic", clinicRouter);
app.use("/api/attachments", attachmentsRouter);
app.use("/api/notes", notesRouter);
app.use("/api/appointments", appointmentsRouter);
+4
View File
@@ -105,6 +105,10 @@ export const patientInputSchema = z
),
status: z.enum(["active", "inpatient", "discharged"]).default("active"),
initials: z.string().trim().max(4).default(""),
phone: z.string().trim().max(30).default(""),
bloodType: z
.enum(["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-", ""])
.default(""),
allergies: z.array(allergySchema).default([]),
alerts: z.array(z.string()).default([]),
medications: z.array(medicationSchema).default([]),
+61
View File
@@ -0,0 +1,61 @@
import { Router } from "express";
import { z } from "zod";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import * as clinicSettings from "../services/clinic-settings.js";
export const clinicRouter = Router();
clinicRouter.use(requireAuth, requireOrg);
// The clinic's settings (currently just its location). Readable by any
// clinician so the app/UI can display the clinic address.
clinicRouter.get(
"/settings",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await clinicSettings.getClinicSettings(req.organizationId!));
} catch (err) {
next(err);
}
},
);
// Set the clinic's location — owner/admin only (gated on the org-update
// statement, same as signing-key rotation / network toggle).
const locationSchema = z.object({
address: z.string().trim().max(200).default(""),
city: z.string().trim().max(120).default(""),
country: z.string().trim().max(120).default(""),
latitude: z.number().min(-90).max(90).nullable().default(null),
longitude: z.number().min(-180).max(180).nullable().default(null),
});
clinicRouter.put(
"/location",
requirePermission({ organization: ["update"] }),
async (req, res, next) => {
try {
const location = locationSchema.parse(req.body);
const view = await clinicSettings.setClinicLocation(
req.organizationId!,
location,
);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: "Updated the clinic location",
entityType: "settings",
});
res.json(view);
} catch (err) {
next(err);
}
},
);
+8
View File
@@ -17,6 +17,7 @@ import {
} from "../middleware/auth.js";
import { emitToWallet } from "../realtime.js";
import { recordActivity } from "../services/activity.js";
import { connectOrg, expectResponse } from "../services/relay-client.js";
import * as patientService from "../services/patients.js";
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
import { getNetworkEnabled } from "../services/signing.js";
@@ -84,6 +85,7 @@ patientsWalletRouter.post(
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
await requireNetwork(req.organizationId!);
const input = pairSchema.parse(req.body);
const { view, ephemeralPubKey } = await walletShare.createPairingRequest(
req.organizationId!,
@@ -91,6 +93,12 @@ patientsWalletRouter.post(
input.mode,
input.durationHours,
);
// No wallet number to `wallet:send` to yet, so pre-register the request id
// with the relay so the scanning device's response routes back to us.
// Ensure the hub is (re)connected first; if it's still mid-handshake the
// on-auth re-registration of pending requests will catch this one.
await connectOrg(req.organizationId!);
expectResponse(req.organizationId!, view.id);
res.status(201).json({
...view,
ephemeralPubKey,
+72 -3
View File
@@ -1,9 +1,10 @@
import { eq } from "drizzle-orm";
import { and, asc, eq, inArray } from "drizzle-orm";
import { Router, type Request } from "express";
import { z } from "zod";
import { db } from "../db/index.js";
import { organization } from "../db/schema/auth.js";
import { member, organization, user } from "../db/schema/auth.js";
import { staffProfile } from "../db/schema/staff-profile.js";
import { appointmentInputSchema } from "../lib/appointment-validation.js";
import { HttpError } from "../lib/http-error.js";
import { initialsFromName } from "../lib/initials.js";
@@ -12,6 +13,10 @@ import { recordActivity } from "../services/activity.js";
import { createAppointment, listAppointments } from "../services/appointments.js";
import { createPatient, getPatient } from "../services/patients.js";
// Clinical-capable roles that can be a patient's provider (mirrors
// staff.ts PROVIDER_ROLES). Department roles (reception, pharmacy, lab) excluded.
const PROVIDER_ROLES = ["owner", "admin", "doctor", "member"] as const;
// Public, unauthenticated kiosk API for a clinic's Patient Portal (an iPad in the
// waiting room). Scoped by the clinic slug in the URL — there is no session.
//
@@ -45,12 +50,76 @@ portalRouter.get("/:clinic", async (req, res, next) => {
}
});
// GET /api/portal/:clinic/doctors — public list of the clinic's providers so a
// patient can pick who to see. Returns only display-safe fields (name +
// specialty); no ids, emails, or usernames leave this unauthenticated surface.
portalRouter.get("/:clinic/doctors", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const rows = await db
.select({ name: user.name, specialty: staffProfile.specialty })
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.leftJoin(
staffProfile,
and(
eq(staffProfile.userId, member.userId),
eq(staffProfile.organizationId, member.organizationId),
),
)
.where(
and(
eq(member.organizationId, clinic.id),
inArray(member.role, PROVIDER_ROLES as unknown as string[]),
),
)
.orderBy(asc(user.name));
res.json(rows.map((r) => ({ name: r.name, specialty: r.specialty ?? null })));
} catch (err) {
next(err);
}
});
const availabilitySchema = z.object({
provider: z.string().trim().max(200).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
});
// GET /api/portal/:clinic/availability?provider=&date= — the taken time slots
// for a provider on a given day, so the kiosk can render only free slots. The
// filter mirrors the booking conflict check (an empty-provider appointment
// blocks the slot clinic-wide). Booking still re-checks server-side (409).
portalRouter.get("/:clinic/availability", async (req, res, next) => {
try {
const clinic = await resolveClinic(req);
const q = availabilitySchema.parse({
provider: req.query.provider,
date: req.query.date,
});
const provider = q.provider ?? "";
const taken = (await listAppointments(clinic.id))
.filter(
(a) =>
a.status !== "cancelled" &&
a.date === q.date &&
(!provider || !a.provider || a.provider === provider),
)
.map((a) => a.time);
res.json({ date: q.date, provider, taken: [...new Set(taken)].sort() });
} catch (err) {
next(err);
}
});
const bookingSchema = z.object({
fileNumber: z.string().trim().min(1, "A file number is required.").max(64),
name: z.string().trim().min(1, "Your name is required.").max(200),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD."),
time: z.string().regex(/^\d{2}:\d{2}$/, "Time must be HH:mm."),
type: z.string().trim().max(120).optional(),
// Chosen provider (doctor name) from the portal's doctor picker; falls back
// to the patient's PCP when omitted.
provider: z.string().trim().max(200).optional(),
});
const newPatientSchema = z.object({
@@ -114,7 +183,7 @@ portalRouter.post("/:clinic/appointments", async (req, res, next) => {
date: body.date,
time: body.time,
type: body.type || "Self-service booking",
provider: patient.pcp || "",
provider: body.provider || patient.pcp || "",
status: "confirmed",
source: "manual",
});
+82
View File
@@ -0,0 +1,82 @@
import { eq } from "drizzle-orm";
import { db } from "../db/index.js";
import { clinicSettings } from "../db/schema/clinic-settings.js";
export type ClinicLocation = {
address: string;
city: string;
country: string;
latitude: number | null;
longitude: number | null;
};
export type ClinicSettingsView = {
location: ClinicLocation;
};
const EMPTY_LOCATION: ClinicLocation = {
address: "",
city: "",
country: "",
latitude: null,
longitude: null,
};
type ClinicSettingsRow = typeof clinicSettings.$inferSelect;
function toView(row: ClinicSettingsRow | undefined): ClinicSettingsView {
if (!row) return { location: { ...EMPTY_LOCATION } };
return {
location: {
address: row.address,
city: row.city,
country: row.country,
latitude: row.latitude,
longitude: row.longitude,
},
};
}
// Read a clinic's settings. Returns empty defaults when no row exists yet, so
// the panel always renders.
export async function getClinicSettings(
orgId: string,
): Promise<ClinicSettingsView> {
const [row] = await db
.select()
.from(clinicSettings)
.where(eq(clinicSettings.organizationId, orgId))
.limit(1);
return toView(row);
}
// Upsert the clinic's location (address + optional coordinates).
export async function setClinicLocation(
orgId: string,
location: ClinicLocation,
): Promise<ClinicSettingsView> {
const values = {
organizationId: orgId,
address: location.address,
city: location.city,
country: location.country,
latitude: location.latitude,
longitude: location.longitude,
};
const [row] = await db
.insert(clinicSettings)
.values(values)
.onConflictDoUpdate({
target: clinicSettings.organizationId,
set: {
address: values.address,
city: values.city,
country: values.country,
latitude: values.latitude,
longitude: values.longitude,
},
})
.returning();
return toView(row);
}
+9
View File
@@ -53,6 +53,8 @@ function toPatient(row: PatientRow, children: Children): Patient {
primaryProviderId: row.primaryProviderId,
status: row.status,
initials: row.initials,
phone: row.phone,
bloodType: row.bloodType,
allergies: children.allergies,
alerts: row.alerts,
medications: children.medications,
@@ -82,6 +84,8 @@ const EMPTY_TREND: Trend = { label: "", unit: "", points: [] };
function redactClinical(patient: Patient): Patient {
return {
...patient,
// bloodType is clinical PHI; phone is a demographic/contact field and stays.
bloodType: "",
allergies: [],
alerts: [],
medications: [],
@@ -116,6 +120,8 @@ function patientColumns(orgId: string, input: PatientInput, createdBy?: string)
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
bloodType: input.bloodType,
alerts: input.alerts,
vitalsBp: input.vitals.bp,
vitalsHr: input.vitals.hr,
@@ -147,6 +153,8 @@ function demographicColumns(
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
bloodType: "",
source: input.source,
alerts: [] as string[],
vitalsBp: "",
@@ -172,6 +180,7 @@ function demographicUpdateColumns(input: PatientInput) {
primaryProviderId: input.primaryProviderId ?? null,
status: input.status,
initials: input.initials,
phone: input.phone,
};
}
+24 -5
View File
@@ -41,8 +41,17 @@ export function sendToWallet(
hubs.get(orgId)?.emit("wallet:send", { walletNumber, event, data });
}
// Tell the relay to expect a device response for `requestId` and route it back
// to this clinic — used by **QR pairing**, where there's no wallet number to
// `wallet:send` to yet, so nothing would otherwise register the request.
export function expectResponse(orgId: string, requestId: string): void {
hubs.get(orgId)?.emit("hub:expect", { requestId });
}
// Open (and authenticate) a hub connection for a clinic, if not already open.
// Idempotent — safe to call on startup and again when an org joins the network.
// Idempotent — safe to call on startup, when an org joins the network, and
// before generating a pairing QR. The socket auto-reconnects on its own, so an
// existing entry is left as-is.
export async function connectOrg(orgId: string): Promise<void> {
if (hubs.has(orgId)) return;
@@ -92,11 +101,21 @@ function registerHubHandlers(orgId: string, hub: Socket): void {
// `token` is only meaningful for a private relay (optional shared gate);
// an empty value is ignored by an open relay.
{ clinicId: publicKey, signature, token: env.RELAY_TOKEN || undefined },
(ack: { ok?: boolean } | undefined) => {
if (ack?.ok) {
console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`);
} else {
async (ack: { ok?: boolean } | undefined) => {
if (!ack?.ok) {
console.warn(`Temetro Network: relay rejected clinic ${orgId}`);
return;
}
console.log(`Temetro Network: clinic ${orgId} authenticated on the relay`);
// The relay keeps routing state in memory, so re-register this clinic's
// still-pending requests — restores QR-pairing / share routing after a
// relay restart or a reconnect.
try {
for (const requestId of await walletShare.pendingRequestIds(orgId)) {
expectResponse(orgId, requestId);
}
} catch {
/* best-effort */
}
},
);
+16
View File
@@ -137,6 +137,22 @@ export async function listShareRequests(
return rows.map(toView);
}
// Ids of this clinic's still-pending share/pairing requests. Used to re-register
// them with the relay when the clinic's hub (re)connects (the relay keeps
// routing state in memory, so it's lost on a relay restart / redeploy).
export async function pendingRequestIds(orgId: string): Promise<string[]> {
const rows = await db
.select({ id: walletShareRequests.id })
.from(walletShareRequests)
.where(
and(
eq(walletShareRequests.organizationId, orgId),
eq(walletShareRequests.status, "pending"),
),
);
return rows.map((r) => r.id);
}
// Apply a response relayed back from the patient's device. On approval we
// decrypt the sealed bundle with the request's ephemeral private key and verify
// the wallet's Ed25519 signature over it (provenance: it really came from that
+2
View File
@@ -61,6 +61,8 @@ export type Patient = {
primaryProviderId?: string | null; // user id of the responsible clinician
status: PatientStatus;
initials: string; // for AvatarFallback
phone?: string; // contact number (demographic; visible to reception)
bloodType?: string; // e.g. "O+"; clinical — redacted for reception
allergies: Allergy[];
alerts: string[];
medications: Medication[];
+8 -2
View File
@@ -104,12 +104,18 @@ white/6%), so layered surfaces stay close in lightness.
## i18n
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, English resources in
`lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in
`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, resources in
`lib/i18n/locales/<lng>/translation.json`). `components/i18n-provider.tsx` wraps the app in
`app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`)
in **client** components. To add a language, drop a `locales/<lng>/translation.json` and register it
in `resources`/`supportedLngs` in `config.ts`.
> **Translate into EVERY locale, not just English.** The app ships multiple languages
> (`lib/i18n/locales/`: currently `en`, `de`, `fr`, `ar`, `so`). Whenever you add or rename a
> translation key, add it to **all** `locales/*/translation.json` files with a real translation for
> each language (not the English string copied over) — leaving a key in only `en/` ships a broken UI
> in the others. Keep the nested structure identical across every locale file.
**Coverage:** essentially all user-facing strings are now keyed (every app page + its dialogs/sheets,
auth pages, settings panels, the sidebar/user menu, chat input, patient cards/detail/form, messages,
notifications, notes). Keys are grouped by feature (`appointments.*`, `patientCard.*`, `messages.*`,
@@ -243,6 +243,11 @@ function SummaryCard({
label={t("patientCard.summary.allergies")}
value={patient.allergies.length || t("patientCard.summary.none")}
/>
<Stat
label={t("patientCard.summary.bloodType")}
value={patient.bloodType || "—"}
/>
<Stat label={t("patientCard.summary.phone")} value={patient.phone || "—"} />
</div>
<AlertBadges alerts={patient.alerts} />
{onEdit ? (
@@ -243,6 +243,8 @@ export function PatientFormDialog({
// per-doctor visibility), not free text. `providerId` is the selected user id.
const [providers, setProviders] = useState<Provider[]>([]);
const [providerId, setProviderId] = useState(patient?.primaryProviderId ?? "");
const [phone, setPhone] = useState(patient?.phone ?? "");
const [bloodType, setBloodType] = useState(patient?.bloodType ?? "");
const [bp, setBp] = useState(patient?.vitals.bp ?? "");
const [hr, setHr] = useState(patient?.vitals.hr ?? "");
const [temp, setTemp] = useState(patient?.vitals.temp ?? "");
@@ -310,6 +312,8 @@ export function PatientFormDialog({
primaryProviderId: providerId || null,
status,
initials: initialsFromName(name),
phone: phone.trim(),
bloodType,
allergies: allergies.filter((a) => a.substance.trim()),
alerts: patient?.alerts ?? [],
medications: medications.filter((m) => m.name.trim()),
@@ -515,6 +519,31 @@ export function PatientFormDialog({
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t("patientForm.phone")}>
<Input
inputMode="tel"
onChange={(event) => setPhone(event.target.value)}
placeholder={t("patientForm.phonePlaceholder")}
value={phone}
/>
</Field>
<Field label={t("patientForm.bloodType")}>
<select
className={controlClass}
onChange={(event) => setBloodType(event.target.value)}
value={bloodType}
>
<option value="">{t("patientForm.bloodTypeUnknown")}</option>
{["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"].map((bt) => (
<option key={bt} value={bt}>
{bt}
</option>
))}
</select>
</Field>
</div>
{showClinical && (
<>
<div className="flex flex-col gap-1.5">
@@ -141,6 +141,8 @@ export function ImportFromWalletDialog({
} catch (err) {
if (err instanceof ApiError && err.status === 400) {
setError(t("patients.importApp.invalidWallet"));
} else if (err instanceof ApiError && err.status === 409) {
setError(t("patients.importApp.networkOff"));
} else {
setError(t("patients.importApp.error"));
}
@@ -171,8 +173,12 @@ export function ImportFromWalletDialog({
setPairUri(`temetro-pair:?${params.toString()}`);
setRequest(pairing);
setPhase("waiting");
} catch {
setError(t("patients.importApp.error"));
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError(t("patients.importApp.networkOff"));
} else {
setError(t("patients.importApp.error"));
}
setPhase("error");
}
};
@@ -359,6 +359,14 @@ export function PatientDetail({
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
<Stat
label={t("patientCard.summary.bloodType")}
value={patient.bloodType || "—"}
/>
<Stat
label={t("patientCard.summary.phone")}
value={patient.phone || "—"}
/>
</div>
</Section>
@@ -13,6 +13,8 @@ import {
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { ClinicLocationSection } from "@/components/settings/settings-location";
import { PatientPortalSection } from "@/components/settings/settings-portal";
import {
getNetworkEnabled,
getSigningKey,
@@ -205,6 +207,10 @@ export function SigningPanel() {
</SettingsCard>
</SettingsSection>
<PatientPortalSection />
<ClinicLocationSection />
<SettingsSection
description={t("settings.signing.identityDescription")}
title={t("settings.signing.identityTitle")}
@@ -0,0 +1,211 @@
"use client";
import { LocateFixed } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
FieldLabel,
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { cn } from "@/lib/utils";
import { getClinicSettings, saveClinicLocation } from "@/lib/clinic";
import { notify } from "@/lib/toast";
// Parse a coordinate input into a number or null (empty ⇒ null). Returns
// `false` when the string is present but not a finite number, so we can flag it.
function parseCoord(value: string): number | null | false {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : false;
}
// Clinic location editor (owner/admin only — mounted inside the Signing panel).
// Persists the clinic's address + optional map coordinates so the wallet app can
// display the clinic location later.
export function ClinicLocationSection() {
const { t } = useTranslation();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [country, setCountry] = useState("");
const [latitude, setLatitude] = useState("");
const [longitude, setLongitude] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
useEffect(() => {
let active = true;
getClinicSettings()
.then((settings) => {
if (!active) return;
const loc = settings.location;
setAddress(loc.address);
setCity(loc.city);
setCountry(loc.country);
setLatitude(loc.latitude === null ? "" : String(loc.latitude));
setLongitude(loc.longitude === null ? "" : String(loc.longitude));
})
.catch(() => {
/* keep empty defaults */
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
// Fill the coordinates from the browser's geolocation (the clinician runs this
// on a device at the clinic). Client-only — no backend or map service.
const useMyLocation = () => {
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.geoUnsupported"),
);
return;
}
setLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6));
setLocating(false);
},
() => {
setLocating(false);
notify.error(
t("settings.location.errorTitle"),
t("settings.location.geoError"),
);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
};
const save = async () => {
const lat = parseCoord(latitude);
const lng = parseCoord(longitude);
if (lat === false || lng === false) {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.invalidCoords"),
);
return;
}
setSaving(true);
try {
await saveClinicLocation({
address: address.trim(),
city: city.trim(),
country: country.trim(),
latitude: lat,
longitude: lng,
});
notify.success(
t("settings.location.savedTitle"),
t("settings.location.savedBody"),
);
} catch {
notify.error(
t("settings.location.errorTitle"),
t("settings.location.error"),
);
} finally {
setSaving(false);
}
};
return (
<SettingsSection
description={t("settings.location.description")}
title={t("settings.location.title")}
>
<SettingsCard className="flex flex-col gap-4 p-5">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.address")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setAddress(e.target.value)}
placeholder={t("settings.location.addressPlaceholder")}
value={address}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.city")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setCity(e.target.value)}
value={city}
/>
</label>
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.country")}</FieldLabel>
<Input
disabled={loading}
onChange={(e) => setCountry(e.target.value)}
value={country}
/>
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.latitude")}</FieldLabel>
<Input
disabled={loading}
inputMode="decimal"
onChange={(e) => setLatitude(e.target.value)}
placeholder="e.g. 2.0469"
value={latitude}
/>
</label>
<label className="flex flex-col gap-1.5">
<FieldLabel>{t("settings.location.longitude")}</FieldLabel>
<Input
disabled={loading}
inputMode="decimal"
onChange={(e) => setLongitude(e.target.value)}
placeholder="e.g. 45.3182"
value={longitude}
/>
</label>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.location.coordinatesHint")}
</p>
<div className="flex flex-wrap gap-2">
<Button
className={cn("rounded-lg", whiteButton)}
disabled={loading || saving}
onClick={save}
type="button"
>
{saving
? t("settings.location.saving")
: t("settings.location.save")}
</Button>
<Button
className="rounded-lg"
disabled={loading || locating}
onClick={useMyLocation}
type="button"
variant="outline"
>
<LocateFixed className="size-4" />
{locating
? t("settings.location.locating")
: t("settings.location.useMyLocation")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
);
}
@@ -0,0 +1,99 @@
"use client";
import { ExternalLink, QrCode } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import {
CopyField,
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { authClient } from "@/lib/auth-client";
import { resolveBackendUrl } from "@/lib/backend-url";
import { cn } from "@/lib/utils";
// Patient Portal section (Settings → Signing): surfaces the clinic's public
// portal link so patients can open it, copy it, or scan a QR. The portal lives
// at /portal/<org-slug>; the QR also carries the backend base (`?api=`) so the
// patient wallet app can reach the JSON API when it scans the same code.
export function PatientPortalSection() {
const { t } = useTranslation();
const { data: activeOrg } = authClient.useActiveOrganization();
const [qrOpen, setQrOpen] = useState(false);
const slug = activeOrg?.slug;
const origin = typeof window !== "undefined" ? window.location.origin : "";
const portalUrl = slug ? `${origin}/portal/${slug}` : "";
const qrUrl = slug
? `${portalUrl}?api=${encodeURIComponent(resolveBackendUrl())}`
: "";
return (
<SettingsSection
description={t("settings.portal.description")}
title={t("settings.portal.title")}
>
<SettingsCard className="flex flex-col gap-4 p-5">
<CopyField
description={t("settings.portal.linkDescription")}
label={t("settings.portal.linkLabel")}
value={portalUrl || "—"}
/>
<div className="flex flex-wrap gap-2">
<Button
className={cn("rounded-lg", whiteButton)}
disabled={!portalUrl}
onClick={() => window.open(portalUrl, "_blank", "noopener")}
type="button"
>
<ExternalLink className="size-4" />
{t("settings.portal.open")}
</Button>
<Button
className="rounded-lg"
disabled={!qrUrl}
onClick={() => setQrOpen(true)}
type="button"
variant="outline"
>
<QrCode className="size-4" />
{t("settings.portal.showQr")}
</Button>
</div>
</SettingsCard>
<Dialog onOpenChange={setQrOpen} open={qrOpen}>
<DialogPopup className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t("settings.portal.qrTitle")}</DialogTitle>
<DialogDescription>
{t("settings.portal.qrDescription")}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col items-center gap-3 pb-2">
{qrUrl ? (
<div className="rounded-2xl bg-white p-4">
<QRCodeSvg value={qrUrl} size={220} />
</div>
) : null}
<p className="break-all text-center text-sm text-muted-foreground">
{portalUrl}
</p>
</DialogPanel>
</DialogPopup>
</Dialog>
</SettingsSection>
);
}
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useAiAccess } from "@/lib/ai-policy";
import { dirFor } from "@/lib/i18n/config";
import { useActiveRole, visibleNavItems } from "@/lib/roles";
import { motion } from "framer-motion";
import Image from "next/image";
@@ -27,7 +28,10 @@ import { useCallInvites } from "@/components/meetings/use-call-invites";
export function DashboardSidebar() {
const { state } = useSidebar();
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Anchor the sidebar to the right for RTL locales (Arabic) so the whole shell
// mirrors instead of leaving the fixed sidebar pinned physically left.
const side = dirFor(i18n.language) === "rtl" ? "right" : "left";
const role = useActiveRole();
const { allowed: aiAllowed } = useAiAccess();
const isCollapsed = state === "collapsed";
@@ -54,7 +58,7 @@ export function DashboardSidebar() {
}));
return (
<Sidebar variant="inset" collapsible="icon">
<Sidebar variant="inset" collapsible="icon" side={side}>
<SidebarHeader
className={cn(
"flex md:pt-3.5",
+1 -1
View File
@@ -19,7 +19,7 @@ export function Switch({
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block aspect-square h-full origin-left in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] data-checked:translate-x-[calc(var(--thumb-size)-4px)]",
"pointer-events-none block aspect-square h-full ltr:origin-left rtl:origin-right in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:not-data-disabled:scale-x-110 in-[[role=switch]:active,[data-slot=label]:active,[data-slot=field-label]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.1)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s] data-checked:origin-[var(--thumb-size)_50%] ltr:data-checked:translate-x-[calc(var(--thumb-size)-4px)] rtl:data-checked:-translate-x-[calc(var(--thumb-size)-4px)]",
)}
data-slot="switch-thumb"
/>
+33
View File
@@ -0,0 +1,33 @@
// Client for clinic-level (organization) settings — currently the clinic's
// location (Settings → Signing → Location). Calls the backend over the shared
// fetch wrapper (session cookie sent automatically).
import { apiFetch } from "@/lib/api-client";
export type ClinicLocation = {
address: string;
city: string;
country: string;
latitude: number | null;
longitude: number | null;
};
export type ClinicSettings = {
location: ClinicLocation;
};
// The clinic's settings. Readable by any clinician; returns empty defaults when
// nothing has been set yet.
export async function getClinicSettings(): Promise<ClinicSettings> {
return apiFetch<ClinicSettings>("/api/clinic/settings");
}
// Save the clinic's location (owner/admin only). Returns the updated settings.
export async function saveClinicLocation(
location: ClinicLocation,
): Promise<ClinicSettings> {
return apiFetch<ClinicSettings>("/api/clinic/location", {
method: "PUT",
body: JSON.stringify(location),
});
}
@@ -322,6 +322,7 @@
"expiredTitle": "انتهت صلاحية الطلب",
"expiredBody": "انتهت مهلة الطلب قبل أن يستجيب المريض.",
"invalidWallet": "لا يبدو هذا رقم محفظة صالحًا.",
"networkOff": "انضم أولاً إلى شبكة Temetro (الإعدادات ← التوقيع) للمشاركة مع تطبيقات المرضى.",
"errorTitle": "تعذّر الوصول إلى المحفظة",
"error": "يرجى التحقّق من رقم المحفظة والمحاولة مرة أخرى.",
"savedTitle": "تم استيراد المريض",
@@ -1462,6 +1463,8 @@
"allergies": "الحساسيات",
"activeMeds": "الأدوية النشطة",
"openProblems": "المشكلات المفتوحة",
"bloodType": "فصيلة الدم",
"phone": "الهاتف",
"none": "لا شيء",
"editRecord": "تعديل السجل"
},
@@ -1564,6 +1567,10 @@
"primaryCare": "الرعاية الأساسية",
"primaryCarePlaceholder": "مثال: د. لينا أورتيز",
"primaryCareUnassigned": "غير مُعيّن",
"phone": "الهاتف",
"phonePlaceholder": "مثال: +1 555 010 2938",
"bloodType": "فصيلة الدم",
"bloodTypeUnknown": "غير معروف",
"currentVitals": "العلامات الحيوية الحالية",
"bp": "ضغط الدم",
"hr": "معدل ضربات القلب",
@@ -2010,6 +2017,28 @@
"errorTitle": "تعذّر تحديث الوصول إلى الشبكة",
"error": "يرجى المحاولة مرة أخرى."
},
"location": {
"title": "موقع العيادة",
"description": "عنوان عيادتك وإحداثيات الخريطة. تظهر للمرضى في تطبيق المحفظة.",
"address": "العنوان",
"addressPlaceholder": "عنوان الشارع",
"city": "المدينة",
"country": "الدولة",
"latitude": "خط العرض",
"longitude": "خط الطول",
"coordinatesHint": "الإحداثيات اختيارية — تُستخدم لعرض عيادتك على الخريطة.",
"save": "حفظ الموقع",
"saving": "جارٍ الحفظ…",
"savedTitle": "تم حفظ الموقع",
"savedBody": "تم تحديث موقع عيادتك.",
"invalidCoords": "يجب أن يكون خطا العرض والطول أرقامًا.",
"errorTitle": "تعذّر حفظ الموقع",
"error": "يرجى المحاولة مرة أخرى.",
"useMyLocation": "استخدام موقعي الحالي",
"locating": "جارٍ تحديد الموقع…",
"geoUnsupported": "الموقع غير متاح على هذا الجهاز.",
"geoError": "تعذّر الحصول على موقعك. تحقّق من الأذونات وحاول مرة أخرى."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
"active": "نشط",
@@ -2120,6 +2149,16 @@
"savedBody": "تم تحديث تهيئة الذكاء الاصطناعي الخاصة بك.",
"saveFailedTitle": "تعذّر الحفظ",
"saveFailedBody": "فشل حفظ إعدادات الذكاء الاصطناعي. يرجى المحاولة مرة أخرى."
},
"portal": {
"title": "بوابة المريض",
"description": "شارك بوابة الحجز العامة لعيادتك مع المرضى — يمكنهم رؤية أطبائك وحجز المواعيد.",
"linkLabel": "رابط البوابة",
"linkDescription": "الصفحة العامة التي يفتحها المرضى للحجز في عيادتك.",
"open": "فتح البوابة",
"showQr": "عرض رمز QR",
"qrTitle": "رمز QR لبوابة المريض",
"qrDescription": "يمسح المرضى هذا لفتح بوابة عيادتك — أو يمسحونه في تطبيق محفظة Temetro للحجز."
}
},
"portal": {
@@ -310,6 +310,7 @@
"expiredTitle": "Anfrage abgelaufen",
"expiredBody": "Die Anfrage ist abgelaufen, bevor der Patient geantwortet hat.",
"invalidWallet": "Das sieht nicht nach einer gültigen Wallet-Nummer aus.",
"networkOff": "Treten Sie zuerst dem Temetro-Netzwerk bei (Einstellungen → Signierung), um mit Patienten-Apps zu teilen.",
"errorTitle": "Wallet nicht erreichbar",
"error": "Bitte prüfen Sie die Wallet-Nummer und versuchen Sie es erneut.",
"savedTitle": "Patient importiert",
@@ -1442,6 +1443,8 @@
"allergies": "Allergien",
"activeMeds": "Aktive Medikamente",
"openProblems": "Offene Probleme",
"bloodType": "Blutgruppe",
"phone": "Telefon",
"none": "Keine",
"editRecord": "Datensatz bearbeiten"
},
@@ -1544,6 +1547,10 @@
"primaryCare": "Primärversorgung",
"primaryCarePlaceholder": "z. B. Dr. Lena Ortiz",
"primaryCareUnassigned": "Nicht zugewiesen",
"phone": "Telefon",
"phonePlaceholder": "z. B. +1 555 010 2938",
"bloodType": "Blutgruppe",
"bloodTypeUnknown": "Unbekannt",
"currentVitals": "Aktuelle Vitalwerte",
"bp": "Blutdruck",
"hr": "Herzfrequenz",
@@ -1990,6 +1997,28 @@
"errorTitle": "Netzwerkzugriff konnte nicht aktualisiert werden",
"error": "Bitte versuchen Sie es erneut."
},
"location": {
"title": "Standort der Klinik",
"description": "Adresse und Kartenkoordinaten Ihrer Klinik. Wird Patienten in der Wallet-App angezeigt.",
"address": "Adresse",
"addressPlaceholder": "Straße und Hausnummer",
"city": "Stadt",
"country": "Land",
"latitude": "Breitengrad",
"longitude": "Längengrad",
"coordinatesHint": "Koordinaten sind optional um Ihre Klinik auf einer Karte anzuzeigen.",
"save": "Standort speichern",
"saving": "Wird gespeichert …",
"savedTitle": "Standort gespeichert",
"savedBody": "Der Standort Ihrer Klinik wurde aktualisiert.",
"invalidCoords": "Breiten- und Längengrad müssen Zahlen sein.",
"errorTitle": "Standort konnte nicht gespeichert werden",
"error": "Bitte versuchen Sie es erneut.",
"useMyLocation": "Aktuellen Standort verwenden",
"locating": "Standort wird ermittelt …",
"geoUnsupported": "Standort ist auf diesem Gerät nicht verfügbar.",
"geoError": "Standort konnte nicht ermittelt werden. Bitte Berechtigungen prüfen und erneut versuchen."
},
"signing": {
"keyTitle": "Signierschlüssel",
"active": "Aktiv",
@@ -2100,6 +2129,16 @@
"savedBody": "Ihre KI-Konfiguration wurde aktualisiert.",
"saveFailedTitle": "Speichern fehlgeschlagen",
"saveFailedBody": "Das Speichern Ihrer KI-Einstellungen ist fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"portal": {
"title": "Patientenportal",
"description": "Teilen Sie das öffentliche Buchungsportal Ihrer Klinik mit Patienten sie können Ihre Ärzte sehen und Termine buchen.",
"linkLabel": "Portal-Link",
"linkDescription": "Die öffentliche Seite, die Patienten zum Buchen bei Ihrer Klinik öffnen.",
"open": "Portal öffnen",
"showQr": "QR-Code anzeigen",
"qrTitle": "Patientenportal-QR",
"qrDescription": "Patienten scannen dies, um das Portal Ihrer Klinik zu öffnen oder scannen es in der Temetro-Wallet-App, um zu buchen."
}
},
"portal": {
@@ -310,6 +310,7 @@
"expiredTitle": "Request expired",
"expiredBody": "The request timed out before the patient responded.",
"invalidWallet": "That doesn't look like a valid wallet number.",
"networkOff": "Join the Temetro Network first (Settings → Signing) to share with patient apps.",
"errorTitle": "Couldn't reach the wallet",
"error": "Please check the wallet number and try again.",
"savedTitle": "Patient imported",
@@ -1442,6 +1443,8 @@
"allergies": "Allergies",
"activeMeds": "Active meds",
"openProblems": "Open problems",
"bloodType": "Blood type",
"phone": "Phone",
"none": "None",
"editRecord": "Edit record"
},
@@ -1544,6 +1547,10 @@
"primaryCare": "Primary care",
"primaryCarePlaceholder": "e.g. Dr. Lena Ortiz",
"primaryCareUnassigned": "Unassigned",
"phone": "Phone",
"phonePlaceholder": "e.g. +1 555 010 2938",
"bloodType": "Blood type",
"bloodTypeUnknown": "Unknown",
"currentVitals": "Current vitals",
"bp": "Blood pressure",
"hr": "Heart rate",
@@ -1990,6 +1997,28 @@
"errorTitle": "Couldn't update network access",
"error": "Please try again."
},
"location": {
"title": "Clinic location",
"description": "Your clinic's address and map coordinates. Shown to patients in the wallet app.",
"address": "Address",
"addressPlaceholder": "Street address",
"city": "City",
"country": "Country",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Coordinates are optional — used to show your clinic on a map.",
"save": "Save location",
"saving": "Saving…",
"savedTitle": "Location saved",
"savedBody": "Your clinic location has been updated.",
"invalidCoords": "Latitude and longitude must be numbers.",
"errorTitle": "Couldn't save location",
"error": "Please try again.",
"useMyLocation": "Use my current location",
"locating": "Locating…",
"geoUnsupported": "Location isn't available on this device.",
"geoError": "Couldn't get your location. Check permissions and try again."
},
"signing": {
"keyTitle": "Signing key",
"active": "Active",
@@ -2100,6 +2129,16 @@
"savedBody": "Your AI configuration has been updated.",
"saveFailedTitle": "Could not save",
"saveFailedBody": "Saving your AI settings failed. Please try again."
},
"portal": {
"title": "Patient Portal",
"description": "Share your clinic's public booking portal with patients — they can view your doctors and book appointments.",
"linkLabel": "Portal link",
"linkDescription": "The public page patients open to book with your clinic.",
"open": "Open portal",
"showQr": "Show QR code",
"qrTitle": "Patient Portal QR",
"qrDescription": "Patients scan this to open your clinic's portal — or scan it in the Temetro wallet app to book."
}
},
"portal": {
@@ -310,6 +310,7 @@
"expiredTitle": "Demande expirée",
"expiredBody": "La demande a expiré avant que le patient ne réponde.",
"invalidWallet": "Cela ne ressemble pas à un numéro de portefeuille valide.",
"networkOff": "Rejoignez d'abord le Réseau Temetro (Paramètres → Signature) pour partager avec les applications des patients.",
"errorTitle": "Impossible de joindre le portefeuille",
"error": "Veuillez vérifier le numéro de portefeuille et réessayer.",
"savedTitle": "Patient importé",
@@ -1442,6 +1443,8 @@
"allergies": "Allergies",
"activeMeds": "Médicaments actifs",
"openProblems": "Problèmes ouverts",
"bloodType": "Groupe sanguin",
"phone": "Téléphone",
"none": "Aucun",
"editRecord": "Modifier le dossier"
},
@@ -1544,6 +1547,10 @@
"primaryCare": "Praticien référent",
"primaryCarePlaceholder": "ex. Dr Lena Ortiz",
"primaryCareUnassigned": "Non attribué",
"phone": "Téléphone",
"phonePlaceholder": "p. ex. +1 555 010 2938",
"bloodType": "Groupe sanguin",
"bloodTypeUnknown": "Inconnu",
"currentVitals": "Signes vitaux actuels",
"bp": "Tension artérielle",
"hr": "Fréquence cardiaque",
@@ -1990,6 +1997,28 @@
"errorTitle": "Impossible de mettre à jour l'accès au réseau",
"error": "Veuillez réessayer."
},
"location": {
"title": "Emplacement de la clinique",
"description": "L'adresse et les coordonnées cartographiques de votre clinique. Affichées aux patients dans l'application wallet.",
"address": "Adresse",
"addressPlaceholder": "Adresse postale",
"city": "Ville",
"country": "Pays",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Les coordonnées sont facultatives — utilisées pour afficher votre clinique sur une carte.",
"save": "Enregistrer l'emplacement",
"saving": "Enregistrement…",
"savedTitle": "Emplacement enregistré",
"savedBody": "L'emplacement de votre clinique a été mis à jour.",
"invalidCoords": "La latitude et la longitude doivent être des nombres.",
"errorTitle": "Impossible d'enregistrer l'emplacement",
"error": "Veuillez réessayer.",
"useMyLocation": "Utiliser ma position actuelle",
"locating": "Localisation…",
"geoUnsupported": "La localisation n'est pas disponible sur cet appareil.",
"geoError": "Impossible d'obtenir votre position. Vérifiez les autorisations et réessayez."
},
"signing": {
"keyTitle": "Clé de signature",
"active": "Active",
@@ -2100,6 +2129,16 @@
"savedBody": "Votre configuration d'IA a été mise à jour.",
"saveFailedTitle": "Impossible d'enregistrer",
"saveFailedBody": "L'enregistrement de vos paramètres d'IA a échoué. Veuillez réessayer."
},
"portal": {
"title": "Portail patient",
"description": "Partagez le portail de réservation public de votre clinique avec les patients — ils peuvent voir vos médecins et prendre rendez-vous.",
"linkLabel": "Lien du portail",
"linkDescription": "La page publique que les patients ouvrent pour réserver avec votre clinique.",
"open": "Ouvrir le portail",
"showQr": "Afficher le QR code",
"qrTitle": "QR du portail patient",
"qrDescription": "Les patients le scannent pour ouvrir le portail de votre clinique — ou le scannent dans l'application Temetro pour réserver."
}
},
"portal": {
@@ -310,6 +310,7 @@
"expiredTitle": "Codsiga waa dhacay",
"expiredBody": "Codsiga wuu dhacay ka hor inta uusan bukaanku ka jawaabin.",
"invalidWallet": "Taasi uma muuqato lambar wallet oo sax ah.",
"networkOff": "Marka hore ku biir Shabakadda Temetro (Dejinta → Saxiixa) si aad ula wadaagto abaabulka bukaannada.",
"errorTitle": "Wallet-ka lama gaari karin",
"error": "Fadlan hubi lambarka wallet-ka oo isku day mar kale.",
"savedTitle": "Bukaanka waa la soo dejiyay",
@@ -1442,6 +1443,8 @@
"allergies": "Xasaasiyadaha",
"activeMeds": "Daawooyin firfircoon",
"openProblems": "Dhibaatooyin furan",
"bloodType": "Nooca dhiigga",
"phone": "Taleefanka",
"none": "Midna",
"editRecord": "Wax ka beddel diiwaanka"
},
@@ -1544,6 +1547,10 @@
"primaryCare": "Daryeelka aasaasiga ah",
"primaryCarePlaceholder": "tusaale Dr. Lena Ortiz",
"primaryCareUnassigned": "Aan la qoondayn",
"phone": "Taleefanka",
"phonePlaceholder": "tusaale: +1 555 010 2938",
"bloodType": "Nooca dhiigga",
"bloodTypeUnknown": "Aan la garanayn",
"currentVitals": "Calaamadaha muhiimka ah ee hadda",
"bp": "Cadaadiska dhiigga",
"hr": "Garaaca wadnaha",
@@ -1990,6 +1997,28 @@
"errorTitle": "Lama cusboonaysiin karin gelitaanka shabakadda",
"error": "Fadlan isku day mar kale."
},
"location": {
"title": "Goobta rugta caafimaadka",
"description": "Cinwaanka iyo isutagga khariidadda rugtaada. Waxaa loo tusayaa bukaannada app-ka wallet-ka.",
"address": "Cinwaanka",
"addressPlaceholder": "Cinwaanka waddada",
"city": "Magaalada",
"country": "Dalka",
"latitude": "Latitude",
"longitude": "Longitude",
"coordinatesHint": "Isutaggu waa ikhtiyaari — waxaa loo isticmaalaa in rugtaada lagu tuso khariidad.",
"save": "Kaydi goobta",
"saving": "Waa la kaydinayaa…",
"savedTitle": "Goobta waa la kaydiyay",
"savedBody": "Goobta rugtaada waa la cusboonaysiiyay.",
"invalidCoords": "Latitude iyo longitude waa inay noqdaan tirooyin.",
"errorTitle": "Lama kaydin karin goobta",
"error": "Fadlan mar kale isku day.",
"useMyLocation": "Isticmaal goobtayda hadda",
"locating": "Waa la helayaa goobta…",
"geoUnsupported": "Goobta laguma heli karo qalabkan.",
"geoError": "Lama heli karin goobtaada. Hubi oggolaanshaha oo isku day mar kale."
},
"signing": {
"keyTitle": "Furaha saxiixa",
"active": "Firfircoon",
@@ -2100,6 +2129,16 @@
"savedBody": "Qaabaynta AI-gaaga waa la cusbooneysiiyay.",
"saveFailedTitle": "Lama kaydin karin",
"saveFailedBody": "Kaydinta dejinta AI waa fashilantay. Fadlan isku day mar kale."
},
"portal": {
"title": "Boggaga Bukaanka",
"description": "La wadaag bukaannada boggaga ballanqaadka guud ee rugtaada — waxay arki karaan dhakhtarradaada oo ballan qaadan karaan.",
"linkLabel": "Linkiga boggaga",
"linkDescription": "Bogga guud ee bukaannadu furaan si ay ballan ugu qaataan rugtaada.",
"open": "Fur boggaga",
"showQr": "Muuji koodhka QR",
"qrTitle": "Koodhka QR ee Boggaga Bukaanka",
"qrDescription": "Bukaannadu waxay tan sawiraan si ay u furaan boggaga rugtaada — ama ku sawir abka Temetro wallet si aad ballan u qaadato."
}
},
"portal": {
+2
View File
@@ -66,6 +66,8 @@ export type Patient = {
primaryProviderId?: string | null; // user id of the responsible clinician
status: "active" | "inpatient" | "discharged";
initials: string; // for AvatarFallback
phone?: string; // contact number (demographic; visible to reception)
bloodType?: string; // e.g. "O+"; clinical — redacted for reception
allergies: Allergy[];
alerts: string[];
medications: Medication[];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.8.0",
"version": "0.10.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.8.0",
"version": "0.10.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"