import { useEffect, useRef, useState, type ReactNode } from 'react'; import DemoEntranceBanner from './DemoEntranceBanner'; import DemoRoleSwitch, { DemoVisitRoleContext } from './DemoRoleSwitch'; import { environment } from '../../environments/environment'; import { demoTabAtLoad } from '../../services/authStorage'; import { DEMO_STANDING_BUDGET_MS, confirmDemoStanding, type DemoStanding } from '../../services/demoStanding'; import { startDemoOperation, type DemoOperation } from '../../services/demoAllowance'; import { DEMO_ENDED_EVENT, claimDemoTab, clearDemoTab, releaseDemoTab, type DemoTabSession } from '../../services/demoTab'; import { demoEntranceUrl } from '../../utils/demoEntrance'; import { discardDemoKey } from '../../utils/dpopKeyStore'; import { DEMO_EXIT_EVENT } from '../../services/demoExit'; import DemoExitLink from './DemoExitLink'; type VisitState = 'checking' | 'admitted' | 'none' | 'copy' | 'ended' | 'left' | 'unavailable'; const Notice = ({ title, text, children }: { title: string; text: string; children?: ReactNode }) => (

{title}

{text}

{children} Go to the demo entrance
); /** * Every page on the demo origin. The DEMO MODE strip sits above everything, through checking, the app itself * and an ended visit, and has no close. The app mounts only once this tab holds the visit exclusively and the * server has confirmed its capability, generation, role and clinic are still live. */ const DemoVisit = ({ children, session = demoTabAtLoad, store = typeof window === 'undefined' ? undefined : window.sessionStorage, claim = claimDemoTab, confirm = confirmDemoStanding, now = Date.now, }: { children: ReactNode; session?: DemoTabSession; store?: Storage; claim?: (leaseId: string) => Promise<'held' | 'copy'>; confirm?: (session: DemoTabSession, store: Storage, operation: DemoOperation) => Promise; now?: () => number; }) => { const prefix = environment.localStoragePrefix; const [state, setState] = useState(session && store ? 'checking' : 'none'); const [attempt, setAttempt] = useState(0); const exiting = useRef(false); const checking = useRef(null); useEffect(() => { const leave = () => { exiting.current = true; checking.current?.cancel(); setState('left'); }; window.addEventListener(DEMO_EXIT_EVENT, leave); return () => window.removeEventListener(DEMO_EXIT_EVENT, leave); }, []); useEffect(() => { if (!session || !store || state !== 'checking') return; // One allowance from the start of the check, settled whatever the lock, key store or server do. let live = true; const operation = startDemoOperation(session.tabId, DEMO_STANDING_BUDGET_MS, () => live && !exiting.current); checking.current = operation; void (async () => { let next: VisitState; try { const holder = await operation.settle(claim(session.leaseId)); if (holder === 'copy') { next = 'copy'; } else { const standing = await operation.settle(confirm(session, store, operation)); // Admitted only by a current answer while the visit is still within its expiry. operation.admit(); next = standing === 'active' ? (Date.parse(session.expiresAt) > now() ? 'admitted' : 'ended') : standing === 'unavailable' ? 'unavailable' : standing === 'mismatch' ? 'left' : 'ended'; } } catch { next = 'unavailable'; } if (live && !exiting.current) setState(next); })(); return () => { live = false; operation.cancel(); }; }, [session, store, state, attempt, claim, confirm, now]); // A refused tab drops its own copy of the visit and lets go of it; nothing else is touched. useEffect(() => { if (exiting.current || !store || (state !== 'none' && state !== 'copy' && state !== 'ended' && state !== 'left')) return; clearDemoTab(prefix, store); // Only the tab that held the visit takes its key; a copy shares the record with the tab that holds it. if (session && (state === 'ended' || state === 'left')) void discardDemoKey(session.tabId).catch(() => undefined); if (session && state !== 'copy') releaseDemoTab(session.leaseId); }, [state, store, prefix, session]); // Admission is checked again where the app would render: a visit that expired before this render shows as // ended and never mounts the app, however late the admitted update was rendered. const expiredAtRender = state === 'admitted' && (!session || !(Date.parse(session.expiresAt) > now())); const shown: VisitState = expiredAtRender ? 'ended' : state; useEffect(() => { if (expiredAtRender) setState('ended'); }, [expiredAtRender]); useEffect(() => { if (!session || state !== 'admitted') return; // Signing out ends the visit in this tab only; the clinic itself is not closed by it, and neither is it // by tokens that no longer match the visit. const end = (event?: Event) => setState((event as CustomEvent | undefined)?.detail === 'SIGNED_OUT' ? 'left' : 'ended'); const timer = window.setTimeout(() => end(), Math.max(0, Date.parse(session.expiresAt) - Date.now())); window.addEventListener(DEMO_ENDED_EVENT, end); return () => { window.clearTimeout(timer); window.removeEventListener(DEMO_ENDED_EVENT, end); }; }, [session, state]); return ( <> {shown === 'admitted' && session && } {shown === 'admitted' && session && {children}} {shown === 'checking' &&

Opening your demo visit

} {shown === 'none' && } {shown === 'copy' && } {shown === 'ended' && } {shown === 'left' && } {shown === 'unavailable' && ( )} ); }; export default DemoVisit;