281 lines
22 KiB
Markdown
281 lines
22 KiB
Markdown
# 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.
|