22 KiB
UI Phase 10 — Messaging & notifications
Mission: make support/coordination feel like a messaging app, not a form list. The optimistic-send plumbing underneath is excellent — but the surfaces betray it: a thread opens at the oldest message and never live-updates, the inbox cannot page past 20 tickets, the unread signals are dead on the real API (REQ-028 gap), every bubble carries a full Shamsi date-time, an alarm-red emergency banner with no phone number sits permanently on every inbox, and notifications are a flat absolute-timestamp list. This phase turns the platform's only sanctioned communication channel into a conversation.
Track: frontend · Depends on: Phases 0–2 · Unlocks: support/coordination feels like a messaging app, not a form list Before you start, read ../../phases/_shared/agent-operating-rules.md and invoke the frontend-designer skill — both are mandatory.
1. Context — where this sits
Messaging is ticket-based by product design (no free chat, no phone directory — see
product/business/12-messaging-and-emergencies.md).
A coordination ticket is auto-created for every confirmed booking, so an active nurse's inbox outgrows one
page quickly. Both systems are functionally complete and real-API-wired (USE_TICKETS_MOCK = false) —
what's missing is the messaging-app layer. Diagnosed, all verified in code:
- The inbox can't page or filter.
TicketInboxScreen.tsx:35callsuseMyTickets({})— page 1 / pageSize 20 forever, no load-more, no status chips — even though the hook (services/tickets/hooks/useMyTickets.ts:14) already acceptspage/pageSize/statusand keys the cache on the filter object. - Unread signals are dead on the real path.
unreadCount/lastMessageAtonTicketSummaryare documented mock-only (services/tickets/types.ts:56-59, "REQ-028 gap"), so on the real API the unread pill (TicketListCard.tsx:44) never shows and times silently fall back tocreatedAt. There is no last-message preview in the type at all. - Threads never live-update and open at the top.
useTicket.ts:15-21has staleTime/gcTime but norefetchInterval(contrast the pollinguseUnreadCount.ts:21);TicketMessageList.tsx:51-63has no scroll logic — a long thread opens at the oldest message; a reply never appears until blur/refocus. - Zero chat typography. Every bubble renders a full
formatShamsiDateTimestamp (TicketMessageList.tsx:58); no date separators, no same-author grouping, system messages render as ordinary bubbles.MessageBubble.tsx:70forcesdirection: 'ltr'on a Persian Shamsi string — bidi visually reorders the date/time segments. - The composer mis-handles mobile and failure.
MessageComposer.tsx:46-51— Enter always sends, even on touch keyboards (no way to type a newline on mobile); on failure the optimistic bubble rolls back and the only trace is a small caption, no retry, no aria-live (MessageComposer.tsx:55-59). Thesendicon (AppIcon/config.ts:85,187) is never RTL-mirrored — stylis flips CSS, not SVG glyphs — so in fa the paper plane points back into the text field. - Emergency affordance is wrong-sized.
TicketInboxScreen.tsx:57renders<EmergencyBanner>on every inbox with nocontactPhone— andEmergencyBanner.tsx:58only renders the call button when a phone exists, so the inbox banner tells users to "call the emergency contact" on a surface that can never show a number. The tel: contact only exists on the nurse's post-confirmation booking read. - Notifications are a flat list.
NotificationCenter.tsx:103stamps every row with an absolute Shamsi date-time; no day grouping; non-navigable rows (deepLink → null) are still ButtonBase cards that ripple and appear to do nothing (NotificationCenter.tsx:46-50). The bell is navigation-only (NotificationBell.tsx:31) even on desktop; the support entry has no unread badge (CustomerLayout.tsx:48-53); admin notifications is a deadPlaceholderScreen(admin/notifications/page.tsx:4-8) the admin nav links to.
What already exists (do not rebuild):
- The full messaging component set in
client/src/components/messaging/(TicketInboxScreen,TicketListCard,TicketThreadScreen,TicketMessageList,MessageBubble,MessageComposer,ContactSupportDialog,EmergencyBanner,BookingSupportEntry), all four-state, all tokenized. - The optimistic-send architecture:
usePostMessagewith clientMessageId reconciliation, draft cleared only on server confirm, composer remount-keyed per ticket (TicketThreadScreen.tsx:117-119). - The notifications system: polled auth-gated
useUnreadCount(only the bell container re-renders),NotificationCenter(unread-first, optimistic mark-read, mark-all, load-more), role-aware null-safeservices/notifications/deepLink.ts, per-kind icons incomponents/notifications/notificationIcon.ts. - The seams:
services/ticketsandservices/notifications(hooks/apis/keys/constants/types), the is_internal-free user types,ticketKeys/notificationKeyscache-key factories. - Foundation from phases 0–2: theme/token system + icon registry (0), shared primitives + relative time + state kits (1), the per-actor chrome with its header/nav slots (2).
2. Required reading (do this first)
- audit/messaging-notifications.md — the 16-problem inventory with file/line evidence, the opportunities this scope is drawn from, and the keep-list §5 restates.
- Code, in this order:
client/src/components/messaging/*(all nine components),client/src/services/tickets/{types.ts,constants.ts,keys.ts,hooks/*},client/src/components/notifications/*+client/src/services/notifications/*,client/src/layout/CustomerLayout.tsx+NurseLayout.tsx(the phase-2 chrome slots), andclient/src/utils/date.ts(you will add a time-only sibling to the two Shamsi formatters). - .claude/skills/frontend-designer/SKILL.md — invoke the skill; §2 tokens, §6 icon registry, §7 non-negotiables all bite here.
- product/business/12-messaging-and-emergencies.md (ticket-only channel, tel:-only emergency) and product/business/14-notifications-and-admin.md.
- REQ-028 in ../../shared-working-context/frontend/requests/for-backend.md — the existing inbox-enrichment request you will extend (see §4).
3. Scope — build this
3.1 Ticket inbox → a real inbox
- Pagination + filters. Expose what
useMyTicketsalready accepts: status filter chips (همه / باز / بسته — map to theTicketStatusvalues) and load-more/paging past pageSize 20.keepPreviousDatais already set, so chip switches must not flash. TicketListCardredesigned around unread + recency: bold subject + unread pill (existing behavior) plus a one-line last-message preview and a relative last-activity time (phase-1 relative-time formatter, decaying to Shamsi).unreadCount/lastMessageAtare mock-only andlastMessagePreview/ last-author-role don't exist at all — extend the contract via REQ (§4) and build mock-tolerant fallbacks: when the enrichment fields are absent (the real path today), the card degrades gracefully to subject + status chip +createdAttime — never an empty slot, never a fake "0 unread".referenceCodestays prominent.- Support-entry unread badge in the chrome. Add a
useSupportUnreadTotal()(or equivalent) read behind theservices/ticketsseam — the mock sums itsunreadCounts; the real implementation returns nothing until the REQ lands, and the badge renders only when a signal exists. Mount it as aBadgeon the phase-2 customer TopBar support entry (CustomerLayout.tsx:48-53) and the nurse nav's support item — a minimal touch to phase-2-ownedlayout/files; note it in your report per the ownership rules.
3.2 Live thread
- Poll while mounted. Give
useTicket/useTicketThreadarefetchInterval(newTICKET_THREAD_REFETCH_INTERVALconstant inservices/tickets/constants.ts; ~15s is proportionate) — TanStack Query only polls while the query has active observers, so this is automatically scoped to the mounted thread screen. Do not setrefetchIntervalInBackground. SSE replaces this later; the seam stays. The global polling posture (§5) is unchanged. - Scroll orchestration. Thread opens scrolled to the newest message. On send: always scroll to
the new bubble. On receive: auto-scroll only when the user is already near-bottom (~120px); otherwise a
floating «پیام جدید ↓» pill that scrolls-to-newest on tap and dismisses on reaching bottom. Build this
as a reusable hook (e.g.
useThreadScroll) — phase 11's admin thread has the inverse bug (a 520px scrollbox that also opens at the top) and will consume it (§3.7). - Chat typography. In
TicketMessageList: centered Shamsi date separators (امروز / دیروز / ۲۵ تیر — derive day labels from the existingIntlfa-IR-u-ca-persianplumbing inutils/date.ts; add a time-onlyformatShamsiTimethere as a minimal foundation extension); consecutive same-author messages group under one author label; bubbles show hh:mm only (the full date lives on the separator);authorRole === 'system'renders as a centered neutral event line (chip-style), not a bubble — a coordination thread must read as a timeline, not a stranger's messages. - Fix the bidi timestamp bug. Remove
direction: 'ltr'from the bubble time label (MessageBubble.tsx:70) — with hh:mm-only Persian-digit stamps no forced direction is needed. Keep the forced LTR only on the LatinreferenceCode. - Sticky composer separation. The sticky strip (
TicketThreadScreen.tsx:100-107) is a bare bgcolor block — give it a real top hairline (divider) or a phase-0 elevation token so bubbles no longer scroll flush into the input. While there, align the messaging surface widths (inbox 640 / thread 720) to one.
3.3 Composer
- Enter semantics per input modality. Enter=send + Shift+Enter=newline on desktop only; on touch
(coarse pointer —
matchMedia('(pointer: coarse)')oruseIsMobile()), Enter inserts a newline and the explicit send button is the only send path. The customer shell is mobile-first; touch keyboards have no Shift+Enter. - Retry-in-place on failure. Today
usePostMessagerolls the optimistic bubble back, leaving only caption text. Instead: keep the failed bubble in place withsendStatus: 'failed'(theMessageSendStatusunion already includes it —types.ts:40), error-token accented, with «تلاش مجدد» (re-mutates with the sameclientMessageId) and a delete affordance that restores the text to the composer. Announce the failure viarole="alert"/aria-live— screen readers are currently never told. The invariant that survives any mechanism change: a failure never loses typed text, and clientMessageId reconciliation never double-renders (§5). Update the co-located tests to prove both. - Attachment affordance — designed, gated. Refund/coordination tickets need photo evidence; the
object-storage seam exists server-side (verification docs). Design the composer attachment button +
pending-upload chip now, but render it only when the contract lands (REQ in §4) — behind a
capability flag in
services/tickets/constants.ts, default off. No dead buttons in production. - Mirror the send icon in RTL. stylis-plugin-rtl flips CSS, not SVG glyphs; Material's own RTL list
names Send as must-mirror. Use the phase-0 auto-mirroring icon strategy for the
sendregistry entry (AppIcon/config.ts:85,187); if phase 0 shipped no such mechanism, add a minimal registry-level mirror (scaleX(-1)underdir="rtl") and note the foundation extension in your report.
3.4 Emergency affordance right-sizing
- Keep the full
EmergencyBanner(error accent + tel: click-to-call) only where the phone exists: the nurse post-confirmation booking read (BookingSupportEntry). That placement is untouched. - In both ticket inboxes, replace the permanent banner (
TicketInboxScreen.tsx:57) with a compact, neutral «موارد اضطراری» row (collapsed by default, expands to the playbook copy + "open a ticket"). Rewrite the customer-side copy so it no longer instructs calling a number the customer can never see. tel:-only stays the law — no VoIP, no phone directory, nothing new out-of-band (§5).
3.5 Notification center
- Day grouping: section headers امروز / دیروز / این هفته, then Shamsi date headers for older items.
- Relative timestamps via the phase-1 relative-time formatter («۵ دقیقه پیش»), decaying to Shamsi for
older rows — replacing the absolute
formatShamsiDateTimeon every row (NotificationCenter.tsx:103). - Per-kind visual identity: soft-tinted icon containers — booking teal, payout success, alert warning
— from
--bal-*tokens only (add-softtokens in both scheme blocks +colors.tsmirror if phase 0 didn't ship them; note the extension). - Non-navigable rows rendered non-interactive: when
notificationDeepLinkreturns null, render a plain surface (no ButtonBase, no ripple, no pointer cursor) that still supports mark-read; navigable rows get a trailing chevron (registry icon from phase 0/1 — register one if missing) and a visible:focus-visiblestyle. Keep the mark-read UX as is — per-row mark-read-on-open and mark-all-read.
3.6 Bell behavior
- Desktop popover preview on the nurse shell (and the admin shell once phase 11 gives it a feed —
§3.7): the bell opens a
Popoverwith the 5 most recent notifications, mark-all-read, and «مشاهده همه» linking to the full center. The popover fetches the list on open (reusing thenotificationKeyscache), never on the poll tick. - Mobile keeps direct navigation to the notification center — no popover on the customer shell.
- Optional polish: a one-shot badge pulse when the count increases (respect
prefers-reduced-motion). - Do not regress the isolation: ONLY the bell container subscribes to the polled count
(
useUnreadCount) — the shell and the popover contents never do.
3.7 Admin notifications placeholder — phase 11 handshake
admin/notifications/page.tsx is a PlaceholderScreen the admin nav links to.
Phase 11 owns the admin tree and decides whether to build an
admin alert feed. This phase's job: if phase 11 hasn't shipped that feed when this runs, hide the dead
admin nav entry (a "coming soon" page in a staff backoffice erodes trust) and record the handshake in your
report. Export useThreadScroll (§3.2) and the bell popover as consumables — phase 11's admin thread
scrollbox needs the same scroll fix. Do not build admin surfaces here.
4. Mocks & seams in this phase
No new mocks or seams. Everything stays behind the existing services/tickets and
services/notifications seams; the mock ticket store keeps supplying the enrichment fields so the full
inbox design is demonstrable offline, and the real path degrades gracefully per §3.1.
Backend gaps become REQ entries appended to ../../shared-working-context/frontend/requests/for-backend.md. REQ-001…038 are taken — check the tracker's highest number at execution time and number onward:
- REQ-039 (indicative) — Ticket inbox enrichment, extension of REQ-028: re-assert
unreadCount+lastMessageAtonTicketSummaryDto, addlastMessagePreview(first ~80 chars, internal notes excluded server-side) + the last message's author role, plus an unread-total read for the chrome badge. Cross-reference REQ-028 rather than duplicating its rationale. - REQ-040 (indicative) — Ticket message photo attachments: upload + serve via the existing object-storage seam, message-attachment linkage, size/type limits. Gates the §3.3 attachment affordance.
5. Critical rules you must not get wrong
- The optimistic-send architecture stays. clientMessageId reconciliation (never a double bubble), no typed text ever lost on failure, composer remount-keyed per ticketId so drafts/in-flight state never cross threads. §3.3 changes the failure presentation, not these invariants — tests must prove them.
is_internalNEVER appears in user-app types or UI. The user-sideservices/ticketstypes don't model it and no component renders it; the REQ you file must keep internal notes excluded fromlastMessagePreviewserver-side. Airtight — do not regress.- referenceCode prominence stays — inbox card, thread header, creation-success dialog, LTR-forced.
- Polling stays polite. The 60s auth-gated count poll remains the only global poll; the thread poll is strictly while-mounted; lists refetch on focus/invalidation, never on an interval.
- Emergency is tel:-only, nurse-post-confirmation-only. No VoIP, no new phone surfaces, no contact directory — the anti-disintermediation rule is product law.
- Design-contract non-negotiables: every new string in both catalogs (ICU plurals for unread
counts); colors from
--bal-*tokens / palette keys, never hexes; logical properties only (the bubble-tailborderStartEndRadiuspattern is the house style); verify dark mode on every new tint; MUI v9 API only; icon registry, not raw imports; co-located*.test.tsxfor every touched/new shared component;clientFetch/cookie rules untouched.
6. Definition of Done
On top of the shared definition-of-done.md:
npm run checkgreen;npm run test:cigreen including updated messaging/notification component tests;en.json/fa.jsonin sync.- Inbox: status chips filter, paging reaches ticket #21+, cards show preview/unread/relative-time from the mock and degrade gracefully (no empty slots) on the real API.
- Thread: opens at the newest message; a reply arriving while mounted appears within the poll interval; scrolled-up + new message shows «پیام جدید ↓»; date separators + grouping + hh:mm stamps + centered system events render; the fa timestamp no longer bidi-scrambles.
- Composer: touch Enter newlines, desktop Enter sends; a failed send leaves a retry-able failed bubble (aria-live announced) and retry never duplicates; the send icon points out of the field in fa.
- Inboxes show the compact emergency row (no permanent red banner); the nurse booking-detail tel: banner is unchanged.
- Notification center is day-grouped with relative times and per-kind tints; null-deepLink rows don't ripple; mark-read/mark-all still work. Nurse desktop bell opens the popover; customer mobile bell still navigates.
- Admin nav no longer links to a placeholder (hidden, or phase 11's feed exists).
- Visual verification on the four axes —
/fa+/en× light + dark — mobile and desktop. - REQs filed in the tracker with correct next numbers; no
server/edits.
7. How to test (what a human can verify after this phase)
- Flip
USE_TICKETS_MOCK = true, open/facustomer → پشتیبانی: inbox shows unread pills, previews, relative times; filter by بسته; page past 20 tickets; the TopBar support entry shows the unread badge. - Flip the mock off (real API): cards show subject + status + Shamsi time — nothing broken or blank; the badge simply doesn't render.
- Open a long thread → opens at the newest message of a date-separated, author-grouped conversation with centered system events. Scroll up, have the other side reply → «پیام جدید ↓»; tap → scrolls to newest.
- Send a message → bubble appears instantly, hh:mm stamp on confirm. Kill the API and send → failed bubble with «تلاش مجدد»; restore the API, retry → exactly one bubble.
- On a touch viewport (devtools emulation), Enter in the composer inserts a newline; on desktop, Enter
sends. In
/fa, the send arrow points out of the field. - Inbox shows a compact «موارد اضطراری» row that expands to the playbook; the red click-to-call banner appears only on the nurse's confirmed-booking detail.
- Notification center (
/fa+/en, light + dark): امروز/دیروز groups, «۵ دقیقه پیش» decaying to Shamsi, tinted per-kind icons; a null-deepLink row doesn't ripple; navigable rows show a chevron. - Nurse desktop: bell opens the popover (5 recent + mark-all + «مشاهده همه»); customer mobile: bell navigates. In devtools: only the count endpoint polls, plus the thread endpoint while a thread is open.
- Admin shell: no dead "notifications — coming soon" nav entry.
8. Hand off & document (close the phase)
- Update
client/CLAUDE.md"Project Structure" if you added components/hooks folders (thread scroll hook, popover, emergency row) — same change, per the working agreements. - Write the report at
dev/shared-working-context/reports/ui-phase-10-report.md: what shipped, the REQ numbers filed (with the REQ-028 cross-reference), the foundation files minimally extended (layout badge slots,formatShamsiTime, icon mirror,-softtokens — per the README ownership rules), the phase-11 handshake state, and the mock-tolerant degradations that light up when REQ-039 lands. - Save a memory note per operating-rules §8: messaging/notifications are now chat-grade; the surviving invariants (optimistic-send, is_internal boundary, polite polling, tel:-only emergency); open REQ gates.