import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import DemoEntranceBanner from './DemoEntranceBanner';
import { DEMO_ROLES, demoAppOrigin, demoEntranceUrl, demoRoleFrom, type DemoRole } from '../../utils/demoEntrance';
import { AgreementBlocks } from '../../components/FirstSignInGate/AgreementBlocks';
import { hostedMountContext } from '../../utils/hostedMount';
import {
DEMO_AGREEMENT_READ_MS, DEMO_ENTRY_BUDGET_MS, readDemoAgreement, requestDemoLease, requestDemoRole, signInDemo, useDemoTurnstile,
type DemoAgreementCopy,
} from '../../services/demoEntry';
import { startDemoOperation, type DemoOperation } from '../../services/demoAllowance';
import { newTabId, readDemoSession } from '../../services/demoTab';
import { DEMO_EXIT_EVENT } from '../../services/demoExit';
import DemoExitLink from './DemoExitLink';
type EntryState =
| { step: 'agreement' }
| { step: 'entering' }
| { step: 'full'; minutes: number }
| { step: 'unavailable' }
| { step: 'agreement-changed' }
| { step: 'failed' }
| { step: 'copy' }
| { step: 'ended' };
// What one opening of this page has been told. Each opening - each change of origin, mount or role, even back
// to an earlier one - is a new lifetime and starts fresh.
type SwitchState = 'idle' | 'switching' | 'failed' | 'ended' | 'unavailable' | 'copy';
// The published agreement as this opening has read it: only what is shown here may be accepted.
type CopyState = { status: 'loading' } | { status: 'ready'; copy: DemoAgreementCopy } | { status: 'none' } | { status: 'failed' };
type View = { lifetime: number; agreed: boolean; state: EntryState; newDemo: boolean; switching: SwitchState; copy: CopyState };
const freshView = (lifetime: number): View => ({
lifetime, agreed: false, state: { step: 'agreement' }, newDemo: false, switching: 'idle', copy: { status: 'loading' },
});
const mountPath = (role: DemoRole) => (DEMO_ROLES[role].mount === 'staff' ? '/staff-portal/' : '/');
const roleName = (role: DemoRole) => DEMO_ROLES[role].label.toLowerCase();
const Page = ({ children }: { children: ReactNode }) => (
{children}
);
/**
* The demo-only agreement, asked before anything is leased. The box starts unticked and Enter stays
* disabled until it is ticked; leaving asks for nothing. A full pool or a failed entry waits for the
* visitor to try again; nothing here retries on its own.
*/
// The physical mount is passed in so a test can state it; on a page it is read from the path. A role is
// accepted only on its own mount, so a staff role never enters through the patient portal or the reverse.
// The page also checks its own origin, so rendering it anywhere but the configured demo origin asks for nothing.
const DemoEntryPage = ({
mount = hostedMountContext(),
origin = window.location.origin,
appOrigin = demoAppOrigin(),
}: { mount?: 'frontend' | 'staff'; origin?: string; appOrigin?: string | null }) => {
const { search } = useLocation();
const approved = !!appOrigin && origin === appOrigin;
const named = demoRoleFrom(search);
const role = approved && named && DEMO_ROLES[named].mount === mount ? named : undefined;
const leaveTo = demoEntranceUrl();
const opening = role ? origin + '|' + mount + '|' + role : '';
const turnstile = useDemoTurnstile(opening);
const unchallenged = !!turnstile.required && !turnstile.token;
// An entry belongs to the opening it was started in. A change of role or mount - even back to the same
// one - retires it in the render that sees the change, and so does leaving the page.
const owner = useRef({ opening, number: 0 });
if (owner.current.opening !== opening) owner.current = { opening, number: owner.current.number + 1 };
const lifetime = owner.current.number;
const mounted = useRef(true);
// Operations still running, by the lifetime that started them; a lifetime that ends cancels its own.
const running = useRef(new Map());
const cancelWhere = (ended: (started: number) => boolean) => {
for (const [operation, started] of [...running.current]) {
if (ended(started)) {
running.current.delete(operation);
operation.cancel();
}
}
};
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
cancelWhere(() => true);
};
}, []);
useEffect(() => () => cancelWhere((started) => started === lifetime), [lifetime]);
const [stored, setView] = useState(() => freshView(lifetime));
const view = stored.lifetime === lifetime ? stored : freshView(lifetime);
const update = (forLifetime: number, change: Partial) =>
setView((prev) => ({ ...(prev.lifetime === forLifetime ? prev : freshView(forLifetime)), ...change }));
const held = approved ? readDemoSession() : undefined;
// An open visit keeps the version its visitor accepted; only a new entry needs the one published now.
const heldLive = !!held && Date.parse(held.expiresAt) > Date.now();
// One read of the agreement at a time per opening, settled within its own allowance: a later read, another
// opening or leaving drops an earlier answer, a read past its allowance fails and can be asked again, and
// different copy clears the tick, so only what is on screen can be accepted.
const copyRead = useRef(null);
const readCopy = (mine: number) => {
copyRead.current?.cancel();
const holder: { operation?: DemoOperation } = {};
const current = () => mounted.current && owner.current.number === mine && copyRead.current === holder.operation;
const operation = startDemoOperation(newTabId(), DEMO_AGREEMENT_READ_MS, current);
holder.operation = operation;
copyRead.current = operation;
update(mine, { copy: { status: 'loading' }, agreed: false });
readDemoAgreement(operation)
.then((answer) => {
if (!current()) return;
update(mine, { copy: answer.kind === 'ready' ? { status: 'ready', copy: answer.copy } : { status: 'none' }, agreed: false });
})
.catch(() => { if (current()) update(mine, { copy: { status: 'failed' }, agreed: false }); });
};
useEffect(() => {
if (!approved || !role) return undefined;
readCopy(lifetime);
return () => { copyRead.current?.cancel(); copyRead.current = null; };
}, [lifetime, approved, role]);
// One operation per press, owned by this lifetime and settled within its allowance whatever its
// dependencies do. Only the owner shows what became of it.
// Within an opening, one consequential action owns the tab at a time: starting an entry or a switch, or
// choosing to replace the held visit, retires whatever was running before it synchronously.
const action = useRef(0);
const retireActions = () => {
action.current += 1;
cancelWhere(() => true);
};
useEffect(() => {
const leave = () => {
mounted.current = false;
retireActions();
copyRead.current?.cancel();
};
window.addEventListener(DEMO_EXIT_EVENT, leave);
return () => window.removeEventListener(DEMO_EXIT_EVENT, leave);
}, []);
const begin = () => {
retireActions();
const mine = lifetime;
const act = action.current;
const owned = () => mounted.current && owner.current.number === mine && action.current === act;
const operation = startDemoOperation(newTabId(), DEMO_ENTRY_BUDGET_MS, owned);
running.current.set(operation, mine);
const done = () => {
running.current.delete(operation);
operation.cancel();
};
return { operation, owned, mine, done };
};
const switchRole = async () => {
if (!role || !held || view.switching === 'switching') return;
const { operation, owned, mine, done } = begin();
update(mine, { switching: 'switching' });
try {
const answer = await requestDemoRole({ leaseId: held.leaseId, leaseSecret: held.leaseSecret }, role, operation);
operation.admit();
if (answer.kind !== 'leased') {
update(mine, { switching: answer.kind });
return;
}
const outcome = await signInDemo(
{ ...answer, agreementVersion: held.agreementVersion }, role, operation, undefined, { generation: held.generation, clinicId: held.clinicId },
);
if (outcome === 'copy') update(mine, { switching: 'copy' });
if (outcome === 'ended') update(mine, { switching: 'ended' });
} catch {
// Out of time or uncertain: the held visit is untouched, so Try again asks once more.
if (owned()) update(mine, { switching: 'failed' });
} finally {
done();
}
};
const enter = async () => {
if (!role || !view.agreed || view.state.step === 'entering' || view.copy.status !== 'ready') return;
const version = view.copy.copy.version;
// This attempt alone spends the pass; the next one waits for a fresh challenge.
const turnstileToken = turnstile.take();
if (turnstile.required && !turnstileToken) return;
const { operation, owned, mine, done } = begin();
const replaces = view.newDemo && held ? { leaseId: held.leaseId, leaseSecret: held.leaseSecret } : undefined;
update(mine, { state: { step: 'entering' } });
try {
const answer = await requestDemoLease({
role,
turnstileToken,
agreement: { accepted: true, version },
...(replaces && { replaces }),
}, operation);
operation.admit();
if (answer.kind === 'full') {
update(mine, { state: { step: 'full', minutes: answer.estimatedFreeInMinutes } });
return;
}
if (answer.kind === 'agreement-changed') {
// Nothing was leased; the current version is read and shown, to be read and agreed afresh.
update(mine, { state: { step: 'agreement-changed' } });
readCopy(mine);
return;
}
if (answer.kind === 'unavailable') {
update(mine, { state: { step: answer.kind } });
return;
}
const outcome = await signInDemo(answer, role, operation);
if (outcome !== 'committed') update(mine, { state: { step: outcome } });
} catch {
// Out of time or uncertain: the entry record keeps this lease, so Try again recovers it.
if (owned()) update(mine, { state: { step: 'failed' } });
} finally {
done();
}
};
if (!approved) {
return (
Choose a role
);
}
if (heldLive && held.role === role) {
return (
Your demo visit is open
{'You are already in the demo as ' + roleName(role) + ' in this tab.'}
Continue your demo
);
}
// Another role switches inside the clinic this tab holds, never by leasing another one. A new demo is a
// separate, explicit choice.
if (heldLive && !view.newDemo) {
const { switching } = view;
return (
{'You are in the demo as ' + roleName(held.role)}
{'You can switch to ' + roleName(role) + ' in the same demo clinic.'}
{switching === 'failed' &&
We could not switch roles. Try again.
}
{switching === 'ended' &&
Your demo visit has ended. Start a new demo to continue.
{'Every demo clinic is in use right now. One is expected to be free in about ' + state.minutes + ' minutes.'}
)}
{state.step === 'unavailable' && (
The demo clinics are not open yet. Please come back soon.
)}
{state.step === 'agreement-changed' && (
The demo agreement has changed. Read the current version above and agree to it to enter.
)}
{state.step === 'failed' && (
We could not open your demo clinic. Try again.
)}
{state.step === 'ended' && (
That demo visit ended before it opened. Enter again to start a new one.
)}
{state.step === 'copy' && (
This demo visit is being opened in another tab. Enter again to start a new one here.
)}
{/* The challenge; a pass is spent by each attempt and asked for again before the next. */}
{turnstile.required && }
{turnstile.required && turnstile.status === 'failed' && (
The security check did not load, so the demo cannot open yet.