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>
This commit is contained in:
Khalid Abdi
2026-07-07 19:31:20 +03:00
parent 36461a5498
commit ab17978b5c
15 changed files with 331 additions and 15 deletions
+29
View File
@@ -7,6 +7,35 @@ for how releases are cut and published.
## [Unreleased]
## [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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.9.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.",
+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",
});
@@ -14,6 +14,7 @@ import {
whiteButton,
} from "@/components/settings/settings-parts";
import { ClinicLocationSection } from "@/components/settings/settings-location";
import { PatientPortalSection } from "@/components/settings/settings-portal";
import {
getNetworkEnabled,
getSigningKey,
@@ -206,6 +207,8 @@ export function SigningPanel() {
</SettingsCard>
</SettingsSection>
<PatientPortalSection />
<ClinicLocationSection />
<SettingsSection
@@ -1,5 +1,6 @@
"use client";
import { LocateFixed } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -36,6 +37,7 @@ export function ClinicLocationSection() {
const [longitude, setLongitude] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
useEffect(() => {
let active = true;
@@ -60,6 +62,34 @@ export function ClinicLocationSection() {
};
}, []);
// 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);
@@ -151,7 +181,7 @@ export function ClinicLocationSection() {
<p className="text-xs text-muted-foreground">
{t("settings.location.coordinatesHint")}
</p>
<div>
<div className="flex flex-wrap gap-2">
<Button
className={cn("rounded-lg", whiteButton)}
disabled={loading || saving}
@@ -162,6 +192,18 @@ export function ClinicLocationSection() {
? 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"
/>
+15 -1
View File
@@ -2033,7 +2033,11 @@
"savedBody": "تم تحديث موقع عيادتك.",
"invalidCoords": "يجب أن يكون خطا العرض والطول أرقامًا.",
"errorTitle": "تعذّر حفظ الموقع",
"error": "يرجى المحاولة مرة أخرى."
"error": "يرجى المحاولة مرة أخرى.",
"useMyLocation": "استخدام موقعي الحالي",
"locating": "جارٍ تحديد الموقع…",
"geoUnsupported": "الموقع غير متاح على هذا الجهاز.",
"geoError": "تعذّر الحصول على موقعك. تحقّق من الأذونات وحاول مرة أخرى."
},
"signing": {
"keyTitle": "مفتاح التوقيع",
@@ -2145,6 +2149,16 @@
"savedBody": "تم تحديث تهيئة الذكاء الاصطناعي الخاصة بك.",
"saveFailedTitle": "تعذّر الحفظ",
"saveFailedBody": "فشل حفظ إعدادات الذكاء الاصطناعي. يرجى المحاولة مرة أخرى."
},
"portal": {
"title": "بوابة المريض",
"description": "شارك بوابة الحجز العامة لعيادتك مع المرضى — يمكنهم رؤية أطبائك وحجز المواعيد.",
"linkLabel": "رابط البوابة",
"linkDescription": "الصفحة العامة التي يفتحها المرضى للحجز في عيادتك.",
"open": "فتح البوابة",
"showQr": "عرض رمز QR",
"qrTitle": "رمز QR لبوابة المريض",
"qrDescription": "يمسح المرضى هذا لفتح بوابة عيادتك — أو يمسحونه في تطبيق محفظة Temetro للحجز."
}
},
"portal": {
+15 -1
View File
@@ -2013,7 +2013,11 @@
"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."
"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",
@@ -2125,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": {
+15 -1
View File
@@ -2013,7 +2013,11 @@
"savedBody": "Your clinic location has been updated.",
"invalidCoords": "Latitude and longitude must be numbers.",
"errorTitle": "Couldn't save location",
"error": "Please try again."
"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",
@@ -2125,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": {
+15 -1
View File
@@ -2013,7 +2013,11 @@
"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."
"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",
@@ -2125,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": {
+15 -1
View File
@@ -2013,7 +2013,11 @@
"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."
"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",
@@ -2125,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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"