import { useNavigate } from "react-router-dom"; import { useBookAppointmentDialog } from "../BookAppointmentDialog/BookAppointmentDialogProvider.tsx"; import { CalendarPlus, MessagesSquare, Phone } from "lucide-react"; import type { Organization } from "fhir/r4"; import { useAuthServiceContext } from "../../services/AuthService.tsx"; import { isAdolescentSelf } from "../../utils/adolescentFeatureGate.ts"; import { isOrglessPatientsHost } from "../../utils/hostedMount"; import { usePatientInboxUnread } from "../PatientInbox/patientInboxContext"; import { formatPhoneNumber } from "../../utils/phoneNumber.tsx"; import { formatFhirHumanName } from "../../utils/fhirHumanName.tsx"; import { PersonBadge } from "../ui/person-badge"; /** * What a PATIENT's frame header carries that nobody else's does. R1. * * Spencer's review of the built page: the header should be *"logo + practice name + phone + address * on the left, Schedule appointment - Contact us on the right, as one header band"* - and what shipped * instead put the phone, the address and both buttons in a **centred card in the page body that also * repeated the practice's name**, so the name was on screen twice and the card read as a stray. * * WHY THIS IS TWO SLOTS AND NOT ONE COMPONENT: they land on opposite ends of the band. Keeping them in * one file is what stops the pair drifting - the role test that decides whether a patient sees this is * written ONCE, and both halves read it. * * THE ROLE TEST IS NOT "is this the patient portal". It is who the viewer IS: a receptionist looking at * their own practice does not need its phone number read back to them, and a "Schedule appointment" * button in a staff frame is a control aimed at the wrong person. * * D6 IS PRESERVED WHOLE, and it is the reason this is in the frame rather than behind the hamburger. * Spencer, from direct observation: *"especially for older users they don't always understand the * hamburger menu concept... I've seen it first hand."* So: * - the practice's phone is ONE TAP - for this cohort the phone IS the fallback when the interface * defeats them, and hiding it behind a menu is the worst possible economy; * - Schedule appointment and Contact us are ALWAYS VISIBLE, never menu-only. The hamburger is IN * ADDITION to them, never a replacement. */ /** The first phone number the practice publishes. Fax and pager are not somewhere a patient calls. */ const practicePhone = (organization: Organization | null): string | undefined => organization?.telecom?.find((t) => t.system === "phone" && !!t.value)?.value; /** * `tel:` wants an unpunctuated number; the label wants a readable one. Both come from the same value, so * they can never disagree about which number the patient is calling. */ const telHref = (raw: string) => `tel:${raw.replace(/[^\d+]/g, "")}`; /** * Is the viewer a patient, or someone acting for one? * * A RELATED PERSON COUNTS. A parent managing a child's care needs the practice's phone and the two * utilities exactly as much as the patient does - arguably more, since they are the one making the call. */ export const useIsPatientViewer = (): boolean => { const { currentUser, USER_ROLES } = useAuthServiceContext(); const resourceType = currentUser?.fhir?.resourceType; return resourceType === USER_ROLES.PATIENT || resourceType === USER_ROLES.RELATED_PERSON; }; /** * The patient's own name and mark in the header, which is what a patient portal was missing. * * Every deck draws it at the right of line one, before the menu button. The built header named the * practice and never the person whose portal it is - so a parent acting for a child, on a screen full of * that child's record, had nothing on it saying whose account they were in. * * INITIALS RATHER THAN A PHOTO REQUEST. The frame is deliberately free of fetches: a previous attempt to * presign an image here was rejected because it drags the http and upload services into a component whose * own test is that it must not take the app down. The name is already on the authenticated identity, so * initials cost nothing and cannot fail. * * NO VERIFIED TICK YET, and that is a gap rather than a decision. The decks draw a green tick on the mark, * and identity proofing status is a fetch this component must not make. It needs to arrive through * something the frame already reads, and until it does an unconditional tick would be a claim nobody * checked - which is worse than the absence. * * Hidden for anyone who is not a patient or acting for one: a receptionist's frame has no patient. */ export const PatientFrameIdentity = () => { const { currentUser } = useAuthServiceContext(); const isPatientViewer = useIsPatientViewer(); const name = formatFhirHumanName( (currentUser?.fhir as { name?: Parameters[0] } | undefined)?.name, ); if (!isPatientViewer || !name) return null; return ( {/* * 🔻 THE SHARED PERSON MARK. Spencer, 2026-09-08: "The users profile image placeholder in * the header has no background." * * It had one - `bg-muted`, which IS the paper colour, on a header painted white. So the mark * was two grey letters floating with nothing behind them while every other mark on the page * sat on a tone. This is the same component the message rows use, so the person's colour is * the person's colour wherever they appear. * * It also carried its own two-letter derivation beside a shared `initialsOf` that does the * same job. That is how the org mark ended up drawing one practice in two colours, and it is * not worth repeating for a name. */} {/* The name is hidden below sm rather than dropped: on a phone the mark alone identifies the account, and the row has to keep the menu button reachable. It stays in the accessible name of the group either way, which is why the mark is aria-hidden and this is not. */} {name} {name} ); }; /** * Who is signed in: the patient chip above for a patient or someone acting for one, and for staff their * own mark on a phone only, before the menu button. From `sm` up the sidebar names the person. The name * stays in the accessible text; the mark is aria-hidden. */ export const FrameIdentity = () => { const { currentUser } = useAuthServiceContext(); const isPatientViewer = useIsPatientViewer(); if (isPatientViewer) return ; const name = formatFhirHumanName( (currentUser?.fhir as { name?: Parameters[0] } | undefined)?.name, ); if (!name) return null; return ( {name} ); }; /** * THE NUMBER, ALONE, AT THE RIGHT-HAND END OF LINE TWO. * * THE ADDRESS USED TO BE HERE and has moved into the brand block, stacked under the practice name - * which is where every deck draws it. The two facts answer different questions: the name and the * address together say WHO AND WHERE THIS PRACTICE IS, while the number is something you DO. Appended * to the phone, the address read as a second half of the same control. * * The deck's own note on this: the address is context beside the number, never inside it - a control * you can only press half of is worse than two things side by side. Moving it up the way the deck * does removes the question entirely. */ export const PatientFrameContact = () => { const { currentOrganization } = useAuthServiceContext(); const isPatientViewer = useIsPatientViewer(); const phone = practicePhone(currentOrganization ?? null); // NO PLACEHOLDER FOR A MISSING VALUE. Inventing "(no phone on file)" tells a patient something about // their practice that we do not know. if (!isPatientViewer || !phone) return null; return ( /* - THE PHONE NUMBER SURVIVES TO MOBILE AS TEXT, and this is the half of the settled header that cost something to get. It used to be `hidden sm:flex`, so the whole line - number included - disappeared on a phone, which is the width where a patient is most likely to be ringing rather than typing. CONDENSING THE THREE ACTIONS TO ICONS IS WHAT BOUGHT THE ROOM FOR IT: *"a number you can read beats one you have to tap to discover."* Dropping the number to fit the labels inverts that trade, so the labels are what give way, never this. The ADDRESS is what drops instead, and only from `lg` down. Nobody navigates by an address read off a phone header; they tap the number. */ {/* Inside the anchor, so the icon is part of the tap target rather than a decoration sitting next to one - on a phone the glyph is the easiest half to hit. `aria-hidden` because the link text already says what it is; announcing "phone" twice is worse than not announcing it. */} {formatPhoneNumber(phone)} ); }; /** * The right half of line two: the two things a patient can always do at their practice. * * SOLID for Schedule appointment, TONAL for Contact front desk (settled 2026-08-13). Making both * tonal flattened the one thing a patient comes here to do into the same weight as the fallback for * when the interface defeats them. Persistent and loud are different properties: always-visible answers * *can I find it*, visual weight answers *does it need me now*. * * "CONTACT US" BECAME "CONTACT FRONT DESK" -, from the settled mockup. *Us* is ambiguous in * exactly the place a stuck patient cannot afford ambiguity: this product has two "us"es, the practice * and Akashic, and they are reached by different controls sitting next to each other. Naming the desk * makes the pair legible without a word of explanation. * * EVERY ACTION SURVIVES TO MOBILE - NOTHING IS BANISHED TO THE DRAWER. That is the rule to hold when * the row gets tight, and it is D6 restated: Spencer, from direct observation, *"especially for older * users they don't always understand the hamburger menu concept... I've seen it first hand."* * So the two practice actions keep their icon at every width and shed only their LABEL below `sm`. * * AND THEY GET NO VISIBLE CAPTION UNDER THE ICON - settled, 2026-08-24. A caption would eat the row * the icons were meant to save, which would put the phone number back on the chopping block. They * carry `aria-label` AND `title` instead: zero pixels, and a screen reader still names them while a * long-press shows the name. The `aria-label` matches the visible text exactly, so the accessible * name is the SAME string at both widths rather than changing as the viewport moves. */ export const PatientFrameUtilities = () => { const navigate = useNavigate(); const { openBookAppointment } = useBookAppointmentDialog(); const isPatientViewer = useIsPatientViewer(); const inboxUnread = usePatientInboxUnread(); const { currentUser, actingAs } = useAuthServiceContext(); // A billing-only switch-in opens the subject's invoices and nothing else, so it books and messages nothing. // A messaging-only one opens their Messages and nothing else, so it books nothing. const billingOnly = actingAs?.scope === "billing"; const messagingOnly = actingAs?.scope === "messaging"; // Messaging is 18+, and the drawer already leaves it out for a signed-in 12-17 patient. The same // rule, on the same identity: a parent acting for their child keeps it. const showMessages = !isAdolescentSelf(currentUser) && !billingOnly; // Booking is practice-bound, and the org-less patients address has no practice to book with: its // drawer already leaves Appointments out, so the header does not offer a booking either. const showSchedule = !isOrglessPatientsHost() && !billingOnly && !messagingOnly; if (!isPatientViewer) return null; return ( <> {/* BOTH CARRY THE SAME TREATMENT, which is what the decks draw at every width — two `qbtn`s on the phone's quick row, two `wbtn`s on the laptop. It replaces solid-plus-tonal, which was this file's own reading ("solid for the thing they came to do, tonal for the fallback"); the approved drawing weighs them equally and the drawing decides. 34px square with the icon alone below 640px, straight off `.qbtn` in the decks, and the standard button's own 32px above it. Both floors live in `index.css` now: they used to be four `!important` utilities here, fighting the mount's blanket 44px minimum from the wrong file. The stylesheet wins that on specificity instead, and this element no longer states a size at all. The 34px is still the one place in this product where a drawn size and the tap-target rule disagree, and it is Spencer's ruling that it does. */} {showSchedule && ( )} {showMessages && ( )} {/* CARD 695 — LAST AND QUIETEST IN THE ROW, deliberately. The two above are settled and unequal for a stated reason (solid for the thing they came to do, tonal for the fallback when the interface defeats them). This is neither: it is a way OUT of the current practice, and it must not compete with either. Ghost, and last. It renders NOTHING for a patient with only one provider — "my other providers" shown to somebody who has none is a promise of something that is not there. The settled mockup moves this to the `` beside the practice name, where a switcher belongs; that move is card 695 and is deliberately NOT in this change, so either can be dropped at the gate without the other. */} {/* "My other providers" used to sit here and has been removed: it was a second control for the same thing as the chevron beside the practice name. Both opened the patient's other practices, and the chevron's dialog already carries the part that mattered - the practice's details and a Go to portal link - so nothing that was asked for is gone. The gate that must survive lives with the chevron: canSwitch is organizations.length > 1, so a patient with one practice still sees no control at all. A list of one implies somewhere else to be, which is worse than no control. */} {/* SUPPORT IS NOT RENDERED HERE, AND THAT IS A CORRECTION RATHER THAN AN OMISSION. An earlier version of this row carried a third control — an amber `MessageCircleQuestion` opening a Freshdesk URL in a new tab. **Two things were wrong with it.** ① Spencer, 2026-08-24, on what Support actually does: *"If the user can't find a solution to their question in the ai assistant we'll have a 'submit support ticket' button that will open up an embedded support ticket form."* **Freshdesk is the ESCALATION INSIDE the assistant, not the destination of the header control.** The header control's job is to open the assistant. ② **That control already exists and already sits in this corner.** `SetupHelpWidget` renders a tab labelled **Support** hanging off the header's bottom-right (card 683), and it opens the assistant panel. A second control with the same name, in the same place, going somewhere else is the worst possible outcome for the one affordance a stuck patient reaches for. The settled mockup's Support glyph IS that tab, drawn inside the header row instead of below it. **Moving it there is its own card** — it touches card 683's settled placement and the widget card 719 just fixed, and it must not ride along inside a header re-layout. */} ); }; /** * - THE WHOLE SECOND LINE, as one thing the frame either has or does not. * * WHY THIS EXISTS RATHER THAN THE FRAME COMPOSING THE TWO HALVES ITSELF: React cannot see that a * child rendered `null`, so a frame that always laid out a second row would reserve a row's worth of * gap for every receptionist, forever, for content that is never there. Returning `null` from ONE * component is the only way "this viewer has no second line" becomes true rather than merely invisible. * * And it keeps the role test written ONCE. The two halves land at opposite ends of the band and used * to test the role separately; a header where the phone number appears for a role the buttons do not is * the drift this shape makes unrepresentable. */ export const PatientFrameSecondRow = () => { const isPatientViewer = useIsPatientViewer(); if (!isPatientViewer) return null; /* * THE ACTIONS COME FIRST AND THE NUMBER IS PUSHED RIGHT, which is the deck's order and the reverse * of what was built. Its second row reads: Schedule appointment, Messages, a spacer, then the * telephone number hard against the right edge. * * IT IS NOT AN ARBITRARY SWAP. The two things a patient can DO sit under the brand they act on, and * the number - the one item on this row that leaves the product entirely - sits apart from them. * Built the other way the number led the row, so the first thing on the line was the fallback. * * `ml-auto` lives on the NUMBER now rather than on the action group, for the same reason it was on * the group before: whichever element is last takes the free space, so the pair stays together and * the gap between the buttons never changes as the row grows. */ return ( <> ); };