# Notifications, messaging/tickets, and content surfaces across actors
## Current state
Notifications are a small, well-factored system: `NotificationBell` (container, polled `useUnreadCount` at 60s interval, auth-gated) + `NotificationBellView` (Badge over `AppIcon icon="notifications"`, tokenized error-red badge) mounted in both `CustomerLayout` (top bar end) and `NurseLayout` (headerActions). The bell navigates to a full-page `NotificationCenter` (`client/src/components/notifications/NotificationCenter.tsx`), shared by `(customer)/notifications/page.tsx` and `nurse/notifications/page.tsx`, which renders an unread-first flat list of `NotificationRow` cards (ButtonBase, unread = dot + fontWeight 800 + `--bal-primary-soft` tint), mark-read-on-open (optimistic), mark-all-read, load-more by growing a `limit`, and skeleton/empty/error-retry states. Deep links are centralized and role-aware in `services/notifications/deepLink.ts`; icons per kind in `notificationIcon.ts`. Admin has no notification center — `admin/notifications/page.tsx` is a `PlaceholderScreen`.
Messaging is ticket-based (the only sanctioned channel, by product design): `TicketInboxScreen` (title + "contact support" CTA + always-on `EmergencyBanner` + flat `TicketListCard` list) shared by customer `/support/tickets` and `/nurse/support/tickets`; `TicketThreadScreen` (`referenceCode` header card + `TicketMessageList` bubbles + sticky `MessageComposer`) shared by the two `[id]` pages. `MessageBubble` does mine/theirs alignment with logical corner radii (RTL-mirrors automatically), teal fill for mine, paper+border for theirs, author label only on theirs, full Shamsi date-time per bubble. Sends are genuinely optimistic (`usePostMessage`): clientMessageId reconciliation, draft cleared only on server confirm, pending bubble at 0.75 opacity with a "sending…" label, rollback + caption error on failure; the composer is remount-keyed per ticket so drafts never cross threads. `ContactSupportDialog` handles category/subject/body then shows the created `referenceCode` with a "view thread" action; `BookingSupportEntry` hangs off booking detail and shows the nurse-only post-confirmation `EmergencyBanner` with the sole sanctioned `tel:` click-to-call. The admin thread (`admin/tickets/[id]/page.tsx`) is a separate build: a 520px scrollbox of `AdminMessageBubble` (internal notes = dashed warning border + badge), a reply/internal ToggleButtonGroup composer, and an inline collapsible `RefundPanel`. Everything is styled from `--bal-*` CSS tokens (both schemes), all icons are stock MUI Material icons via the `AppIcon` registry, all list surfaces have loading/empty/error states, and copy in `messages/fa.json` is human and decent. Critically, `USE_TICKETS_MOCK = false` (real API) while `unreadCount`/`lastMessageAt` on `TicketSummary` are documented as mock-only (REQ-028), and the thread query has no refetch interval — so several "messaging" behaviors exist only under the mock.
## Problems (16)
- **[high]** `client/src/components/messaging/TicketInboxScreen.tsx` — The inbox has no pagination and no status/category filter: `useMyTickets({})` pins page 1 / pageSize 20 with no load-more UI, so any ticket beyond the first 20 is unreachable. This is not theoretical — a coordination ticket is auto-created for every confirmed booking, so an active nurse's inbox outgrows one page quickly. The hook already supports `status` and `page`; the screen just never exposes them.
- evidence: line 35: `const { data, isLoading, isError, refetch } = useMyTickets({});` — no setLimit/setPage anywhere, unlike NotificationCenter's load-more
- **[high]** `client/src/components/messaging/TicketListCard.tsx` — On the real API (USE_TICKETS_MOCK=false in services/tickets/constants.ts:15) the inbox's core messaging affordances are dead: `unreadCount` and `lastMessageAt` are mock-only (REQ-028 gap, types.ts:56-59), so no unread pill, no bold-unread subject, and times silently fall back to `createdAt`. There is also no last-message preview snippet at all (not even in the type). Users cannot tell which ticket has new support/nurse activity — the single most important signal of an inbox.
- evidence: types.ts:56-59 "REQ-028 gap — mock-only until delivered"; TicketListCard.tsx:44 `(ticket.unreadCount ?? 0) > 0`
- **[high]** `client/src/services/tickets/hooks/useTicket.ts` — The thread never live-updates: `useTicket`/`useTicketThread` have staleTime 15s but no `refetchInterval`, so a support or nurse reply that arrives while the user is sitting on the thread never appears until they blur/refocus the window or navigate away. For the platform's only communication channel this is below messaging baseline — the user sends a message and stares at a screen that will not answer.
- evidence: useTicket.ts:15-21 — queryKey/staleTime/gcTime only, no refetchInterval (contrast useUnreadCount.ts:21 which polls)
- **[high]** `client/src/components/messaging/TicketMessageList.tsx` — No scroll management anywhere in the thread: messages render as a plain flow list with no scroll-to-newest on open or on send/receive, so a long thread opens at the top (oldest message) and the user must manually scroll down past the history every visit. The admin thread has the inverse bug — a `maxHeight: 520, overflowY: 'auto'` box that also opens scrolled to the top, hiding the newest messages below the fold inside the scrollbox.
- evidence: TicketMessageList.tsx:51-63 — no ref/useEffect/scrollIntoView; admin/tickets/[id]/page.tsx:150
- **[medium]** `client/src/components/messaging/MessageBubble.tsx` — The bubble timestamp forces `direction: 'ltr'` on a Persian Shamsi string (formatShamsiDateTime for fa yields e.g. "۲۵ تیر ۱۴۰۵، ۱۴:۳۰" with a Persian month name). Forcing an RTL-language string into an LTR paragraph makes bidi reorder its segments visually (time/date tokens swap around the comma). `direction:'ltr'` is correct for the Latin referenceCode but wrong here.
- evidence: MessageBubble.tsx:71 `direction: 'ltr'` on the timeLabel Typography
- **[medium]** `client/src/components/common/AppIcon/config.ts` — The `send` icon (SendOutlined paper plane) is never mirrored for RTL. The theme's stylis-plugin-rtl mirrors generated CSS, not SVG glyphs, so in the default fa layout the send arrow points right — back into the text field instead of toward the inline send direction. Material's own RTL guidance lists Send among icons that must be mirrored.
- evidence: config.ts:85 `import SendIcon from '@mui/icons-material/SendOutlined'`; no scaleX(-1)/rtl transform anywhere in client/src (grep confirmed)
- **[medium]** `client/src/components/messaging/MessageComposer.tsx` — Enter always sends — including on mobile touch keyboards, where Enter is how users write a multi-line message. The customer shell is explicitly mobile-first; standard chat behavior is Enter=send on desktop only, and a send button tap on touch. There is also no way to insert a newline on mobile at all (Shift+Enter doesn't exist on touch keyboards).
- evidence: MessageComposer.tsx:46-51 `if (event.key === 'Enter' && !event.shiftKey) { … submit(); }` with no pointer/viewport check
- **[medium]** `client/src/components/messaging/TicketInboxScreen.tsx` — The alarm-red EmergencyBanner renders permanently at the top of every ticket inbox with no `contactPhone` (the inbox never has one — the tel: contact only exists on the nurse's post-confirmation booking read). The copy tells users to "first call the emergency contact" on a surface that cannot show any number, which is confusing, and a full error-colored banner as permanent inbox chrome produces alarm fatigue and pushes the actual ticket list below the fold on mobile.
- evidence: line 57 `` — contactName/contactPhone never passed; EmergencyBanner.tsx:58 renders the call button only `phone ? …`
- **[medium]** `client/src/components/notifications/NotificationRow.tsx` — Navigable and non-navigable notifications are visually identical ButtonBase cards: a `kind:'none'` row (deepLink returns null) still ripples on click and silently only marks itself read — the tap appears to do nothing. No chevron/affordance distinguishes rows that open something, and the ButtonBase has no hover state and no visible :focus-visible style, so keyboard users get no focus indication on either notifications or ticket cards (TicketListCard has the same construction).
- evidence: NotificationRow.tsx:30-43 sx has static bgcolor/border only; NotificationCenter.tsx:46-50 `if (target) router.push(…)` with no UI differentiation
- **[medium]** `client/src/components/notifications/NotificationBell.tsx` — The bell is navigation-only: clicking it always route-pushes to the full notifications page, even on the nurse/admin desktop shells where the expected pattern is a popover preview (recent items + mark-all + "view all"). Full-page context switch for glancing at notifications is heavy on desktop; there is also no visual acknowledgment (animation/pulse) when the polled count increments.
- evidence: NotificationBell.tsx:31 `onClick={() => router.push(`/${locale}${notificationsPath(role)}`)}`
- **[medium]** `client/src/components/messaging/TicketMessageList.tsx` — The thread has zero chat typography structure: every bubble carries a full "day longMonth year, hh:mm" Shamsi timestamp (huge for chat), there are no date separators, no grouping of consecutive messages from the same author, and `system` messages (author_system = "سیستم") render as ordinary left-side bubbles instead of centered event lines — so an auto-created coordination thread reads as a raw list, not a conversation.
- evidence: TicketMessageList.tsx:58 `timeLabel={formatShamsiDateTime(message.createdAt, locale)}` for every message; no separator logic in the map
- **[medium]** `client/src/layout/CustomerLayout.tsx` — New support activity is invisible in the app chrome: the top-bar support entry is a bare AppIconButton with no unread badge (unlike the notification bell), and the 5-tab BottomBar has no support presence at all — so a customer with an unanswered support reply sees nothing anywhere unless a notification also fires. Partly blocked by REQ-028, but the affordance isn't even scaffolded.
- evidence: lines 48-53 `` with no Badge wrapper
- **[low]** `client/src/components/messaging/MessageComposer.tsx` — Send failure feedback is a bare caption line above the input with no retry button and no aria-live region — the optimistic bubble disappears (rolled back) and the only trace is small error text; a user mid-scroll can easily believe the message was sent. Screen readers are never told the send failed.
- evidence: lines 55-59 — plain Typography, no role="alert"/aria-live, no retry action
- **[low]** `client/src/components/messaging/TicketThreadScreen.tsx` — The sticky composer strip is only a bgcolor block — no top border, shadow, or fade — so message bubbles scroll flush into/under it and visually collide with the input. Also the surface widths are inconsistent across sibling messaging screens: inbox 640px, thread 720px, admin thread 820px.
- evidence: lines 100-107 `position:'sticky', bottom:0, bgcolor:'var(--bal-bg-default)'` and nothing else; TicketInboxScreen.tsx:41 maxWidth 640 vs TicketThreadScreen.tsx:45 maxWidth 720
- **[low]** `client/src/components/notifications/NotificationCenter.tsx` — The error empty-state icon is `error` → MUI `Dangerous` (a filled hazard glyph) for a mundane "couldn't load" state — over-severe and one of several filled icons (Info, Star, Dangerous) mixed into an otherwise Outlined icon set on these surfaces, reinforcing the default-MUI look. Load-more also just grows `limit` and refetches the whole list from offset 0 (O(n) payload growth per click).
- evidence: line 79 ``; config.ts:21 `DangerousIcon`; line 111 `setLimit((current) => current + NOTIFICATIONS_PAGE_SIZE)`
- **[low]** `client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx` — Admin notifications is still a PlaceholderScreen while the admin nav links to it — a dead-end "coming soon" page in a shipped backoffice.
- evidence: lines 4-8 return ``
## Opportunities (9)
- **Deliver REQ-028 and rebuild the ticket inbox as a real messaging inbox** (impact: high, effort: medium) — Land the contract fields (unreadCount, lastMessageAt, plus a lastMessagePreview snippet and author role of the last message) and redesign TicketListCard around them: bold subject + one-line last-message snippet, unread pill, relative last-activity time, ordering by activity. Add status filter chips (باز/بسته) and load-more/pagination using the params useMyTickets already accepts. This turns a static ticket registry into an inbox users check willingly.
- **Make the thread live: poll-while-mounted + scroll orchestration** (impact: high, effort: medium) — Give the ticket detail query a refetchInterval while the thread screen is mounted (the seam already exists; SSE can replace it later), auto-scroll to the newest message on open and on send/receive, and show a floating "پیام جدید" pill when the user has scrolled up. Pair with a subtle enter animation for new bubbles. This single change is the biggest step toward messaging-app quality on the only communication channel the platform allows.
- **Chat-grade thread typography: date separators, grouping, time-only stamps, system event lines** (impact: high, effort: medium) — Insert centered Shamsi date separators (امروز / دیروز / ۲۵ تیر), collapse consecutive same-author messages into a group with one author label, show hh:mm-only inside bubbles (full date on long-press/hover), and render authorRole 'system' as centered chip-style event lines (e.g. "تیکت هماهنگی ایجاد شد"). Give the support author a terracotta-tinted label/avatar dot so "a human from Balinyaar" reads warm and distinct — a direct trust cue in a healthcare product.
- **Desktop notification popover + bell micro-interaction** (impact: medium, effort: medium) — On the nurse/admin desktop shells, make the bell open a Popover with the 5 most recent notifications, mark-all-read, and a "view all" link to the full center; keep the direct navigation on the mobile customer shell. Add a one-shot badge pulse/ring animation when the polled count increases so arriving activity is felt, and a document.title/favicon unread hint since polling is 60s.
- **Notification center: day grouping, relative time, per-kind visual identity** (impact: medium, effort: small) — Group rows under امروز / دیروز / این هفته headers, use relative timestamps ("۵ دقیقه پیش") that decay into Shamsi dates, and give each kind a soft tinted icon container (booking teal, payout success-green, ticket terracotta, refund info) instead of the uniform primary icon — the list gains scannable hierarchy without new data. Add a trailing chevron only on rows that deep-link.
- **Right-size the emergency affordance per surface** (impact: medium, effort: small) — Keep the full red banner with tel: only where the phone exists (nurse booking detail, post-confirmation). In the ticket inboxes, replace the permanent alarm banner with a compact, neutral "موارد اضطراری" help row that expands to the playbook copy — and on the customer side rewrite the copy so it doesn't instruct calling a number the customer can never see. Reduces alarm fatigue while keeping the safety path one tap away.
- **Ticket lifecycle + trust affordances** (impact: medium, effort: medium) — Add user-side close/reopen actions, a post-resolution satisfaction prompt, and — most valuable for trust — an expected-response-time promise in the thread and after ticket creation ("پشتیبانی معمولاً ظرف ۲ ساعت پاسخ میدهد") plus a "support has seen this" state when staff first views. In a marketplace where disputes are money-adjacent, telling families when to expect an answer is a product feature, not copy.
- **Composer upgrades: photo attachments, retry-in-place, mobile keyboard behavior** (impact: medium, effort: large) — Refund and coordination tickets routinely need evidence (receipts, care-situation photos); add an attachment affordance to the composer (the object-storage seam already exists server-side for verification docs). Convert send failure into a failed bubble with an inline "تلاش مجدد" chip instead of the vanish-and-caption pattern, add aria-live, and make Enter insert a newline on touch devices.
- **Build the admin notifications surface or remove the nav entry** (impact: low, effort: small) — Either implement a minimal admin alert feed (b1 alert facade already exists) behind the placeholder route, or drop the nav item until it exists — a "coming soon" page in a staff backoffice erodes confidence in the rest of the console.
## Keep (do not regress)
- The optimistic-send architecture is genuinely excellent: clientMessageId reconciliation (never a double bubble), draft cleared only on server confirm so a failure never loses typed text, and the composer remount-keyed per ticketId so drafts/in-flight state never leak across threads (TicketThreadScreen.tsx:117-119, MessageComposer.tsx:42).
- RTL discipline via logical properties throughout: borderStartEndRadius/borderStartStartRadius bubble tails, marginInlineStart:'auto', borderInlineStart accent on EmergencyBanner, textAlign:'start' on ButtonBase cards, justifyContent flex-end/flex-start for mine/theirs that mirrors automatically in fa.
- Everything colors from --bal-* semantic tokens with both schemes defined — zero hard-coded hexes in any of these components, so dark mode holds up by construction (StatusChip even documents 'Never hard-code a hex here').
- Every list surface (notification center, ticket inbox, thread) ships all four states — skeleton (with alternating bubble-shaped skeletons in threads), empty with title+body copy, error with retry, populated.
- The is_internal boundary is airtight in the UI layer: user-side types never model it, MessageBubble/TicketMessageList never render it, and the admin surface renders internal notes unmistakably (dashed warning border + badge + distinct bg) so staff can't confuse a note with a reply.
- referenceCode treated as the support currency: shown prominently in the inbox card, thread header, and creation-success dialog, correctly forced LTR for the Latin code.
- Polite, well-factored polling: only the unread count polls (60s, auth-gated, stale-while-revalidate) and only the tiny bell container re-renders on count change — the shell never does.
- Role-aware deep-linking centralized in notificationDeepLink with null-safe fallbacks (a notification that doesn't apply to the role is non-navigable, never a broken route), shared by bell and center.
- Emergency contact is tel:-only, gated to the nurse post-confirmation care read, with no VoIP or phone directory anywhere — the product's anti-disintermediation rule is faithfully enforced in the UI.
- Persian copy in messages/fa.json is human and warm ('بهروز هستید', 'هنوز پیامی نیست، هماهنگی را شروع کنید'), with proper ICU plurals on the bell aria-label; Shamsi dates render via the Intl Persian calendar with no date library.