Files
baya-monorepo/archive/post-phase/ui/audit/messaging-notifications.md
T
2026-08-02 18:48:32 +03:30

19 KiB
Raw Blame History

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.