mirror of
https://github.com/temetro/temetro.git
synced 2026-08-06 08:57:41 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user