mirror of
https://github.com/temetro/temetro.git
synced 2026-08-11 19:18:03 +00:00
e8f3ed9ffe
`npm run lint` reported 99 errors and 26 warnings across 65 files and had presumably been failing for a while — next.config.ts sets eslint.ignoreDuringBuilds, so the build never surfaced it. 68 were in vendored code: components/charts and components/ai-elements, pulled from upstream registries. Re-linting those reports upstream's style back at us, and "fixing" them means diverging and eating conflicts on every update — ai-elements already has exactly this carve-out on the TypeScript side via ignoreBuildErrors. Ignore both, plus the two registry files in components/ui (carousel publishes its api from an effect; the sidebar skeleton picks a random width). The other 31 were ours, nearly all react-hooks/set-state-in-effect on the same shape: an effect that re-seeds form state when a dialog opens or a selection changes. Moved to render-phase adjustment, which is both what React recommends and a real fix — the effect version paints one frame of the *previous* record's values before correcting itself. Two carried sharper bugs: the employee dialog could keep a typed password across a switch to another member, and use-wallet-sync could carry `linked` over to a newly-selected patient, briefly offering to push a record to someone else's wallet. The rest: useIsMobile and speech-support detection become useSyncExternalStore (correct on first paint, no mount flash); refs mirroring state are written in effects rather than during render; the care-team fetch moves into its effect behind a reload key, dropping an exhaustive-deps suppression. Two effects in chat-panel keep the rule disabled with a reason. Both are what effects are for: draining the queued-message buffer when the transport goes idle, and resolving ?thread from the URL — the latter mints an id with nanoid(), so moving it into render would just trade this error for a purity one. Also removes a dead /explore-era import and unused directives found on the way. lint now exits 0, with the ai-elements tsc carve-out unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
124 lines
4.0 KiB
TypeScript
124 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { ExternalLink, QrCode } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import QRCodeSvg from "react-qr-code";
|
|
|
|
import {
|
|
CopyField,
|
|
SettingsCard,
|
|
SettingsSection,
|
|
whiteButton,
|
|
} from "@/components/settings/settings-parts";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogPanel,
|
|
DialogPopup,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { authClient } from "@/lib/auth-client";
|
|
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 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}` : "";
|
|
|
|
// Drop the previous clinic's QR as soon as the slug changes, rather than
|
|
// leaving it on screen until the new one arrives. Adjusted during render, so
|
|
// switching clinics never shows the old clinic's pairing code.
|
|
const [prevSlug, setPrevSlug] = useState(slug);
|
|
if (prevSlug !== slug) {
|
|
setPrevSlug(slug);
|
|
setQrUri("");
|
|
}
|
|
|
|
// Fetch the relay-based pairing descriptor for the QR (non-secret).
|
|
useEffect(() => {
|
|
if (!slug) return;
|
|
let active = true;
|
|
getPortalLink(slug)
|
|
.then((link) => {
|
|
if (active) setQrUri(portalPairingUri(link));
|
|
})
|
|
.catch(() => {
|
|
if (active) setQrUri("");
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [slug]);
|
|
|
|
return (
|
|
<SettingsSection
|
|
description={t("settings.portal.description")}
|
|
title={t("settings.portal.title")}
|
|
>
|
|
<SettingsCard className="flex flex-col gap-4 p-5">
|
|
<CopyField
|
|
description={t("settings.portal.linkDescription")}
|
|
label={t("settings.portal.linkLabel")}
|
|
value={portalUrl || "—"}
|
|
/>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
className={cn("rounded-lg", whiteButton)}
|
|
disabled={!portalUrl}
|
|
onClick={() => window.open(portalUrl, "_blank", "noopener")}
|
|
type="button"
|
|
>
|
|
<ExternalLink className="size-4" />
|
|
{t("settings.portal.open")}
|
|
</Button>
|
|
<Button
|
|
className="rounded-lg"
|
|
disabled={!qrUri}
|
|
onClick={() => setQrOpen(true)}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<QrCode className="size-4" />
|
|
{t("settings.portal.showQr")}
|
|
</Button>
|
|
</div>
|
|
</SettingsCard>
|
|
|
|
<Dialog onOpenChange={setQrOpen} open={qrOpen}>
|
|
<DialogPopup className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("settings.portal.qrTitle")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("settings.portal.qrDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogPanel className="flex flex-col items-center gap-3 pb-2">
|
|
{qrUri ? (
|
|
<div className="rounded-2xl bg-white p-4">
|
|
<QRCodeSvg value={qrUri} size={220} />
|
|
</div>
|
|
) : null}
|
|
<p className="break-all text-center text-sm text-muted-foreground">
|
|
{portalUrl}
|
|
</p>
|
|
</DialogPanel>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
</SettingsSection>
|
|
);
|
|
}
|