// One of the three documents, opened from its link in the notice. // // THE AFFIRMATION IS PINNED TO THE HEADER AND STAYS THERE WHILE THE TEXT SCROLLS, and that is the // whole shape of this dialog rather than a styling preference. A legal agreement is long; a control at // the END makes agreeing require scrolling past all of it, which reads as a design that would rather you // did not read, and strands anyone who has read enough. Drawn that way in // `mockups/patient-portal/overview.html`. // // Do not move the affirmation into the footer "for consistency with other dialogs". It is inconsistent // on purpose. import { useEffect, useState } from 'react'; import { X } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from '../ui/dialog'; import { CONSENT_INSTRUMENT_DEFINITIONS, ConsentInstrumentId, declinableInstruments, } from '../../config/consentInstruments'; import { orgAgreementName } from './orgAgreements'; import type { AgreementSubject } from './agreementSubject'; /** * THE WORDS ON THE BUTTON, DERIVED FROM THE DOCUMENT'S KIND RATHER THAN WRITTEN AT THE CALL SITE. * * Spencer, 2026-09-02: *"'I have read' for privacy, keep 'I agree' for the other two."* The model * carries the same distinction as a full sentence in `consentPrompt()`; a button needs the short form, * and it is derived here from the same `kind` so the two can never disagree about which document is * merely read. * * THE DECK DRAWS "I Agree" ON ALL THREE, INCLUDING THE PRIVACY NOTICE, AND IT IS STALE. The drawing * predates that ruling; the ruling is implemented in the model and on the server. Recorded rather than * silently reconciled, per this card's instruction to say so where the two disagree. */ export const consentAffirmation = (instrument: ConsentInstrumentId): string => CONSENT_INSTRUMENT_DEFINITIONS[instrument].kind === 'acknowledgement' ? 'I Have Read' : 'I Agree'; interface AgreementDocumentDialogProps { open: boolean; instrument: ConsentInstrumentId; /** The practice's name, as the patient knows it. */ organizationName: string; /** The document as the practice currently offers it. Null while it is still being fetched. */ text: string | null; version?: number; /** Set when the document could not be read at all. The dialog says so rather than showing nothing. */ loadError?: string | null; busy?: boolean; onAgree: () => void; /** Absent where declining is not part of the flow, such as the booking review step. */ onDecline?: () => void; onClose: () => void; subject?: AgreementSubject; /** Why the reader may not answer it (a helper, or a patient under 18). Set, the document stays readable and offers no answer. */ limitation?: string | null; } const AgreementDocumentDialog = ({ open, instrument, organizationName, text, version, loadError, busy, onAgree, onDecline, onClose, subject, limitation, }: AgreementDocumentDialogProps) => { const readOnlyHelper = Boolean(limitation); const name = orgAgreementName(instrument); /** * THE DECLINE IS OFFERED ONLY WHERE ONE CAN EXIST, and this is derived, never listed. * * The privacy notice is an ACKNOWLEDGEMENT - you cannot decline having been told something - and the * server refuses a decline for it outright. Drawing the control anyway would put a button on screen * whose only possible answer is an error, which is the defect this work has already met twice. * The deck draws "I do not agree" under all three. Same staleness as the affirmation above. */ const canDecline = !readOnlyHelper && Boolean(onDecline) && declinableInstruments().includes(instrument); /** * THE BODY IS SCROLLED BACK TO THE TOP WHENEVER A DIFFERENT DOCUMENT IS OPENED. * * The dialog is reused for all three, so without this a patient who scrolled to the bottom of one * document opens the next one already at its end - past everything it says, with the affirmation * still sitting in the header ready to press. */ const [bodyElement, setBodyElement] = useState(null); useEffect(() => { if (bodyElement) bodyElement.scrollTop = 0; }, [bodyElement, instrument, text]); return ( { if (!next) onClose(); }}> {/* THE FIXED HEADER. `shrink-0` is load-bearing: without it a long body squeezes the header away and the affirmation goes with it. */}
{name} {!readOnlyHelper && ( )}
{subject && (
For {subject.subjectName}.{' '} {readOnlyHelper ? limitation : subject.guardianName ? <>You, {subject.guardianName}, are answering on their behalf. : 'You are answering on their behalf.'}
)} {!subject && readOnlyHelper && (
{limitation}
)} {subject && 'For ' + subject.subjectName + '. '} {name}, as {organizationName} currently offers it.
{/* The provenance line. ⚠️ NO EFFECTIVE DATE: the drawing carries one and the wire does not, and a date invented here would sit beside a real version number and read as though it came from the same place. */}

{organizationName} {version !== undefined && ` · version ${version}`}

{/* 🚨 A DOCUMENT THAT CANNOT BE READ SAYS SO. It must never render as an empty box with a live affirmation above it - that is a patient agreeing to nothing, and the record afterwards would not show that anything was wrong. */} {loadError ?

{loadError}

: text ??

Loading…

}
{canDecline && (
)}
); }; export default AgreementDocumentDialog;