diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f18a5c..fe1bae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/package.json b/backend/package.json index 42a88ea..6fd073c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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.", diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index 453cd57..91ad1a8 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -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", }); diff --git a/frontend/components/settings/settings-billing.tsx b/frontend/components/settings/settings-billing.tsx index d441a42..e7aa4a7 100644 --- a/frontend/components/settings/settings-billing.tsx +++ b/frontend/components/settings/settings-billing.tsx @@ -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() { + + { 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() {

{t("settings.location.coordinatesHint")}

-
+
diff --git a/frontend/components/settings/settings-portal.tsx b/frontend/components/settings/settings-portal.tsx new file mode 100644 index 0000000..8148817 --- /dev/null +++ b/frontend/components/settings/settings-portal.tsx @@ -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/; 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 ( + + + +
+ + +
+
+ + + + + {t("settings.portal.qrTitle")} + + {t("settings.portal.qrDescription")} + + + + {qrUrl ? ( +
+ +
+ ) : null} +

+ {portalUrl} +

+
+
+
+
+ ); +} diff --git a/frontend/components/sidebar-02/app-sidebar.tsx b/frontend/components/sidebar-02/app-sidebar.tsx index 723c98a..5a7b904 100644 --- a/frontend/components/sidebar-02/app-sidebar.tsx +++ b/frontend/components/sidebar-02/app-sidebar.tsx @@ -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 ( - + diff --git a/frontend/lib/i18n/locales/ar/translation.json b/frontend/lib/i18n/locales/ar/translation.json index 997c018..3aa53a0 100644 --- a/frontend/lib/i18n/locales/ar/translation.json +++ b/frontend/lib/i18n/locales/ar/translation.json @@ -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": { diff --git a/frontend/lib/i18n/locales/de/translation.json b/frontend/lib/i18n/locales/de/translation.json index d7044b7..6dfa832 100644 --- a/frontend/lib/i18n/locales/de/translation.json +++ b/frontend/lib/i18n/locales/de/translation.json @@ -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": { diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 46c07b0..9ea1493 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -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": { diff --git a/frontend/lib/i18n/locales/fr/translation.json b/frontend/lib/i18n/locales/fr/translation.json index be0cd88..f273b40 100644 --- a/frontend/lib/i18n/locales/fr/translation.json +++ b/frontend/lib/i18n/locales/fr/translation.json @@ -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": { diff --git a/frontend/lib/i18n/locales/so/translation.json b/frontend/lib/i18n/locales/so/translation.json index 578ed24..7c8943a 100644 --- a/frontend/lib/i18n/locales/so/translation.json +++ b/frontend/lib/i18n/locales/so/translation.json @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index 533b5c7..4e68314 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.9.0", + "version": "0.10.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index ca80788..530e001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "temetro", - "version": "0.9.0", + "version": "0.10.0", "private": true, "devDependencies": { "shadcn": "^4.11.0"