Files
baya-monorepo/dev/post-phase/ui/audit/messaging-notifications.md
T
2026-07-17 13:22:04 +03:30

68 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<EmergencyBanner onOpenTicket={…} />` — 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 `<AppIconButton icon="support" …/>` 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 `<AppIcon icon="error" …/>`; 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 `<PlaceholderScreen icon="notifications" …/>`
## 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.