mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 01:47:01 +00:00
4e60361f77
The "Import from a patient app → QR" code baked the web app's own API_BASE_URL (typically http://localhost:4000) into the pairing URI, so a real phone could never reach the relay and "Scan to connect" silently stalled. Use the backend's server-resolved, device-reachable relay URL instead (PUBLIC_RELAY_URL, else the request host), surface it on the WalletPairing type, and document PUBLIC_RELAY_URL in .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { Check, Loader2, QrCode, Smartphone, X } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import QRCodeSvg from "react-qr-code";
|
|
|
|
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogPanel,
|
|
DialogPopup,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { ApiError } from "@/lib/api-client";
|
|
import {
|
|
commitWalletShare,
|
|
type Patient,
|
|
pollWalletShare,
|
|
requestWalletPairing,
|
|
requestWalletShare,
|
|
type WalletShareRequest,
|
|
} from "@/lib/patients";
|
|
import { notify } from "@/lib/toast";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Phase =
|
|
| "form"
|
|
| "requesting"
|
|
| "waiting"
|
|
| "approved"
|
|
| "denied"
|
|
| "expired"
|
|
| "error";
|
|
|
|
type Mode = "number" | "qr";
|
|
|
|
const DURATIONS = [
|
|
{ hours: 1, key: "hours", count: 1 },
|
|
{ hours: 24, key: "days", count: 1 },
|
|
{ hours: 168, key: "days", count: 7 },
|
|
] as const;
|
|
|
|
const POLL_INTERVAL = 2500;
|
|
const POLL_TIMEOUT = 3 * 60 * 1000;
|
|
|
|
export function ImportFromWalletDialog({
|
|
open,
|
|
onOpenChange,
|
|
onImported,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onImported?: (fileNumber: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [mode, setMode] = useState<Mode>("number");
|
|
const [walletNumber, setWalletNumber] = useState("");
|
|
const [temporary, setTemporary] = useState(false);
|
|
const [durationHours, setDurationHours] = useState<number>(24);
|
|
const [phase, setPhase] = useState<Phase>("form");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [request, setRequest] = useState<WalletShareRequest | null>(null);
|
|
const [pairUri, setPairUri] = useState<string | null>(null);
|
|
const [reviewOpen, setReviewOpen] = useState(false);
|
|
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const stopPolling = () => {
|
|
if (pollTimer.current) {
|
|
clearInterval(pollTimer.current);
|
|
pollTimer.current = null;
|
|
}
|
|
};
|
|
|
|
// Reset everything whenever the dialog is (re)opened.
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMode("number");
|
|
setWalletNumber("");
|
|
setTemporary(false);
|
|
setDurationHours(24);
|
|
setPhase("form");
|
|
setError(null);
|
|
setRequest(null);
|
|
setPairUri(null);
|
|
setReviewOpen(false);
|
|
}
|
|
return stopPolling;
|
|
}, [open]);
|
|
|
|
// Poll the request until the patient approves/denies on their device.
|
|
useEffect(() => {
|
|
if (phase !== "waiting" || !request) return;
|
|
const startedAt = Date.now();
|
|
pollTimer.current = setInterval(async () => {
|
|
try {
|
|
const next = await pollWalletShare(request.id);
|
|
if (next.status === "approved") {
|
|
stopPolling();
|
|
setRequest(next);
|
|
setPhase("approved");
|
|
} else if (next.status === "denied") {
|
|
stopPolling();
|
|
setPhase("denied");
|
|
} else if (
|
|
next.status === "expired" ||
|
|
Date.now() - startedAt > POLL_TIMEOUT
|
|
) {
|
|
stopPolling();
|
|
setPhase("expired");
|
|
}
|
|
} catch {
|
|
/* transient — keep polling until timeout */
|
|
if (Date.now() - startedAt > POLL_TIMEOUT) {
|
|
stopPolling();
|
|
setPhase("expired");
|
|
}
|
|
}
|
|
}, POLL_INTERVAL);
|
|
return stopPolling;
|
|
}, [phase, request]);
|
|
|
|
const sendRequest = async () => {
|
|
setPhase("requesting");
|
|
setError(null);
|
|
try {
|
|
const req = await requestWalletShare({
|
|
walletNumber: walletNumber.trim(),
|
|
mode: temporary ? "temporary" : "permanent",
|
|
durationHours: temporary ? durationHours : undefined,
|
|
});
|
|
setRequest(req);
|
|
setPhase("waiting");
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 400) {
|
|
setError(t("patients.importApp.invalidWallet"));
|
|
} else {
|
|
setError(t("patients.importApp.error"));
|
|
}
|
|
setPhase("error");
|
|
}
|
|
};
|
|
|
|
// QR flow: create a pairing request (no wallet number) and build the
|
|
// `temetro-pair:` URI the app scans (this clinic's relay URL + request + key).
|
|
const startPairing = async () => {
|
|
setPhase("requesting");
|
|
setError(null);
|
|
try {
|
|
const pairing = await requestWalletPairing({
|
|
mode: temporary ? "temporary" : "permanent",
|
|
durationHours: temporary ? durationHours : undefined,
|
|
});
|
|
const params = new URLSearchParams({
|
|
// Use the server-resolved, phone-reachable relay URL — NOT API_BASE_URL,
|
|
// which is often http://localhost:4000 (the phone itself) and never
|
|
// connects from a real device.
|
|
relay: pairing.relayUrl,
|
|
rid: pairing.id,
|
|
epk: pairing.ephemeralPubKey,
|
|
mode: pairing.shareMode,
|
|
});
|
|
if (temporary) params.set("dur", String(durationHours));
|
|
setPairUri(`temetro-pair:?${params.toString()}`);
|
|
setRequest(pairing);
|
|
setPhase("waiting");
|
|
} catch {
|
|
setError(t("patients.importApp.error"));
|
|
setPhase("error");
|
|
}
|
|
};
|
|
|
|
const commitDraft = async (record: Patient) => {
|
|
if (!request) return;
|
|
try {
|
|
const saved = await commitWalletShare(request.id, record);
|
|
setReviewOpen(false);
|
|
onOpenChange(false);
|
|
onImported?.(saved.fileNumber);
|
|
notify.success(
|
|
t("patients.importApp.savedTitle"),
|
|
t("patients.importApp.savedBody", { name: saved.name }),
|
|
);
|
|
} catch (err) {
|
|
notify.error(
|
|
t("patients.importApp.errorTitle"),
|
|
err instanceof Error ? err.message : t("patients.importApp.error"),
|
|
);
|
|
}
|
|
};
|
|
|
|
const durationLabel = (d: (typeof DURATIONS)[number]) =>
|
|
t(`patients.importApp.${d.key}`, { count: d.count });
|
|
|
|
return (
|
|
<>
|
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
|
<DialogPopup className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Smartphone className="size-4" />
|
|
{t("patients.importApp.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("patients.importApp.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogPanel className="flex flex-col gap-4">
|
|
{phase === "waiting" && mode === "qr" && pairUri ? (
|
|
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
|
<div className="rounded-2xl bg-white p-4">
|
|
<QRCodeSvg value={pairUri} size={196} />
|
|
</div>
|
|
<p className="font-medium text-sm">
|
|
{t("patients.importApp.qrCaption")}
|
|
</p>
|
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
{t("patients.importApp.waitingTitle")}
|
|
</p>
|
|
</div>
|
|
) : phase === "waiting" ? (
|
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
|
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
|
<p className="font-medium text-sm">
|
|
{t("patients.importApp.waitingTitle")}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("patients.importApp.waitingBody")}
|
|
</p>
|
|
</div>
|
|
) : phase === "approved" ? (
|
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
|
<Check className="size-8 text-emerald-500" />
|
|
<p className="font-medium text-sm">
|
|
{t("patients.importApp.approvedTitle")}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("patients.importApp.approvedBody")}
|
|
</p>
|
|
<Button onClick={() => setReviewOpen(true)} type="button">
|
|
{t("patients.importApp.review")}
|
|
</Button>
|
|
</div>
|
|
) : phase === "denied" || phase === "expired" ? (
|
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
|
<X className="size-8 text-muted-foreground" />
|
|
<p className="font-medium text-sm">
|
|
{t(`patients.importApp.${phase}Title`)}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t(`patients.importApp.${phase}Body`)}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex gap-1 rounded-2xl bg-muted/50 p-1">
|
|
<Button
|
|
className="flex-1 rounded-xl"
|
|
onClick={() => setMode("number")}
|
|
size="sm"
|
|
type="button"
|
|
variant={mode === "number" ? "default" : "ghost"}
|
|
>
|
|
<Smartphone className="size-4" />
|
|
{t("patients.importApp.tabNumber")}
|
|
</Button>
|
|
<Button
|
|
className="flex-1 rounded-xl"
|
|
onClick={() => setMode("qr")}
|
|
size="sm"
|
|
type="button"
|
|
variant={mode === "qr" ? "default" : "ghost"}
|
|
>
|
|
<QrCode className="size-4" />
|
|
{t("patients.importApp.tabQr")}
|
|
</Button>
|
|
</div>
|
|
|
|
{mode === "number" ? (
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("patients.importApp.walletLabel")}
|
|
</span>
|
|
<Input
|
|
autoFocus
|
|
disabled={phase === "requesting"}
|
|
onChange={(e) => setWalletNumber(e.target.value)}
|
|
placeholder={t("patients.importApp.walletPlaceholder")}
|
|
value={walletNumber}
|
|
/>
|
|
</label>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("patients.importApp.qrHint")}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-start justify-between gap-4 rounded-2xl border border-border bg-card/30 p-3">
|
|
<div className="space-y-0.5">
|
|
<p className="font-medium text-sm">
|
|
{t("patients.importApp.tempLabel")}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("patients.importApp.tempHint")}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={temporary}
|
|
disabled={phase === "requesting"}
|
|
onCheckedChange={(v) => setTemporary(v)}
|
|
/>
|
|
</div>
|
|
|
|
{temporary ? (
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("patients.importApp.durationLabel")}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
{DURATIONS.map((d) => (
|
|
<Button
|
|
className={cn(
|
|
"flex-1 rounded-2xl",
|
|
durationHours !== d.hours &&
|
|
"bg-transparent text-foreground",
|
|
)}
|
|
key={d.hours}
|
|
onClick={() => setDurationHours(d.hours)}
|
|
size="sm"
|
|
type="button"
|
|
variant={
|
|
durationHours === d.hours ? "default" : "outline"
|
|
}
|
|
>
|
|
{durationLabel(d)}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</DialogPanel>
|
|
|
|
<DialogFooter>
|
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
{phase === "approved" || phase === "denied" || phase === "expired"
|
|
? t("patients.importApp.close")
|
|
: t("patients.importApp.cancel")}
|
|
</DialogClose>
|
|
{(phase === "form" || phase === "requesting" || phase === "error") &&
|
|
mode === "number" ? (
|
|
<Button
|
|
disabled={!walletNumber.trim() || phase === "requesting"}
|
|
onClick={sendRequest}
|
|
type="button"
|
|
>
|
|
{phase === "requesting"
|
|
? t("patients.importApp.requesting")
|
|
: t("patients.importApp.request")}
|
|
</Button>
|
|
) : (phase === "form" || phase === "requesting" || phase === "error") &&
|
|
mode === "qr" ? (
|
|
<Button
|
|
disabled={phase === "requesting"}
|
|
onClick={startPairing}
|
|
type="button"
|
|
>
|
|
{phase === "requesting"
|
|
? t("patients.importApp.requesting")
|
|
: t("patients.importApp.generateQr")}
|
|
</Button>
|
|
) : null}
|
|
</DialogFooter>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
|
|
{/* Review the shared record in the full patient form (review mode — the
|
|
form emits the draft, we commit it via the wallet-share endpoint). */}
|
|
{request?.draft ? (
|
|
<PatientFormDialog
|
|
mode="edit"
|
|
onDraft={commitDraft}
|
|
onOpenChange={setReviewOpen}
|
|
open={reviewOpen}
|
|
patient={request.draft}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|