# 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](ui-phase-2-shells-and-navigation.md) ·
> **Unlocks:** support/coordination feels like a messaging app, not a form list
> **Before you start, read [../../phases/_shared/agent-operating-rules.md](../../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](../../../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:

1. **The inbox can't page or filter.** `TicketInboxScreen.tsx:35` calls `useMyTickets({})` — page 1 /
   pageSize 20 forever, no load-more, no status chips — even though the hook
   (`services/tickets/hooks/useMyTickets.ts:14`) already accepts `page`/`pageSize`/`status` and keys the
   cache on the filter object.
2. **Unread signals are dead on the real path.** `unreadCount`/`lastMessageAt` on `TicketSummary` are
   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 to `createdAt`. There is no
   last-message preview in the type at all.
3. **Threads never live-update and open at the top.** `useTicket.ts:15-21` has staleTime/gcTime but no
   `refetchInterval` (contrast the polling `useUnreadCount.ts:21`); `TicketMessageList.tsx:51-63` has no
   scroll logic — a long thread opens at the oldest message; a reply never appears until blur/refocus.
4. **Zero chat typography.** Every bubble renders a full `formatShamsiDateTime` stamp
   (`TicketMessageList.tsx:58`); no date separators, no same-author grouping, system messages render as
   ordinary bubbles. `MessageBubble.tsx:70` forces `direction: 'ltr'` on a Persian Shamsi string — bidi
   visually reorders the date/time segments.
5. **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`). The
   `send` icon (`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.
6. **Emergency affordance is wrong-sized.** `TicketInboxScreen.tsx:57` renders `<EmergencyBanner>` on
   every inbox with no `contactPhone` — and `EmergencyBanner.tsx:58` only 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.
7. **Notifications are a flat list.** `NotificationCenter.tsx:103` stamps 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 dead `PlaceholderScreen`
   (`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: `usePostMessage` with 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-safe
  `services/notifications/deepLink.ts`, per-kind icons in `components/notifications/notificationIcon.ts`.
- The seams: `services/tickets` and `services/notifications` (hooks/apis/keys/constants/types), the
  is_internal-free user types, `ticketKeys`/`notificationKeys` cache-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](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), and
  `client/src/utils/date.ts` (you will add a time-only sibling to the two Shamsi formatters).
- [.claude/skills/frontend-designer/SKILL.md](../../../.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](../../../product/business/12-messaging-and-emergencies.md)
  (ticket-only channel, tel:-only emergency) and [product/business/14-notifications-and-admin.md](../../../product/business/14-notifications-and-admin.md).
- REQ-028 in [../../shared-working-context/frontend/requests/for-backend.md](../../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 `useMyTickets` already accepts: status filter chips (همه / باز /
  بسته — map to the `TicketStatus` values) and load-more/paging past pageSize 20. `keepPreviousData` is
  already set, so chip switches must not flash.
- **`TicketListCard` redesigned 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`/`lastMessageAt` are mock-only and `lastMessagePreview`/
  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 + `createdAt` time — never an empty slot, never a fake "0 unread". `referenceCode`
  stays prominent.
- **Support-entry unread badge in the chrome.** Add a `useSupportUnreadTotal()` (or equivalent) read
  behind the `services/tickets` seam — the mock sums its `unreadCount`s; the real implementation returns
  nothing until the REQ lands, and the badge renders **only when a signal exists**. Mount it as a `Badge`
  on the phase-2 customer TopBar support entry (`CustomerLayout.tsx:48-53`) and the nurse nav's support
  item — a minimal touch to phase-2-owned `layout/` files; note it in your report per the ownership rules.

### 3.2 Live thread

- **Poll while mounted.** Give `useTicket`/`useTicketThread` a `refetchInterval` (new
  `TICKET_THREAD_REFETCH_INTERVAL` constant in `services/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** set `refetchIntervalInBackground`. 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 existing `Intl` `fa-IR-u-ca-persian` plumbing in `utils/date.ts`; add a
  time-only `formatShamsiTime` there 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 Latin `referenceCode`.
- **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)')` or `useIsMobile()`), 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 `usePostMessage` rolls the optimistic bubble back, leaving only
  caption text. Instead: keep the failed bubble in place with `sendStatus: 'failed'` (the
  `MessageSendStatus` union already includes it — `types.ts:40`), error-token accented, with «تلاش مجدد»
  (re-mutates with the **same** `clientMessageId`) and a delete affordance that restores the text to the
  composer. Announce the failure via `role="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 `send` registry entry
  (`AppIcon/config.ts:85,187`); if phase 0 shipped no such mechanism, add a minimal registry-level mirror
  (`scaleX(-1)` under `dir="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 `formatShamsiDateTime` on every row (`NotificationCenter.tsx:103`).
- **Per-kind visual identity:** soft-tinted icon containers — booking teal, payout success, alert warning
  — from `--bal-*` tokens only (add `-soft` tokens in **both** scheme blocks + `colors.ts` mirror if
  phase 0 didn't ship them; note the extension).
- **Non-navigable rows rendered non-interactive:** when `notificationDeepLink` returns 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-visible` style. **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 `Popover` with the 5 most recent notifications, mark-all-read, and «مشاهده همه»
  linking to the full center. The popover fetches the list **on open** (reusing the `notificationKeys`
  cache), 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](ui-phase-11-admin-and-partner-console.md) 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](../../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` + `lastMessageAt` on `TicketSummaryDto`, **add** `lastMessagePreview` (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_internal` NEVER appears in user-app types or UI.** The user-side `services/tickets` types don't
  model it and no component renders it; the REQ you file must keep internal notes excluded from
  `lastMessagePreview` server-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-tail `borderStartEndRadius` pattern is the house style); verify dark mode on every new tint;
  MUI v9 API only; icon registry, not raw imports; co-located `*.test.tsx` for every touched/new shared
  component; `clientFetch`/cookie rules untouched.

## 6. Definition of Done

On top of the shared [definition-of-done.md](../../phases/_shared/definition-of-done.md):

- [ ] `npm run check` green; `npm run test:ci` green including updated messaging/notification component
      tests; `en.json`/`fa.json` in 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)

1. Flip `USE_TICKETS_MOCK = true`, open `/fa` customer → پشتیبانی: inbox shows unread pills, previews,
   relative times; filter by بسته; page past 20 tickets; the TopBar support entry shows the unread badge.
2. Flip the mock off (real API): cards show subject + status + Shamsi time — nothing broken or blank; the
   badge simply doesn't render.
3. 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.
4. 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.
5. 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.
6. 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.
7. 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.
8. 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.
9. 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, `-soft` tokens — 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.
