Files
temetro/frontend/components/portal/portal-kiosk.tsx
T
Khalid Abdi 947623a691 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>
2026-07-09 00:06:04 +03:00

493 lines
16 KiB
TypeScript

"use client";
import {
ArrowLeft,
CalendarCheck,
CalendarPlus,
CheckCircle2,
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 {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import {
bookPortalAppointment,
createPortalPatient,
getPortalClinic,
getPortalLink,
lookupPortalResults,
portalPairingUri,
type PortalBookingResult,
type PortalResults,
} from "@/lib/portal";
import { cn } from "@/lib/utils";
type Step = "choose" | "book" | "results" | "wallet";
const todayKey = () => new Date().toISOString().slice(0, 10);
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5 text-start">
<span className="font-medium text-foreground text-sm">{label}</span>
{children}
</label>
);
}
export function PortalKiosk({ clinic }: { clinic: string }) {
const { t } = useTranslation();
const [clinicName, setClinicName] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [step, setStep] = useState<Step>("choose");
useEffect(() => {
getPortalClinic(clinic)
.then((c) => setClinicName(c.name))
.catch(() => setNotFound(true));
}, [clinic]);
if (notFound) {
return (
<div className="flex min-h-dvh items-center justify-center p-6">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<CalendarCheck />
</EmptyMedia>
<EmptyTitle>{t("portal.notFoundTitle")}</EmptyTitle>
<EmptyDescription>{t("portal.notFoundBody")}</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
);
}
return (
<div className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col items-center justify-center gap-8 px-6 py-12">
<header className="flex flex-col items-center gap-2 text-center">
<span className="font-medium text-muted-foreground text-sm uppercase tracking-wide">
{t("portal.kicker")}
</span>
<h1 className="font-semibold text-3xl tracking-tight sm:text-4xl">
{clinicName ?? "…"}
</h1>
</header>
{step === "choose" ? (
<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")} />
)}
</div>
);
}
// Card-style radio: two large, touch-friendly choices.
function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
const { t } = useTranslation();
const cards: { step: Step; icon: React.ReactNode; title: string; desc: string }[] =
[
{
step: "book",
icon: <CalendarPlus className="size-7" />,
title: t("portal.choose.bookTitle"),
desc: t("portal.choose.bookDesc"),
},
{
step: "results",
icon: <FlaskConical className="size-7" />,
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">
{cards.map((c) => (
<button
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-start transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
key={c.step}
onClick={() => onPick(c.step)}
type="button"
>
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{c.icon}
</span>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 font-semibold text-lg text-foreground">
{c.title}
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 rtl:rotate-180" />
</span>
<span className="text-muted-foreground text-sm">{c.desc}</span>
</span>
</button>
))}
</div>
);
}
function BackButton({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
return (
<button
className="flex items-center gap-1.5 self-start text-muted-foreground text-sm transition-colors hover:text-foreground"
onClick={onBack}
type="button"
>
<ArrowLeft className="size-4 rtl:rotate-180" />
{t("portal.back")}
</button>
);
}
// 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.
const [mode, setMode] = useState<"returning" | "new">("returning");
const [name, setName] = useState("");
const [fileNumber, setFileNumber] = useState("");
const [sex, setSex] = useState("M");
const [age, setAge] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("09:00");
const [type, setType] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<PortalBookingResult | null>(null);
// The file number assigned to a freshly-registered patient (shown on success).
const [newFile, setNewFile] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
// A new patient is registered first to obtain a file number.
let file = fileNumber.trim();
if (mode === "new") {
const created = await createPortalPatient(clinic, {
name: name.trim(),
sex,
age: age ? Number(age) : undefined,
});
file = created.fileNumber;
setNewFile(created.fileNumber);
}
const result = await bookPortalAppointment(clinic, {
name: name.trim(),
fileNumber: file,
date,
time,
type: type.trim() || undefined,
});
setDone(result);
} catch (err) {
setError(
err instanceof Error ? err.message : t("portal.book.errorGeneric"),
);
} finally {
setBusy(false);
}
};
if (done) {
return (
<div className="flex w-full flex-col items-center gap-6">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<CheckCircle2 />
</EmptyMedia>
<EmptyTitle>{t("portal.book.successTitle")}</EmptyTitle>
<EmptyDescription>
{t("portal.book.successBody", {
date: done.date,
time: done.time,
})}
{newFile
? ` ${t("portal.book.newFileNote", { file: newFile })}`
: ""}
</EmptyDescription>
</EmptyHeader>
</Empty>
<Button onClick={onBack} variant="outline">
{t("portal.done")}
</Button>
</div>
);
}
return (
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
<BackButton onBack={onBack} />
<h2 className="font-semibold text-xl">{t("portal.book.title")}</h2>
{/* Returning vs new patient. */}
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-muted/40 p-1">
{(["returning", "new"] as const).map((m) => (
<button
className={cn(
"rounded-xl px-3 py-2 text-sm font-medium transition-colors",
mode === m
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
key={m}
onClick={() => setMode(m)}
type="button"
>
{t(`portal.book.mode.${m}`)}
</button>
))}
</div>
<Field label={t("portal.field.name")}>
<Input onChange={(e) => setName(e.target.value)} required value={name} />
</Field>
{mode === "returning" ? (
<Field label={t("portal.field.fileNumber")}>
<Input
onChange={(e) => setFileNumber(e.target.value)}
required
value={fileNumber}
/>
</Field>
) : (
<div className="grid grid-cols-2 gap-3">
<Field label={t("portal.field.sex")}>
<select
className="h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
onChange={(e) => setSex(e.target.value)}
value={sex}
>
<option value="M">{t("portal.field.sexMale")}</option>
<option value="F">{t("portal.field.sexFemale")}</option>
</select>
</Field>
<Field label={t("portal.field.age")}>
<Input
max={150}
min={0}
onChange={(e) => setAge(e.target.value)}
type="number"
value={age}
/>
</Field>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={t("portal.field.date")}>
<Input
min={todayKey()}
onChange={(e) => setDate(e.target.value)}
required
type="date"
value={date}
/>
</Field>
<Field label={t("portal.field.time")}>
<Input
onChange={(e) => setTime(e.target.value)}
required
type="time"
value={time}
/>
</Field>
</div>
<Field label={t("portal.field.reason")}>
<Input
onChange={(e) => setType(e.target.value)}
placeholder={t("portal.field.reasonPlaceholder")}
value={type}
/>
</Field>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button disabled={busy} size="lg" type="submit">
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
{t("portal.book.submit")}
</Button>
</form>
);
}
function ResultsStep({ clinic, onBack }: { clinic: string; onBack: () => void }) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [fileNumber, setFileNumber] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<PortalResults | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
setData(
await lookupPortalResults(clinic, fileNumber.trim(), name.trim()),
);
} catch (err) {
setError(
err instanceof Error ? err.message : t("portal.results.errorGeneric"),
);
} finally {
setBusy(false);
}
};
if (data) {
return (
<div className="flex w-full flex-col gap-4">
<BackButton onBack={onBack} />
<h2 className="font-semibold text-xl">
{t("portal.results.greeting", { name: data.name })}
</h2>
<div className="rounded-2xl border bg-card/40 p-4">
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{t("portal.results.upcoming")}
</p>
{data.upcoming.length === 0 ? (
<p className="mt-2 text-muted-foreground text-sm">
{t("portal.results.noUpcoming")}
</p>
) : (
<ul className="mt-2 flex flex-col gap-2">
{data.upcoming.map((a) => (
<li
className="flex items-center justify-between gap-3 rounded-xl border bg-card px-3 py-2"
key={`${a.date}-${a.time}`}
>
<span className="font-medium text-foreground text-sm tabular-nums">
{a.date} · {a.time}
</span>
<span className="truncate text-muted-foreground text-sm">
{a.type} · {a.provider}
</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-2xl border bg-card/40 p-4">
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{t("portal.results.resultsLabel")}
</p>
<p className="mt-2 text-foreground text-sm">
{data.hasResults
? t("portal.results.ready", { count: data.resultCount })
: t("portal.results.none")}
</p>
{data.hasResults ? (
<p className="mt-1 text-muted-foreground text-xs">
{t("portal.results.askStaff")}
</p>
) : null}
</div>
<Button onClick={() => setData(null)} variant="outline">
{t("portal.results.lookupAnother")}
</Button>
</div>
);
}
return (
<form className="flex w-full flex-col gap-4" onSubmit={submit}>
<BackButton onBack={onBack} />
<h2 className="font-semibold text-xl">{t("portal.results.title")}</h2>
<p className="text-muted-foreground text-sm">{t("portal.results.subtitle")}</p>
<Field label={t("portal.field.name")}>
<Input onChange={(e) => setName(e.target.value)} required value={name} />
</Field>
<Field label={t("portal.field.fileNumber")}>
<Input
onChange={(e) => setFileNumber(e.target.value)}
required
value={fileNumber}
/>
</Field>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button disabled={busy} size="lg" type="submit">
{busy ? <Loader2 className="size-4 animate-spin" /> : null}
{t("portal.results.submit")}
</Button>
</form>
);
}