frontend: relay-based Patient Portal QR + kiosk "Link wallet" option

- lib/portal.ts: getPortalLink + portalPairingUri build a temetro-portal: URI
  (relay URL + clinic signing key) instead of a localhost API URL.
- Signing settings QR now encodes that pairing URI, so a real phone can reach
  the clinic over the Temetro Network relay (fixes "server cannot be accessed").
- Portal kiosk gains a third "Link my wallet" option that shows the same QR.
- New portal.choose.wallet* / portal.wallet.* keys in all 5 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-09 00:06:04 +03:00
parent dd64d689c8
commit 947623a691
8 changed files with 151 additions and 16 deletions
+54 -1
View File
@@ -8,9 +8,11 @@ import {
ChevronRight,
FlaskConical,
Loader2,
Smartphone,
} from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
import { Button } from "@/components/ui/button";
import {
@@ -25,13 +27,15 @@ import {
bookPortalAppointment,
createPortalPatient,
getPortalClinic,
getPortalLink,
lookupPortalResults,
portalPairingUri,
type PortalBookingResult,
type PortalResults,
} from "@/lib/portal";
import { cn } from "@/lib/utils";
type Step = "choose" | "book" | "results";
type Step = "choose" | "book" | "results" | "wallet";
const todayKey = () => new Date().toISOString().slice(0, 10);
@@ -93,6 +97,8 @@ export function PortalKiosk({ clinic }: { clinic: string }) {
<ChooseStep onPick={setStep} />
) : step === "book" ? (
<BookStep clinic={clinic} onBack={() => setStep("choose")} />
) : step === "wallet" ? (
<WalletStep clinic={clinic} onBack={() => setStep("choose")} />
) : (
<ResultsStep clinic={clinic} onBack={() => setStep("choose")} />
)}
@@ -117,6 +123,12 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
title: t("portal.choose.resultsTitle"),
desc: t("portal.choose.resultsDesc"),
},
{
step: "wallet",
icon: <Smartphone className="size-7" />,
title: t("portal.choose.walletTitle"),
desc: t("portal.choose.walletDesc"),
},
];
return (
<div className="grid w-full gap-4 sm:grid-cols-2">
@@ -157,6 +169,47 @@ function BackButton({ onBack }: { onBack: () => void }) {
);
}
// Show a QR the patient scans with the temetro wallet app to link it to this
// clinic over the Temetro Network relay. After linking, the app can book
// appointments and view/download results itself, syncing back to the clinic.
function WalletStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
const { t } = useTranslation();
const [uri, setUri] = useState<string | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
getPortalLink(clinic)
.then((link) => active && setUri(portalPairingUri(link)))
.catch(() => active && setError(true));
return () => {
active = false;
};
}, [clinic]);
return (
<div className="flex w-full flex-col items-center gap-5">
<BackButton onBack={onBack} />
<h2 className="font-semibold text-xl">{t("portal.wallet.title")}</h2>
<p className="max-w-md text-center text-muted-foreground text-sm">
{t("portal.wallet.subtitle")}
</p>
{uri ? (
<div className="rounded-2xl bg-white p-4">
<QRCodeSvg value={uri} size={232} />
</div>
) : error ? (
<p className="text-destructive text-sm">{t("portal.wallet.error")}</p>
) : (
<Loader2 className="size-6 animate-spin text-muted-foreground" />
)}
<p className="max-w-md text-center text-muted-foreground text-xs">
{t("portal.wallet.hint")}
</p>
</div>
);
}
function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
const { t } = useTranslation();
// "returning" = has a file number; "new" = register first, then book.
@@ -1,7 +1,7 @@
"use client";
import { ExternalLink, QrCode } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import QRCodeSvg from "react-qr-code";
@@ -21,24 +21,42 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { authClient } from "@/lib/auth-client";
import { resolveBackendUrl } from "@/lib/backend-url";
import { getPortalLink, portalPairingUri } from "@/lib/portal";
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.
// at /portal/<org-slug>. The QR encodes a `temetro-portal:` pairing URI (relay
// URL + clinic signing key) — the wallet app scans it and talks to this clinic
// over the Temetro Network relay, so it works from a real phone (no localhost).
export function PatientPortalSection() {
const { t } = useTranslation();
const { data: activeOrg } = authClient.useActiveOrganization();
const [qrOpen, setQrOpen] = useState(false);
const [qrUri, setQrUri] = useState("");
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())}`
: "";
// Fetch the relay-based pairing descriptor for the QR (non-secret).
useEffect(() => {
if (!slug) {
setQrUri("");
return;
}
let active = true;
getPortalLink(slug)
.then((link) => {
if (active) setQrUri(portalPairingUri(link));
})
.catch(() => {
if (active) setQrUri("");
});
return () => {
active = false;
};
}, [slug]);
return (
<SettingsSection
@@ -63,7 +81,7 @@ export function PatientPortalSection() {
</Button>
<Button
className="rounded-lg"
disabled={!qrUrl}
disabled={!qrUri}
onClick={() => setQrOpen(true)}
type="button"
variant="outline"
@@ -83,9 +101,9 @@ export function PatientPortalSection() {
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col items-center gap-3 pb-2">
{qrUrl ? (
{qrUri ? (
<div className="rounded-2xl bg-white p-4">
<QRCodeSvg value={qrUrl} size={220} />
<QRCodeSvg value={qrUri} size={220} />
</div>
) : null}
<p className="break-all text-center text-sm text-muted-foreground">
@@ -2172,7 +2172,15 @@
"bookTitle": "حجز موعد",
"bookDesc": "جدول زيارة مع فريق الرعاية الخاص بك.",
"resultsTitle": "عرض نتائجي",
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة."
"resultsDesc": "تحقّق من الزيارات القادمة وما إذا كانت النتائج جاهزة.",
"walletTitle": "ربط محفظتي",
"walletDesc": "استخدم تطبيق temetro لحجز المواعيد وعرض النتائج بنفسك."
},
"wallet": {
"title": "اربط محفظتك",
"subtitle": "امسح هذا الرمز بتطبيق محفظة temetro لربطه بهذه العيادة.",
"error": "تعذّر تحميل رمز الربط. الرجاء سؤال مكتب الاستقبال.",
"hint": "بعد الربط، يمكنك حجز المواعيد وعرض النتائج من هاتفك."
},
"field": {
"name": "الاسم الكامل",
@@ -2152,7 +2152,15 @@
"bookTitle": "Einen Termin buchen",
"bookDesc": "Vereinbaren Sie einen Besuch bei Ihrem Behandlungsteam.",
"resultsTitle": "Meine Ergebnisse ansehen",
"resultsDesc": "Prüfen Sie bevorstehende Besuche und ob Ergebnisse bereit sind."
"resultsDesc": "Prüfen Sie bevorstehende Besuche und ob Ergebnisse bereit sind.",
"walletTitle": "Wallet verknüpfen",
"walletDesc": "Nutzen Sie die temetro-App, um selbst zu buchen und Ergebnisse zu sehen."
},
"wallet": {
"title": "Wallet verknüpfen",
"subtitle": "Scannen Sie diesen Code mit der temetro-Wallet-App, um sie mit dieser Klinik zu verbinden.",
"error": "Der Kopplungscode konnte nicht geladen werden. Bitte fragen Sie an der Rezeption.",
"hint": "Nach der Verknüpfung können Sie Termine buchen und Ergebnisse auf Ihrem Telefon ansehen."
},
"field": {
"name": "Vollständiger Name",
@@ -2152,7 +2152,15 @@
"bookTitle": "Book an appointment",
"bookDesc": "Schedule a visit with your care team.",
"resultsTitle": "View my results",
"resultsDesc": "Check upcoming visits and whether results are ready."
"resultsDesc": "Check upcoming visits and whether results are ready.",
"walletTitle": "Link my wallet",
"walletDesc": "Use the temetro app to book and view results yourself."
},
"wallet": {
"title": "Link your wallet",
"subtitle": "Scan this code with the temetro wallet app to connect it to this clinic.",
"error": "Couldn't load the pairing code. Please ask the front desk.",
"hint": "After linking, you can book appointments and view results from your phone."
},
"field": {
"name": "Full name",
@@ -2152,7 +2152,15 @@
"bookTitle": "Prendre un rendez-vous",
"bookDesc": "Planifiez une visite avec votre équipe soignante.",
"resultsTitle": "Voir mes résultats",
"resultsDesc": "Consultez les prochaines visites et si les résultats sont prêts."
"resultsDesc": "Consultez les prochaines visites et si les résultats sont prêts.",
"walletTitle": "Lier mon portefeuille",
"walletDesc": "Utilisez l'application temetro pour réserver et voir vos résultats vous-même."
},
"wallet": {
"title": "Lier votre portefeuille",
"subtitle": "Scannez ce code avec l'application portefeuille temetro pour la connecter à cette clinique.",
"error": "Impossible de charger le code d'association. Veuillez demander à l'accueil.",
"hint": "Une fois lié, vous pourrez prendre rendez-vous et consulter vos résultats depuis votre téléphone."
},
"field": {
"name": "Nom complet",
@@ -2152,7 +2152,15 @@
"bookTitle": "Ballan qabso",
"bookDesc": "Qorshee booqasho kooxdaada daryeelka.",
"resultsTitle": "Eeg natiijooyinkayga",
"resultsDesc": "Hubi booqashooyinka soo socda iyo haddii natiijooyinku diyaar yihiin."
"resultsDesc": "Hubi booqashooyinka soo socda iyo haddii natiijooyinku diyaar yihiin.",
"walletTitle": " Isku xir walletkayga",
"walletDesc": "Isticmaal abka temetro si aad adigu u ballansato oo aad u aragto natiijooyinka."
},
"wallet": {
"title": "Isku xir walletkaaga",
"subtitle": "Ku sawir koodhkan abka wallet-ka temetro si aad ugu xidho rugtan caafimaad.",
"error": "Lama soo rari karin koodhka isku xirka. Fadlan weydii miiska hore.",
"hint": "Kadib xirka, waxaad ballan ka samaysan kartaa oo natiijooyinka ka arki kartaa taleefankaaga."
},
"field": {
"name": "Magaca oo dhan",
+24
View File
@@ -65,6 +65,30 @@ export function getPortalClinic(clinic: string): Promise<PortalClinic> {
return portalFetch<PortalClinic>(`/${encodeURIComponent(clinic)}`);
}
// The relay-based pairing descriptor the wallet app scans: the clinic's signing
// public key (relay routing id) + the relay URL. Non-secret values.
export type PortalLink = {
clinicId: string;
relay: string;
slug: string;
name: string;
};
export function getPortalLink(clinic: string): Promise<PortalLink> {
return portalFetch<PortalLink>(`/${encodeURIComponent(clinic)}/link`);
}
// Build the `temetro-portal:` URI the wallet app scans to reach this clinic over
// the Temetro Network relay (no localhost API URL — works from a real phone).
export function portalPairingUri(link: PortalLink): string {
const params = new URLSearchParams({
relay: link.relay,
clinic: link.clinicId,
slug: link.slug,
});
return `temetro-portal:?${params.toString()}`;
}
export type PortalNewPatient = {
name: string;
sex?: string;