diff --git a/frontend/components/portal/portal-kiosk.tsx b/frontend/components/portal/portal-kiosk.tsx index 1813863..d186ff8 100644 --- a/frontend/components/portal/portal-kiosk.tsx +++ b/frontend/components/portal/portal-kiosk.tsx @@ -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 }) { ) : step === "book" ? ( setStep("choose")} /> + ) : step === "wallet" ? ( + setStep("choose")} /> ) : ( 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: , + title: t("portal.choose.walletTitle"), + desc: t("portal.choose.walletDesc"), + }, ]; return (
@@ -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(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 ( +
+ +

{t("portal.wallet.title")}

+

+ {t("portal.wallet.subtitle")} +

+ {uri ? ( +
+ +
+ ) : error ? ( +

{t("portal.wallet.error")}

+ ) : ( + + )} +

+ {t("portal.wallet.hint")} +

+
+ ); +} + function BookStep({ clinic, onBack }: { clinic: string; onBack: () => void }) { const { t } = useTranslation(); // "returning" = has a file number; "new" = register first, then book. diff --git a/frontend/components/settings/settings-portal.tsx b/frontend/components/settings/settings-portal.tsx index 8148817..90e6812 100644 --- a/frontend/components/settings/settings-portal.tsx +++ b/frontend/components/settings/settings-portal.tsx @@ -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/; 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/. 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 (