19 KiB
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 supportsstatusandpage; the screen just never exposes them.- evidence: line 35:
const { data, isLoading, isError, refetch } = useMyTickets({});— no setLimit/setPage anywhere, unlike NotificationCenter's load-more
- evidence: line 35:
- [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:unreadCountandlastMessageAtare mock-only (REQ-028 gap, types.ts:56-59), so no unread pill, no bold-unread subject, and times silently fall back tocreatedAt. 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
- evidence: types.ts:56-59 "REQ-028 gap — mock-only until delivered"; TicketListCard.tsx:44
- [high]
client/src/services/tickets/hooks/useTicket.ts— The thread never live-updates:useTicket/useTicketThreadhave staleTime 15s but norefetchInterval, 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 — amaxHeight: 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 forcesdirection: '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
- evidence: MessageBubble.tsx:71
- [medium]
client/src/components/common/AppIcon/config.ts— Thesendicon (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)
- evidence: config.ts:85
- [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
- evidence: MessageComposer.tsx:46-51
- [medium]
client/src/components/messaging/TicketInboxScreen.tsx— The alarm-red EmergencyBanner renders permanently at the top of every ticket inbox with nocontactPhone(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 onlyphone ? …
- evidence: line 57
- [medium]
client/src/components/notifications/NotificationRow.tsx— Navigable and non-navigable notifications are visually identical ButtonBase cards: akind:'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
- evidence: NotificationRow.tsx:30-43 sx has static bgcolor/border only; NotificationCenter.tsx:46-50
- [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)})}
- evidence: NotificationBell.tsx:31
- [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, andsystemmessages (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
- evidence: TicketMessageList.tsx:58
- [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
- evidence: lines 48-53
- [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
- evidence: lines 100-107
- [low]
client/src/components/notifications/NotificationCenter.tsx— The error empty-state icon iserror→ MUIDangerous(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 growslimitand refetches the whole list from offset 0 (O(n) payload growth per click).- evidence: line 79
<AppIcon icon="error" …/>; config.ts:21DangerousIcon; line 111setLimit((current) => current + NOTIFICATIONS_PAGE_SIZE)
- evidence: line 79
- [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" …/>
- 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.