ui phase 10

This commit is contained in:
hamid
2026-07-19 17:13:32 +03:30
parent b638e25a0e
commit b4b8c9ea79
48 changed files with 1643 additions and 290 deletions
+10 -10
View File
@@ -288,8 +288,8 @@ client/
│ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo; ui-phase-9 added `initialsFallback` (renders `InitialsAvatar` instead of MUI's generic silhouette when there's no `avatarUrl`) — the customer account hub's identity header (tested) │ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo; ui-phase-9 added `initialsFallback` (renders `InitialsAvatar` instead of MUI's generic silhouette when there's no `avatarUrl`) — the customer account hub's identity header (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care; ui-phase-7 added a standalone AddressCard below the hero — a `geo:`/Neshan-web map deep-link when the frozen snapshot carries lat/lng, a quiet nurse-only fallback note otherwise (REQ-051) — and a nurse-only in-visit «در حال ویزیت» banner promoting the check-out action), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard; ui-phase-7 made the EVV CTA the full-width hero action + an optional `serviceLabel` line, REQ-052), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), CheckOutConfirmButton (ui-phase-7 — the shared check-out action + lightweight confirm dialog, used by both SessionCard and BookingDetailView's in-visit banner so "check-out ends the visit and starts the payout clock" always gets one confirm step), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care; ui-phase-7 added a standalone AddressCard below the hero — a `geo:`/Neshan-web map deep-link when the frozen snapshot carries lat/lng, a quiet nurse-only fallback note otherwise (REQ-051) — and a nurse-only in-visit «در حال ویزیت» banner promoting the check-out action), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard; ui-phase-7 made the EVV CTA the full-width hero action + an optional `serviceLabel` line, REQ-052), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), CheckOutConfirmButton (ui-phase-7 — the shared check-out action + lightweight confirm dialog, used by both SessionCard and BookingDetailView's in-visit banner so "check-out ends the visit and starts the payout clock" always gets one confirm step), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressForm, AddressCard (ui-phase-9 added a pin-quality cue — hasPin/pinSetLabel/pinMissingLabel), and the map-pin picker boundary — `AddressMapPicker` now branches on `NESHAN_WEB_KEY` (`@/config`): real Neshan tiles via `NeshanMap` (Leaflet, dynamically imported `ssr:false`; search box + locate-me + draggable pin + reverse-geocoded preview via `services/geography/neshan.ts`'s direct third-party fetch client) when set, else the original bounded-canvas grid stand-in (kept, not deleted, for dev/CI/jsdom) — `{ latitude, longitude }` in/out is identical either way so `AddressForm` never changed (each tested; `NeshanMap` itself isn't unit-tested — jsdom+Leaflet integration — and is unreachable in tests since `NEXT_PUBLIC_NESHAN_KEY` is unset in CI) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressForm, AddressCard (ui-phase-9 added a pin-quality cue — hasPin/pinSetLabel/pinMissingLabel), and the map-pin picker boundary — `AddressMapPicker` now branches on `NESHAN_WEB_KEY` (`@/config`): real Neshan tiles via `NeshanMap` (Leaflet, dynamically imported `ssr:false`; search box + locate-me + draggable pin + reverse-geocoded preview via `services/geography/neshan.ts`'s direct third-party fetch client) when set, else the original bounded-canvas grid stand-in (kept, not deleted, for dev/CI/jsdom) — `{ latitude, longitude }` in/out is identical either way so `AddressForm` never changed (each tested; `NeshanMap` itself isn't unit-tested — jsdom+Leaflet integration — and is unreachable in tests since `NEXT_PUBLIC_NESHAN_KEY` is unset in CI)
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts │ ├── messaging/ # f14 tickets composites (import from @/components/messaging), ui-phase-10 messaging-app rebuild. Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen (status filter chips + load-more, EmergencyPlaybookRow instead of a permanent banner), TicketThreadScreen (+ TicketConversationPanel, key={ticketId} — owns the one usePostMessage/draft both TicketMessageList and MessageComposer share, so retry/discard and the composer are one pipeline), TicketMessageList (date separators/author-grouped bubbles/centered system events via useThreadScroll — opens at the newest message, "new message" pill), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (controlled; pointer-aware Enter semantics, attachment affordance gated behind `TICKETS_ATTACHMENTS_ENABLED`), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch; still mounts the full alarm-red EmergencyBanner, untouched). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, hh:mm-only, failed-send retry-in-place + discard, `role="alert"` on failure), TicketListCard (prominent referenceCode + unread pill + last-message preview + relative time, mock-tolerant when the enrichment fields are absent), EmergencyBanner (nurse post-confirmation tel: playbook only), EmergencyPlaybookRow (the inbox's compact neutral emergency row). Helpers: statusKind.ts, authorLabel.ts, clientMessageId.ts, useThreadScroll (reusable scroll-orchestration hook, exported for phase 11's admin thread)
│ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts │ ├── notifications/ # f14 notification composites (import from @/components/notifications), ui-phase-10 pass. NotificationBell (chrome container — subscribes to the polling count so only it re-renders; opens NotificationBellPopover on the nurse desktop shell instead of navigating, everywhere else still navigates) → NotificationBellView (pure, tested, ref-forwarding so the container can anchor the popover), NotificationBellPopover (5-recent preview, fetches on open, exported for phase 11's admin shell once it has a feed), NotificationRow (pure, tested: per-kind tinted icon container, navigable rows get a trailing chevron, non-navigable rows render as a plain non-rippling surface), NotificationCenter (shared page body: unread-first, day-grouped امروز/دیروز/این‌هفته with relative timestamps, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts (+ notificationTint). Admin's bell entry is hidden (no real feed yet, `AdminLayout.tsx`) until phase 11 ships one.
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), AuthIllustration + TrustBullets (ui-phase-3 CSS/SVG login-hero treatment) │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), AuthIllustration + TrustBullets (ui-phase-3 CSS/SVG login-hero treatment)
├── i18n/ ├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
@@ -297,9 +297,9 @@ client/
│ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop │ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop
├── layout/ # ui-2 rewrite — per-actor branded chrome + correct locale-aware navigation ├── layout/ # ui-2 rewrite — per-actor branded chrome + correct locale-aware navigation
│ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below │ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below
│ ├── CustomerLayout.tsx # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md │ ├── CustomerLayout.tsx # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md; ui-phase-10 added a `useSupportUnreadTotal`-driven Badge on the root-tab support icon (renders only when a signal exists — mock-only until REQ-059)
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer) │ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer); ui-phase-10 added `badgeCount` (useSupportUnreadTotal) on the support sidebar item
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell │ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role); no notification bell (ui-phase-10 — admin has no real feed yet, re-add via `NotificationBellPopover` once phase 11 ships one)
│ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving) │ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving)
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark │ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
│ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard │ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard
@@ -312,7 +312,7 @@ client/
│ ├── TopBar.tsx # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav) │ ├── TopBar.tsx # title | titleNode override, align ('start' breadcrumb-style | 'center'), optional secondaryRow (the customer desktop top-nav)
│ ├── SideBar.tsx # renders both Drawers (mobile temporary + desktop permanent) off one content tree; close handler wired to the nav list only (dark-mode/locale toggles never close it); brand header + optional identity slot │ ├── SideBar.tsx # renders both Drawers (mobile temporary + desktop permanent) off one content tree; close handler wired to the nav list only (dark-mode/locale toggles never close it); brand header + optional identity slot
│ ├── SideBarNavList.tsx # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down │ ├── SideBarNavList.tsx # renders `ListSubheader` sections when items share a `group`; selection computed once via matchActivePath and passed down
│ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop │ ├── SideBarNavItem.tsx # navigates via `@/i18n/navigation`'s Link — one navigation, no redirect hop; renders `LinkToPage.badgeCount` as a small Badge on the icon when > 0 (ui-phase-10, the nurse support entry)
│ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — customer header (root tabs) + every sidebar shell's drawer header │ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — customer header (root tabs) + every sidebar shell's drawer header
│ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse sidebar + customer profile hub (tested) │ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse sidebar + customer profile hub (tested)
│ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme() │ ├── DarkModeButton.tsx # 'use client' — only subscriber to useColorScheme()
@@ -355,8 +355,8 @@ client/
│ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo │ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
│ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire │ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire
│ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string │ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
│ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket(one detail(id) = the whole thread; no message pagination)/useTicketThread(select over detail)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC: onMutate append pending, onError rollback+keep composer draft, onSuccess replace by clientMessageId, onSettled invalidate). seam+mock(PRIMARY)+client(maps b15 1:1). **is_internal NEVER modelled in the user-app types** — both mappers DROP any internal message (server-strip mimic); no internal affordance anywhere. Mock stores an internal note it never returns (no-leak demo), seeds a booking-linked coordination ticket (idempotent for coordination+bookingId → "jump to existing"), tracks the last viewer so an optimistic message reconciles as mine, MOCK_SEND_FAIL_SENTINEL='/fail' drives the failure→retry path. Wire summary lacks unreadCount/lastMessageAt (REQ-028) → mock-only │ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket+useTicketThread(select over detail; both poll every TICKET_THREAD_REFETCH_INTERVAL while a thread is mounted, ui-phase-10)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC + retry-in-place, ui-phase-10: onMutate is idempotent on clientMessageId — a retry flips an existing `failed` bubble back to `sending` instead of appending a duplicate; onError no longer rolls back, it flips the bubble to `sendStatus:'failed'` in place so the text + a retry/discard affordance survive; onSettled also invalidates the unread-total badge)/useDiscardFailedMessage(non-mutation cache removal for the composer's discard-and-retype)/useSupportUnreadTotal(chrome badge, §3.1 — mock sums unread, real returns null until REQ-059). seam+mock(PRIMARY)+client(maps b15 1:1). **is_internal NEVER modelled in the user-app types** — both mappers DROP any internal message (server-strip mimic); no internal affordance anywhere. Mock stores an internal note it never returns (no-leak demo), seeds a booking-linked coordination ticket (idempotent for coordination+bookingId → "jump to existing"), tracks the last viewer so an optimistic message reconciles as mine, MOCK_SEND_FAIL_SENTINEL='/fail' drives the failure→retry path. `unreadCount`/`lastMessageAt` are REAL (REQ-028, delivered); `lastMessagePreview`/`lastAuthorRole` are the newer REQ-059 gap → mock-only, card degrades gracefully without them
│ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo │ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit, now takes an optional `{enabled}` so the ui-phase-10 bell popover can fetch only on open)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo
│ ├── admin/ # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole; seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface are gaps (REQ-029/030/031). support_alerts are internal-only — never rendered outside an admin route │ ├── admin/ # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole; seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface are gaps (REQ-029/030/031). support_alerts are internal-only — never rendered outside an admin route
│ ├── partnerCenter/ # F15 partner centers (b15): admin management + the center-scoped portal. usePartnerCenters/usePartnerCenter/useCenterSponsoredNurses/useCreate/useUpdate/useVerify/useSetActive/useAssignNurse (admin) + useMyPartnerCenter/useMySponsoredNurses/useMySponsoredBookings/useMySettlement (portal); seam+mock(PRIMARY)+client. settlement_iban masked last-4 (write-then-masked); merchant-of-record gates the settlement view; VAT on the commission line only (config vat_rate); deriveCenterState(isActive,verifiedAt). Mock-primary: portal split reads + activate/suspend + invoice total are gaps (REQ-032/033) │ ├── partnerCenter/ # F15 partner centers (b15): admin management + the center-scoped portal. usePartnerCenters/usePartnerCenter/useCenterSponsoredNurses/useCreate/useUpdate/useVerify/useSetActive/useAssignNurse (admin) + useMyPartnerCenter/useMySponsoredNurses/useMySponsoredBookings/useMySettlement (portal); seam+mock(PRIMARY)+client. settlement_iban masked last-4 (write-then-masked); merchant-of-record gates the settlement view; VAT on the commission line only (config vat_rate); deriveCenterState(isActive,verifiedAt). Mock-primary: portal split reads + activate/suspend + invoice total are gaps (REQ-032/033)
│ │ # Admin-endpoint ADDITIONS to existing domains (the staff lens — NOT new domains): │ │ # Admin-endpoint ADDITIONS to existing domains (the staff lens — NOT new domains):
@@ -497,8 +497,8 @@ async function MyServerComponent() {
- `'payouts'` — the f12 nurse earnings & payout-history surface: the balance header (`balance_net_*`/`balance_owed_*` — the negative "owed back" state + hint) + four buckets (`bucket_*`), the cadence/dispute-window explainer (`explainer_*` — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (`tab_*`/`estate_*` for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (`amount_gross`/`amount_commission`/`amount_your_payout`) + clawback net explanation (`clawback_*`), the per-state affordances (`pending_affordance`/`dispute_window_*`/`eligible_affordance`/`paid_on`), the payout-status labels (`pstatus_*` for pending/submitted/paid/failed) + batch-status labels (`bstatus_*`), the read-only failure banner (`failure_*`), and the detail money decomposition + booking-links copy (`detail_*`/`gross_earnings_label`/`net_amount_label`); consumed by the `/nurse/earnings` pages and `EarningsBalanceHeader`/`EarningsRow`/`PayoutHistoryRow` - `'payouts'` — the f12 nurse earnings & payout-history surface: the balance header (`balance_net_*`/`balance_owed_*` — the negative "owed back" state + hint) + four buckets (`bucket_*`), the cadence/dispute-window explainer (`explainer_*` — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (`tab_*`/`estate_*` for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (`amount_gross`/`amount_commission`/`amount_your_payout`) + clawback net explanation (`clawback_*`), the per-state affordances (`pending_affordance`/`dispute_window_*`/`eligible_affordance`/`paid_on`), the payout-status labels (`pstatus_*` for pending/submitted/paid/failed) + batch-status labels (`bstatus_*`), the read-only failure banner (`failure_*`), and the detail money decomposition + booking-links copy (`detail_*`/`gross_earnings_label`/`net_amount_label`); consumed by the `/nurse/earnings` pages and `EarningsBalanceHeader`/`EarningsRow`/`PayoutHistoryRow`
- `'reviews'` — the f13 leave-a-review flow + the C3 reviews tab: the form labels (`title`/`rating_label`/`body_label`/`tags_label`/`submit`), the review-tag labels keyed off the code (`tag_{punctual,professional,clean,kind,communicative}` — never off the wire), the not-eligible reasons (`reason_*`), the moderation-status labels (`status_pending_moderation`/`status_published`/`status_hidden`/`status_rejected`), the persistent "under review" + my-review copy, the booking-detail CTA (`cta_leave`/`cta_under_review`/`cta_view_review`), the aggregate count (`count` ICU plural), the masked author fallback (`author_masked`), and the list empty/error/load-more; consumed by the review page, the C3 `ReviewsPanel`, and the `LeaveReviewCta` - `'reviews'` — the f13 leave-a-review flow + the C3 reviews tab: the form labels (`title`/`rating_label`/`body_label`/`tags_label`/`submit`), the review-tag labels keyed off the code (`tag_{punctual,professional,clean,kind,communicative}` — never off the wire), the not-eligible reasons (`reason_*`), the moderation-status labels (`status_pending_moderation`/`status_published`/`status_hidden`/`status_rejected`), the persistent "under review" + my-review copy, the booking-detail CTA (`cta_leave`/`cta_under_review`/`cta_view_review`), the aggregate count (`count` ICU plural), the masked author fallback (`author_masked`), and the list empty/error/load-more; consumed by the review page, the C3 `ReviewsPanel`, and the `LeaveReviewCta`
- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard` - `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry` - `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`/`filter_all`/`load_more`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`/`day_today`/`day_yesterday`/`new_message_pill`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`/`discard_failed`/`attach_photo` — the last gated off by `TICKETS_ATTACHMENTS_ENABLED`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and both emergency surfaces: the nurse post-confirmation **playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`, and the ui-phase-10 inbox **compact row** (`emergency_row_title`/`emergency_row_body` — rewritten to never instruct calling a number the inbox can't show); consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`EmergencyPlaybookRow`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell` - `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, the day-group headers (`group_today`/`group_yesterday`/`group_this_week`, ui-phase-10), `view_all` (the bell popover's link to the full center), and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell` + `NotificationBellPopover`
- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark); ui-phase-3 added the login-hero `trust_*` bullets, the consent line (`consent_line`, `t.rich` with `<terms>`/`<privacy>` tags), and select-role's `role_add_later_note` - `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark); ui-phase-3 added the login-hero `trust_*` bullets, the consent line (`consent_line`, `t.rich` with `<terms>`/`<privacy>` tags), and select-role's `role_add_later_note`
- `'legal'` — ui-phase-3's `/terms`/`/privacy` static pages: `terms_title`/`privacy_title`, `draft_banner` (the human/legal-review flag shown on-page), `terms_intro`/`privacy_intro`, and `terms_sections`/`privacy_sections` (arrays of `{title, body}` read via `t.raw`, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages - `'legal'` — ui-phase-3's `/terms`/`/privacy` static pages: `terms_title`/`privacy_title`, `draft_banner` (the human/legal-review flag shown on-page), `terms_intro`/`privacy_intro`, and `terms_sections`/`privacy_sections` (arrays of `{title, body}` read via `t.raw`, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites - `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
+14 -1
View File
@@ -1401,6 +1401,15 @@
"empty_body": "Open a ticket to coordinate or get support.", "empty_body": "Open a ticket to coordinate or get support.",
"error_body": "Couldn't load your tickets.", "error_body": "Couldn't load your tickets.",
"retry": "Retry", "retry": "Retry",
"filter_all": "All",
"load_more": "Load more",
"day_today": "Today",
"day_yesterday": "Yesterday",
"new_message_pill": "New message ↓",
"discard_failed": "Discard and edit",
"attach_photo": "Attach a photo",
"emergency_row_title": "Emergencies",
"emergency_row_body": "If this is urgent, open a ticket and our support team will follow up as soon as possible.",
"category_support": "Support", "category_support": "Support",
"category_coordination": "Visit coordination", "category_coordination": "Visit coordination",
"category_refund": "Refund", "category_refund": "Refund",
@@ -1450,7 +1459,11 @@
"retry": "Retry", "retry": "Retry",
"mark_all_read": "Mark all read", "mark_all_read": "Mark all read",
"load_more": "Load more", "load_more": "Load more",
"bell_aria": "{count, number} unread notifications" "bell_aria": "{count, number} unread notifications",
"group_today": "Today",
"group_yesterday": "Yesterday",
"group_this_week": "This week",
"view_all": "View all"
}, },
"admin": { "admin": {
"overview_title": "Backoffice", "overview_title": "Backoffice",
+14 -1
View File
@@ -1401,6 +1401,15 @@
"empty_body": "برای هماهنگی یا پشتیبانی، یک تیکت جدید باز کنید.", "empty_body": "برای هماهنگی یا پشتیبانی، یک تیکت جدید باز کنید.",
"error_body": "بارگذاری تیکت‌ها ممکن نشد.", "error_body": "بارگذاری تیکت‌ها ممکن نشد.",
"retry": "تلاش مجدد", "retry": "تلاش مجدد",
"filter_all": "همه",
"load_more": "نمایش بیشتر",
"day_today": "امروز",
"day_yesterday": "دیروز",
"new_message_pill": "پیام جدید ↓",
"discard_failed": "حذف و ویرایش پیام",
"attach_photo": "افزودن عکس",
"emergency_row_title": "موارد اضطراری",
"emergency_row_body": "در شرایط اضطراری، یک تیکت فوری ثبت کنید تا پشتیبانی در اسرع وقت با شما تماس بگیرد.",
"category_support": "پشتیبانی", "category_support": "پشتیبانی",
"category_coordination": "هماهنگی ویزیت", "category_coordination": "هماهنگی ویزیت",
"category_refund": "بازپرداخت", "category_refund": "بازپرداخت",
@@ -1450,7 +1459,11 @@
"retry": "تلاش مجدد", "retry": "تلاش مجدد",
"mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده", "mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"load_more": "نمایش بیشتر", "load_more": "نمایش بیشتر",
"bell_aria": "{count, number} اعلان خوانده‌نشده" "bell_aria": "{count, number} اعلان خوانده‌نشده",
"group_today": "امروز",
"group_yesterday": "دیروز",
"group_this_week": "این هفته",
"view_all": "مشاهده همه"
}, },
"admin": { "admin": {
"overview_title": "پیشخان مدیریت", "overview_title": "پیشخان مدیریت",
@@ -214,4 +214,4 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
* chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional` * chevrons pointing "start"). AppIcon applies the flip via a `data-icon-directional`
* attribute + the single CSS rule in globals.css add a name here, nothing else. * attribute + the single CSS rule in globals.css add a name here, nothing else.
*/ */
export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward']); export const DIRECTIONAL_ICONS = new Set<IconName>(['back', 'chevron_start', 'forward', 'send']);
@@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react';
jest.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string) => {
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
return messages[namespace]?.[key] ?? key;
},
}));
import { ThemeProvider } from '../../theme';
import EmergencyPlaybookRow from './EmergencyPlaybookRow';
describe('<EmergencyPlaybookRow/> component', () => {
it('starts collapsed with the playbook body hidden', () => {
render(
<ThemeProvider>
<EmergencyPlaybookRow onOpenTicket={() => {}} />
</ThemeProvider>,
);
expect(screen.getByTestId('emergency-playbook-toggle')).toHaveAttribute('aria-expanded', 'false');
});
it('expands to reveal the playbook body + open-ticket action on tap', () => {
render(
<ThemeProvider>
<EmergencyPlaybookRow onOpenTicket={() => {}} />
</ThemeProvider>,
);
fireEvent.click(screen.getByTestId('emergency-playbook-toggle'));
expect(screen.getByTestId('emergency-playbook-toggle')).toHaveAttribute('aria-expanded', 'true');
});
it('invokes onOpenTicket from the expanded open-ticket action', () => {
const onOpenTicket = jest.fn();
render(
<ThemeProvider>
<EmergencyPlaybookRow onOpenTicket={onOpenTicket} />
</ThemeProvider>,
);
fireEvent.click(screen.getByTestId('emergency-playbook-toggle'));
fireEvent.click(screen.getByRole('button', { name: /پشتیبانی/ }));
expect(onOpenTicket).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,70 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Collapse from '@mui/material/Collapse';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTranslations } from 'next-intl';
import AppButton from '@/components/common/AppButton';
import { AppIcon } from '@/components/common';
export interface EmergencyPlaybookRowProps {
/** Opens the support-ticket flow ("…then open a ticket"). */
onOpenTicket: () => void;
}
/**
* The ticket-inbox emergency affordance (§3.4) a compact, neutral, collapsed-by-default row (not the
* alarm-red `EmergencyBanner`, which stays reserved for the surface that actually has a `tel:` contact:
* the nurse's post-confirmation booking detail). Expands to the playbook copy + an "open a ticket" action.
* The inbox never has a phone number to show, so its copy never instructs calling one.
* @component EmergencyPlaybookRow
*/
const EmergencyPlaybookRow: FunctionComponent<EmergencyPlaybookRowProps> = ({ onOpenTicket }) => {
const t = useTranslations('tickets');
const [expanded, setExpanded] = useState(false);
return (
<Box
data-testid="emergency-playbook-row"
sx={{ borderRadius: 2, border: '1px solid', borderColor: 'divider', bgcolor: 'var(--bal-bg-paper)' }}
>
<ButtonBase
onClick={() => setExpanded((current) => !current)}
aria-expanded={expanded}
data-testid="emergency-playbook-toggle"
sx={{ display: 'flex', width: '100%', justifyContent: 'flex-start', p: 1.5, gap: 1, textAlign: 'start' }}
>
<AppIcon icon="info" size={20} color="var(--bal-text-secondary)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexGrow: 1, color: 'var(--bal-text-secondary)' }}>
{t('emergency_row_title')}
</Typography>
<AppIcon
icon="expand"
size={20}
color="var(--bal-text-secondary)"
style={{ transform: expanded ? 'rotate(180deg)' : undefined, transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)' }}
/>
</ButtonBase>
<Collapse in={expanded}>
<Stack sx={{ gap: 1, px: 1.5, pb: 1.5 }}>
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('emergency_row_body')}
</Typography>
<AppButton
variant="text"
color="primary"
startIcon="support"
onClick={onOpenTicket}
sx={{ alignSelf: 'flex-start' }}
>
{t('emergency_open_ticket')}
</AppButton>
</Stack>
</Collapse>
</Box>
);
};
export default EmergencyPlaybookRow;
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme'; import { ThemeProvider } from '../../theme';
import MessageBubble from './MessageBubble'; import MessageBubble from './MessageBubble';
import type { TicketMessage } from '@/services/tickets/types'; import type { TicketMessage } from '@/services/tickets/types';
@@ -13,14 +13,20 @@ const base: TicketMessage = {
sendStatus: 'sent', sendStatus: 'sent',
}; };
function renderBubble(overrides: Partial<TicketMessage>) { function renderBubble(overrides: Partial<TicketMessage>, extra: { showAuthorLabel?: boolean; onRetry?: () => void; onDiscard?: () => void } = {}) {
return render( return render(
<ThemeProvider> <ThemeProvider>
<MessageBubble <MessageBubble
message={{ ...base, ...overrides }} message={{ ...base, ...overrides }}
authorLabel="پشتیبانی" authorLabel="پشتیبانی"
showAuthorLabel={extra.showAuthorLabel ?? true}
timeLabel="۱۰:۰۰" timeLabel="۱۰:۰۰"
sendingLabel="در حال ارسال…" sendingLabel="در حال ارسال…"
failedLabel="پیام ارسال نشد"
retryLabel="تلاش مجدد"
discardLabel="حذف"
onRetry={extra.onRetry}
onDiscard={extra.onDiscard}
/> />
</ThemeProvider>, </ThemeProvider>,
); );
@@ -35,6 +41,11 @@ describe('<MessageBubble/> component', () => {
expect(screen.getByText('۱۰:۰۰')).toBeInTheDocument(); expect(screen.getByText('۱۰:۰۰')).toBeInTheDocument();
}); });
it('hides the author label when it is not the head of its group', () => {
renderBubble({ isMine: false }, { showAuthorLabel: false });
expect(screen.queryByText('پشتیبانی')).not.toBeInTheDocument();
});
it('marks my messages as mine and never shows an author label', () => { it('marks my messages as mine and never shows an author label', () => {
renderBubble({ isMine: true }); renderBubble({ isMine: true });
expect(screen.getByTestId('message-bubble')).toHaveAttribute('data-mine', 'true'); expect(screen.getByTestId('message-bubble')).toHaveAttribute('data-mine', 'true');
@@ -46,4 +57,20 @@ describe('<MessageBubble/> component', () => {
expect(screen.getByText('در حال ارسال…')).toBeInTheDocument(); expect(screen.getByText('در حال ارسال…')).toBeInTheDocument();
expect(screen.queryByText('۱۰:۰۰')).not.toBeInTheDocument(); expect(screen.queryByText('۱۰:۰۰')).not.toBeInTheDocument();
}); });
it('announces a failed send (role="alert") and offers retry + discard in place', () => {
const onRetry = jest.fn();
const onDiscard = jest.fn();
renderBubble(
{ isMine: true, id: null, clientMessageId: 'c1', sendStatus: 'failed' },
{ onRetry, onDiscard },
);
const bubble = screen.getByTestId('message-bubble');
expect(bubble).toHaveAttribute('role', 'alert');
expect(screen.getByText('سلام، ساعت ویزیت را تغییر دهید')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('message-retry'));
expect(onRetry).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId('message-discard'));
expect(onDiscard).toHaveBeenCalledTimes(1);
});
}); });
@@ -1,37 +1,66 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { AppIcon } from '@/components/common';
import type { TicketMessage } from '@/services/tickets/types'; import type { TicketMessage } from '@/services/tickets/types';
export interface MessageBubbleProps { export interface MessageBubbleProps {
message: TicketMessage; message: TicketMessage;
/** Translated author label (only shown for others' messages — mine is obvious). */ /** Translated author label (only shown for others' messages — mine is obvious). */
authorLabel: string; authorLabel: string;
/** Pre-formatted Shamsi time — the caller owns locale (shown once the message is sent). */ /** Renders the author label above the bubble — only the first bubble of a consecutive same-author group. */
showAuthorLabel: boolean;
/** Pre-formatted **hh:mm** — the full date lives on the thread's date separator, not the bubble. */
timeLabel: string; timeLabel: string;
/** "در حال ارسال…" — shown while an optimistic message is still sending (in place of the time). */ /** "در حال ارسال…" — shown while an optimistic message is still sending (in place of the time). */
sendingLabel: string; sendingLabel: string;
/** "پیام ارسال نشد" — shown in place of the time when a send has failed (§3.3). */
failedLabel: string;
/** «تلاش مجدد» — re-mutates with the same `clientMessageId` (retry-in-place, never a duplicate bubble). */
retryLabel: string;
/** Discards the failed bubble and restores its text to the composer draft. */
discardLabel: string;
onRetry?: () => void;
onDiscard?: () => void;
} }
/** /**
* One message bubble in a ticket thread. **Mine vs theirs** drives side + color and **mirrors for RTL** * One message bubble in a ticket thread. **Mine vs theirs** drives side + color and **mirrors for RTL**
* automatically (`justifyContent: flex-end` resolves to the inline-end left in RTL). A pending optimistic * automatically (`justifyContent: flex-end` resolves to the inline-end left in RTL). A pending optimistic
* send shows the "sending" state in place of the timestamp; on a send **failure** the bubble is rolled back * send shows the "sending" state in place of the timestamp. On **failure** the bubble stays in place
* by `usePostMessage` and the draft is retried from the composer (§3.5), so this bubble only ever renders the * (`sendStatus: 'failed'`, error-token accented, `role="alert"` so screen readers are told) with a retry
* sending/sent states. Purely presentational the caller supplies the translated author/time strings. Never * chip (re-mutates the same `clientMessageId` never a duplicate) and a discard action that restores the
* renders any internal-note content or styling (there is no internal message in the user view §5). * typed text to the composer the invariant that survives any mechanism change: a failure never loses
* typed text. Never renders any internal-note content or styling (there is no internal message in the user
* view §5).
* @component MessageBubble * @component MessageBubble
*/ */
const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorLabel, timeLabel, sendingLabel }) => { const MessageBubble: FunctionComponent<MessageBubbleProps> = ({
message,
authorLabel,
showAuthorLabel,
timeLabel,
sendingLabel,
failedLabel,
retryLabel,
discardLabel,
onRetry,
onDiscard,
}) => {
const isMine = message.isMine; const isMine = message.isMine;
const sending = message.sendStatus === 'sending'; const sending = message.sendStatus === 'sending';
const failed = message.sendStatus === 'failed';
return ( return (
<Box <Box
data-testid="message-bubble" data-testid="message-bubble"
data-mine={isMine ? 'true' : 'false'} data-mine={isMine ? 'true' : 'false'}
data-send-status={message.sendStatus}
role={failed ? 'alert' : undefined}
sx={{ display: 'flex', justifyContent: isMine ? 'flex-end' : 'flex-start', width: '100%' }} sx={{ display: 'flex', justifyContent: isMine ? 'flex-end' : 'flex-start', width: '100%' }}
> >
<Stack <Stack
@@ -40,17 +69,17 @@ const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorL
px: 1.5, px: 1.5,
py: 1, py: 1,
borderRadius: 2, borderRadius: 2,
bgcolor: isMine ? 'var(--bal-primary)' : 'var(--bal-bg-paper)', bgcolor: failed ? 'var(--bal-error-soft)' : isMine ? 'var(--bal-primary)' : 'var(--bal-bg-paper)',
color: isMine ? 'var(--bal-primary-contrast)' : 'var(--bal-text-primary)', color: failed ? 'var(--bal-text-primary)' : isMine ? 'var(--bal-primary-contrast)' : 'var(--bal-text-primary)',
border: isMine ? 'none' : '1px solid', border: failed ? '1px solid' : isMine ? 'none' : '1px solid',
borderColor: isMine ? undefined : 'divider', borderColor: failed ? 'var(--bal-error)' : isMine ? undefined : 'divider',
// Mine leans to the inline-end corner, theirs to the inline-start — a subtle "tail". // Mine leans to the inline-end corner, theirs to the inline-start — a subtle "tail".
borderStartEndRadius: isMine ? 4 : undefined, borderStartEndRadius: isMine ? 4 : undefined,
borderStartStartRadius: isMine ? undefined : 4, borderStartStartRadius: isMine ? undefined : 4,
opacity: sending ? 0.75 : 1, opacity: sending ? 0.75 : 1,
}} }}
> >
{!isMine ? ( {!isMine && showAuthorLabel ? (
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-primary)', mb: 0.25 }}> <Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-primary)', mb: 0.25 }}>
{authorLabel} {authorLabel}
</Typography> </Typography>
@@ -60,18 +89,43 @@ const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorL
{message.body} {message.body}
</Typography> </Typography>
{failed ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center', mt: 0.5 }}>
<Typography variant="caption" sx={{ color: 'var(--bal-error)', fontWeight: 700, flexGrow: 1 }}>
{failedLabel}
</Typography>
<Button
onClick={onRetry}
data-testid="message-retry"
size="small"
sx={{ minWidth: 0, p: 0, color: 'var(--bal-error)', fontWeight: 700, textDecoration: 'underline' }}
>
{retryLabel}
</Button>
<IconButton
size="small"
onClick={onDiscard}
aria-label={discardLabel}
data-testid="message-discard"
sx={{ color: 'var(--bal-error)' }}
>
<AppIcon icon="delete" size={16} color="var(--bal-error)" />
</IconButton>
</Stack>
) : (
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
mt: 0.25, mt: 0.25,
alignSelf: 'flex-end', display: 'block',
textAlign: 'end',
opacity: isMine ? 0.8 : 1, opacity: isMine ? 0.8 : 1,
color: isMine ? 'inherit' : 'var(--bal-text-secondary)', color: isMine ? 'inherit' : 'var(--bal-text-secondary)',
direction: 'ltr',
}} }}
> >
{sending ? sendingLabel : timeLabel} {sending ? sendingLabel : timeLabel}
</Typography> </Typography>
)}
</Stack> </Stack>
</Box> </Box>
); );
@@ -1,77 +1,63 @@
'use client'; 'use client';
import { FunctionComponent, KeyboardEvent, useState } from 'react'; import { FunctionComponent, KeyboardEvent } from 'react';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import useMediaQuery from '@mui/material/useMediaQuery';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { AppIcon } from '@/components/common'; import { AppIcon, AppIconButton } from '@/components/common';
import { usePostMessage } from '@/services/tickets'; import { TICKETS_ATTACHMENTS_ENABLED } from '@/services/tickets/constants';
export interface MessageComposerProps { export interface MessageComposerProps {
ticketId: number; value: string;
onChange: (value: string) => void;
onSubmit: () => void;
sending: boolean;
/** True when the ticket is closed / the user can't post — the input + send are disabled. */ /** True when the ticket is closed / the user can't post — the input + send are disabled. */
disabled?: boolean; disabled?: boolean;
} }
/** A client-generated id so the optimistic bubble reconciles to the server message (never a double-render). */
function makeClientMessageId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
/** /**
* The sticky thread composer. Send is **optimistic** (`usePostMessage`): the bubble appears instantly. The * The sticky thread composer a controlled input (the draft lives in `TicketConversationPanel`, which
* draft is **kept until the server confirms** cleared only in `onSuccess` so a failure leaves the text in * owns the send/retry/discard mutation so a failed bubble and the composer share one source of truth).
* place to retry without retyping (the pending bubble is rolled back by the mutation; §3.5). Submit is * **Enter semantics are input-modality aware** (§3.3): on a fine pointer (desktop) Enter sends and
* disabled while sending; Enter sends, Shift+Enter newlines. * Shift+Enter inserts a newline; on a coarse pointer (touch) Enter always inserts a newline the mobile
* shell has no Shift+Enter, so the explicit send button is the only send path there. The attachment
* affordance is designed but rendered only behind `TICKETS_ATTACHMENTS_ENABLED` (off REQ-060 gate).
* @component MessageComposer * @component MessageComposer
*/ */
const MessageComposer: FunctionComponent<MessageComposerProps> = ({ ticketId, disabled }) => { const MessageComposer: FunctionComponent<MessageComposerProps> = ({ value, onChange, onSubmit, sending, disabled }) => {
const t = useTranslations('tickets'); const t = useTranslations('tickets');
const [draft, setDraft] = useState(''); const isCoarsePointer = useMediaQuery('(pointer: coarse)');
const postMessage = usePostMessage(ticketId);
const sending = postMessage.isPending;
const submit = () => {
const body = draft.trim();
if (!body || sending || disabled) return;
postMessage.mutate(
{ body, clientMessageId: makeClientMessageId() },
{ onSuccess: () => setDraft('') }, // clear the draft ONLY on server confirm (§3.5)
);
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (isCoarsePointer) return; // touch keyboards: Enter always inserts a newline; the send button sends
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
submit(); onSubmit();
} }
}; };
return ( return (
<Stack sx={{ gap: 0.5 }}>
{postMessage.isError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }} data-testid="composer-send-failed">
{t('send_failed')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-end' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'flex-end' }}>
{TICKETS_ATTACHMENTS_ENABLED ? (
<AppIconButton icon="attachment" title={t('attach_photo')} disabled={disabled || sending} />
) : null}
<TextField <TextField
fullWidth fullWidth
multiline multiline
maxRows={4} maxRows={4}
size="small" size="small"
placeholder={t('composer_placeholder')} placeholder={t('composer_placeholder')}
value={draft} value={value}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
disabled={disabled} disabled={disabled}
/> />
<IconButton <IconButton
onClick={submit} onClick={onSubmit}
disabled={disabled || sending || draft.trim().length === 0} disabled={disabled || sending || value.trim().length === 0}
aria-label={t('send')} aria-label={t('send')}
sx={{ sx={{
bgcolor: 'var(--bal-primary)', bgcolor: 'var(--bal-primary)',
@@ -87,7 +73,6 @@ const MessageComposer: FunctionComponent<MessageComposerProps> = ({ ticketId, di
)} )}
</IconButton> </IconButton>
</Stack> </Stack>
</Stack>
); );
}; };
@@ -0,0 +1,80 @@
'use client';
import { FunctionComponent, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useTranslations } from 'next-intl';
import { useDiscardFailedMessage, usePostMessage } from '@/services/tickets';
import type { TicketMessage } from '@/services/tickets/types';
import { makeClientMessageId } from './clientMessageId';
import MessageComposer from './MessageComposer';
import TicketMessageList from './TicketMessageList';
export interface TicketConversationPanelProps {
ticketId: number;
/** Closed tickets show a notice instead of the composer. */
closed: boolean;
}
/**
* The interactive body of a thread the message list + the sticky composer, owning the one
* `usePostMessage` mutation + draft state both share (so a failed bubble's retry/discard and the
* composer's own send are the same optimistic pipeline, never two disconnected ones). Mounted **keyed by
* `ticketId`** from `TicketThreadScreen` so navigating threadthread (the App Router reuses the `[id]`
* subtree) remounts this whole panel the draft/in-flight/failed state never crosses tickets (§5).
* @component TicketConversationPanel
*/
const TicketConversationPanel: FunctionComponent<TicketConversationPanelProps> = ({ ticketId, closed }) => {
const t = useTranslations('tickets');
const [draft, setDraft] = useState('');
const postMessage = usePostMessage(ticketId);
const discardFailedMessage = useDiscardFailedMessage();
const sending = postMessage.isPending;
const submit = () => {
const body = draft.trim();
if (!body || sending || closed) return;
postMessage.mutate(
{ body, clientMessageId: makeClientMessageId() },
{ onSuccess: () => setDraft('') }, // clear the draft ONLY on server confirm (§3.5)
);
};
const retry = (message: TicketMessage) => {
if (!message.clientMessageId) return;
postMessage.mutate({ body: message.body, clientMessageId: message.clientMessageId });
};
const discard = (message: TicketMessage) => {
if (!message.clientMessageId) return;
discardFailedMessage(ticketId, message.clientMessageId);
setDraft(message.body); // a failure never loses typed text — restore it for editing (§3.3)
};
return (
<>
<TicketMessageList ticketId={ticketId} onRetry={retry} onDiscard={discard} />
<Box
sx={{
position: 'sticky',
bottom: 0,
bgcolor: 'var(--bal-bg-default)',
borderTop: '1px solid',
borderColor: 'divider',
pt: 1,
pb: 0.5,
}}
>
{closed ? (
<Typography variant="body2" sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', py: 1 }}>
{t('closed_notice')}
</Typography>
) : (
<MessageComposer value={draft} onChange={setDraft} onSubmit={submit} sending={sending} />
)}
</Box>
</>
);
};
export default TicketConversationPanel;
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { FunctionComponent, useState } from 'react'; import { FunctionComponent, useState } from 'react';
import Chip from '@mui/material/Chip';
import Skeleton from '@mui/material/Skeleton'; import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
@@ -9,21 +10,29 @@ import AppButton from '@/components/common/AppButton';
import { AppIcon } from '@/components/common'; import { AppIcon } from '@/components/common';
import { ticketThreadPath } from '@/constants'; import { ticketThreadPath } from '@/constants';
import { useMyTickets } from '@/services/tickets'; import { useMyTickets } from '@/services/tickets';
import { formatShamsiDateTime } from '@/utils'; import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { TicketStatus } from '@/services/tickets/types';
import { formatRelativeTime, formatShamsiDate } from '@/utils';
import { authorLabelKey } from './authorLabel';
import ContactSupportDialog from './ContactSupportDialog'; import ContactSupportDialog from './ContactSupportDialog';
import EmergencyBanner from './EmergencyBanner'; import EmergencyPlaybookRow from './EmergencyPlaybookRow';
import TicketListCard from './TicketListCard'; import TicketListCard from './TicketListCard';
export interface TicketInboxScreenProps { export interface TicketInboxScreenProps {
role: 'customer' | 'nurse'; role: 'customer' | 'nurse';
} }
/** Status filter chips — `undefined` (همه) plus every `TicketStatus`, in display order. */
const STATUS_FILTERS: Array<TicketStatus | undefined> = [undefined, 'open', 'closed'];
/** /**
* The "My Tickets" inbox the emergency playbook banner (support entry), a "Contact support" CTA that opens a * The "My Tickets" inbox a status filter chip row, the compact emergency playbook row, a "Contact
* new ticket (and shows its `referenceCode`), and the paginated ticket list. Cards show the **`referenceCode` * support" CTA that opens a new ticket (and shows its `referenceCode`), and the paginated ticket list.
* prominently**, the status chip, an unread indicator, and a null-safe linked-booking/refund hint. * Cards show the **`referenceCode`** prominently, the status chip, an unread indicator + last-message
* Empty / loading-skeleton / errorretry states. Shared by the customer and nurse inbox pages (role decides * preview + relative last-activity time (mock-tolerant: they degrade gracefully when the real API hasn't
* the thread route + the ticket shell, not the components). * shipped the enrichment fields yet, REQ-059), and a null-safe linked-booking/refund hint. Empty /
* loading-skeleton / errorretry states. Shared by the customer and nurse inbox pages (role decides the
* thread route + the ticket shell, not the components).
* @component TicketInboxScreen * @component TicketInboxScreen
*/ */
const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role }) => { const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role }) => {
@@ -31,14 +40,22 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [status, setStatus] = useState<TicketStatus | undefined>(undefined);
const [limit, setLimit] = useState(TICKETS_PAGE_SIZE);
const { data, isLoading, isError, refetch } = useMyTickets({}); const { data, isLoading, isError, isFetching, refetch } = useMyTickets({ status, pageSize: limit, page: 1 });
const tickets = data?.items ?? []; const tickets = data?.items ?? [];
const total = data?.total ?? 0;
const selectStatus = (next: TicketStatus | undefined) => {
setStatus(next);
setLimit(TICKETS_PAGE_SIZE);
};
const openThread = (ticketId: number) => router.push(`/${locale}${ticketThreadPath(role, ticketId)}`); const openThread = (ticketId: number) => router.push(`/${locale}${ticketThreadPath(role, ticketId)}`);
return ( return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}> <Stack sx={{ gap: 2, maxWidth: 720, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 1 }}> <Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}> <Typography variant="h6" sx={{ fontWeight: 800 }}>
{t('title')} {t('title')}
@@ -53,7 +70,19 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
</AppButton> </AppButton>
</Stack> </Stack>
<EmergencyBanner onOpenTicket={() => setDialogOpen(true)} /> <EmergencyPlaybookRow onOpenTicket={() => setDialogOpen(true)} />
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{STATUS_FILTERS.map((filter) => (
<Chip
key={filter ?? 'all'}
label={filter ? t(`status_${filter}`) : t('filter_all')}
onClick={() => selectStatus(filter)}
color={status === filter ? 'primary' : 'default'}
variant={status === filter ? 'filled' : 'outlined'}
/>
))}
</Stack>
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
@@ -89,12 +118,30 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
ticket={ticket} ticket={ticket}
categoryLabel={t(`category_${ticket.category}`)} categoryLabel={t(`category_${ticket.category}`)}
statusLabel={t(`status_${ticket.status}`)} statusLabel={t(`status_${ticket.status}`)}
timeLabel={formatShamsiDateTime(ticket.lastMessageAt ?? ticket.createdAt, locale)} timeLabel={formatRelativeTime(ticket.lastMessageAt ?? ticket.createdAt, locale, formatShamsiDate)}
previewLabel={
ticket.lastMessagePreview
? ticket.lastAuthorRole
? `${t(authorLabelKey(ticket.lastAuthorRole))}: ${ticket.lastMessagePreview}`
: ticket.lastMessagePreview
: null
}
linkedBookingLabel={ticket.bookingId != null ? t('linked_booking', { id: ticket.bookingId }) : null} linkedBookingLabel={ticket.bookingId != null ? t('linked_booking', { id: ticket.bookingId }) : null}
linkedRefundLabel={ticket.refundId != null ? t('linked_refund', { id: ticket.refundId }) : null} linkedRefundLabel={ticket.refundId != null ? t('linked_refund', { id: ticket.refundId }) : null}
onOpen={() => openThread(ticket.id)} onOpen={() => openThread(ticket.id)}
/> />
))} ))}
{total > tickets.length ? (
<AppButton
variant="text"
color="primary"
onClick={() => setLimit((current) => current + TICKETS_PAGE_SIZE)}
disabled={isFetching}
sx={{ alignSelf: 'center' }}
>
{t('load_more')}
</AppButton>
) : null}
</Stack> </Stack>
)} )}
@@ -66,4 +66,27 @@ describe('<TicketListCard/> component', () => {
fireEvent.click(screen.getByTestId('ticket-card')); fireEvent.click(screen.getByTestId('ticket-card'));
expect(onOpen).toHaveBeenCalledTimes(1); expect(onOpen).toHaveBeenCalledTimes(1);
}); });
it('renders the last-message preview when the enrichment field is present', () => {
renderCard({}, { previewLabel: 'پرستار: ساعت ۵ عصر هماهنگ شد' });
expect(screen.getByText('پرستار: ساعت ۵ عصر هماهنگ شد')).toBeInTheDocument();
});
it('degrades gracefully (no empty slot) when the preview enrichment field is absent', () => {
const { container } = render(
<ThemeProvider>
<TicketListCard
ticket={base}
categoryLabel="هماهنگی"
statusLabel="باز"
timeLabel="۲ ساعت پیش"
previewLabel={null}
linkedBookingLabel={null}
linkedRefundLabel={null}
onOpen={() => {}}
/>
</ThemeProvider>,
);
expect(container.querySelector('[data-testid="ticket-card"]')).toBeInTheDocument();
});
}); });
@@ -16,8 +16,15 @@ export interface TicketListCardProps {
categoryLabel: string; categoryLabel: string;
/** Translated status label. */ /** Translated status label. */
statusLabel: string; statusLabel: string;
/** Pre-formatted last-activity time — the caller owns locale. */ /** Pre-formatted last-activity time (relative, decaying to Shamsi) — the caller owns locale. */
timeLabel: string; timeLabel: string;
/**
* Pre-formatted "last message" preview line, already prefixed with the translated author label (e.g.
* "پرستار: ساعت ۵ عصر هماهنگ شد") when `lastAuthorRole` is known. `null` when the enrichment fields are
* absent (the real path today, REQ-059 gap) the card degrades to subject + status + time, never an
* empty slot.
*/
previewLabel?: string | null;
/** e.g. "رزرو #۵۰۰۱" — rendered only when the ticket is booking-linked (null-safe). */ /** e.g. "رزرو #۵۰۰۱" — rendered only when the ticket is booking-linked (null-safe). */
linkedBookingLabel?: string | null; linkedBookingLabel?: string | null;
/** e.g. "بازپرداخت #۹۰۰۱" — rendered only when refund-linked (null-safe). */ /** e.g. "بازپرداخت #۹۰۰۱" — rendered only when refund-linked (null-safe). */
@@ -28,9 +35,11 @@ export interface TicketListCardProps {
/** /**
* One ticket in the "My Tickets" inbox. The **`referenceCode` is shown prominently** (§5 it's what a user * One ticket in the "My Tickets" inbox. The **`referenceCode` is shown prominently** (§5 it's what a user
* quotes to support); the status chip reuses the shared `StatusChip`, the linked-booking/refund hint renders * quotes to support); the status chip reuses the shared `StatusChip`, the linked-booking/refund hint renders
* only when present (null-safe), and an **unread indicator** (a count dot + bolded subject) shows when the * only when present (null-safe), and an **unread indicator** (a count pill + bolded subject) shows when the
* ticket has unread activity. Purely presentational the caller supplies translated labels + the formatted * ticket has unread activity. `previewLabel` renders a one-line last-message snippet when the enrichment
* time and handles navigation via `onOpen`. * fields exist (mock today, REQ-059 on the real path) its absence never leaves an empty slot. Purely
* presentational the caller supplies translated labels + the formatted time and handles navigation via
* `onOpen`.
* @component TicketListCard * @component TicketListCard
*/ */
const TicketListCard: FunctionComponent<TicketListCardProps> = ({ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
@@ -38,6 +47,7 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
categoryLabel, categoryLabel,
statusLabel, statusLabel,
timeLabel, timeLabel,
previewLabel,
linkedBookingLabel, linkedBookingLabel,
linkedRefundLabel, linkedRefundLabel,
onOpen, onOpen,
@@ -58,6 +68,7 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
bgcolor: 'var(--bal-bg-paper)', bgcolor: 'var(--bal-bg-paper)',
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
}} }}
> >
<Stack sx={{ gap: 0.75, width: '100%' }}> <Stack sx={{ gap: 0.75, width: '100%' }}>
@@ -96,6 +107,21 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
{ticket.referenceCode} {ticket.referenceCode}
</Typography> </Typography>
{previewLabel ? (
<Typography
variant="body2"
sx={{
color: hasUnread ? 'var(--bal-text-primary)' : 'var(--bal-text-secondary)',
fontWeight: hasUnread ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{previewLabel}
</Typography>
) : null}
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{linkedBookingLabel ? ( {linkedBookingLabel ? (
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}> <Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
@@ -1,30 +1,86 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Skeleton from '@mui/material/Skeleton'; import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import AppButton from '@/components/common/AppButton';
import { useTicketThread } from '@/services/tickets'; import { useTicketThread } from '@/services/tickets';
import { formatShamsiDateTime } from '@/utils'; import type { TicketAuthorRole, TicketMessage } from '@/services/tickets/types';
import MessageBubble from './MessageBubble'; import { formatDaySeparator, formatShamsiDate, formatShamsiTime } from '@/utils';
import { authorLabelKey } from './authorLabel'; import { authorLabelKey } from './authorLabel';
import MessageBubble from './MessageBubble';
import { useThreadScroll } from './useThreadScroll';
export interface TicketMessageListProps { export interface TicketMessageListProps {
ticketId: number; ticketId: number;
/** Re-mutates a failed send with the same `clientMessageId` (never a duplicate bubble — §3.3). */
onRetry: (message: TicketMessage) => void;
/** Discards a failed bubble and restores its text to the composer draft. */
onDiscard: (message: TicketMessage) => void;
}
type MessageBlock =
| { kind: 'separator'; key: string; label: string }
| { kind: 'system'; key: string; message: TicketMessage }
| { kind: 'group'; key: string; authorRole: TicketAuthorRole; isMine: boolean; messages: TicketMessage[] };
/** Groups messages into date separators, centered system events, and consecutive same-author bubbles. */
function buildBlocks(messages: TicketMessage[], locale: string, todayLabel: string, yesterdayLabel: string): MessageBlock[] {
const blocks: MessageBlock[] = [];
let lastDayKey: string | null = null;
for (const message of messages) {
const dayKey = formatShamsiDate(message.createdAt, locale);
if (dayKey !== lastDayKey) {
blocks.push({
kind: 'separator',
key: `sep-${message.clientMessageId ?? message.id}`,
label: formatDaySeparator(message.createdAt, locale, todayLabel, yesterdayLabel),
});
lastDayKey = dayKey;
}
if (message.authorRole === 'system') {
blocks.push({ kind: 'system', key: `sys-${message.clientMessageId ?? message.id}`, message });
continue;
}
const last = blocks[blocks.length - 1];
if (last?.kind === 'group' && last.authorRole === message.authorRole && last.isMine === message.isMine) {
last.messages.push(message);
} else {
blocks.push({
kind: 'group',
key: `grp-${message.clientMessageId ?? message.id}`,
authorRole: message.authorRole,
isMine: message.isMine,
messages: [message],
});
}
}
return blocks;
} }
/** /**
* The message list of a thread a `select` over the ticket detail (`useTicketThread`), so it re-renders on a * The message list of a thread a `select` over the ticket detail (`useTicketThread`), so it re-renders on a
* new (optimistic) message without re-rendering the thread header. Empty ("no messages yet start * new (optimistic) message without re-rendering the thread header. Reads as a **timeline**: centered Shamsi
* coordinating"), skeleton, and populated states. Each bubble's author label + Shamsi time are resolved here; * date separators (امروز/دیروز/older date), consecutive same-author messages grouped under one author label,
* the bubbles never render an internal note (there are none in the user view §5). * `hh:mm`-only bubble stamps, and `system` messages as centered neutral event chips instead of bubbles.
* Opens scrolled to the **newest** message and auto-scrolls on send/receive (`useThreadScroll`, §3.2), with a
* floating "new message" pill when the viewer has scrolled up. Empty / skeleton / populated states. Never
* renders an internal note there are none in the user view (§5).
* @component TicketMessageList * @component TicketMessageList
*/ */
const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId }) => { const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId, onRetry, onDiscard }) => {
const t = useTranslations('tickets'); const t = useTranslations('tickets');
const locale = useLocale(); const locale = useLocale();
const { data: messages, isLoading } = useTicketThread(ticketId); const { data: messages, isLoading } = useTicketThread(ticketId);
const lastMessage = messages?.[messages.length - 1];
const { bottomRef, showNewMessagePill, scrollToNewest } = useThreadScroll(messages?.length ?? 0, lastMessage?.isMine ?? false);
if (isLoading) { if (isLoading) {
return ( return (
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
@@ -48,19 +104,80 @@ const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId
); );
} }
const blocks = buildBlocks(messages, locale, t('day_today'), t('day_yesterday'));
return ( return (
<Stack sx={{ gap: 1 }}> <Box sx={{ position: 'relative' }}>
{messages.map((message) => ( <Stack sx={{ gap: 1.5 }}>
{blocks.map((block) => {
if (block.kind === 'separator') {
return (
<Typography
key={block.key}
variant="caption"
sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', fontWeight: 700 }}
>
{block.label}
</Typography>
);
}
if (block.kind === 'system') {
return (
<Typography
key={block.key}
variant="caption"
data-testid="system-event"
sx={{
alignSelf: 'center',
color: 'var(--bal-text-secondary)',
bgcolor: 'var(--bal-divider)',
borderRadius: 10,
px: 1.5,
py: 0.5,
}}
>
{block.message.body}
</Typography>
);
}
return (
<Stack key={block.key} sx={{ gap: 0.25 }}>
{block.messages.map((message, index) => (
<MessageBubble <MessageBubble
key={message.clientMessageId ?? String(message.id)} key={message.clientMessageId ?? String(message.id)}
message={message} message={message}
authorLabel={t(authorLabelKey(message.authorRole))} authorLabel={t(authorLabelKey(message.authorRole))}
timeLabel={formatShamsiDateTime(message.createdAt, locale)} showAuthorLabel={index === 0 && !message.isMine}
timeLabel={formatShamsiTime(message.createdAt, locale)}
sendingLabel={t('sending')} sendingLabel={t('sending')}
failedLabel={t('send_failed')}
retryLabel={t('retry')}
discardLabel={t('discard_failed')}
onRetry={() => onRetry(message)}
onDiscard={() => onDiscard(message)}
/> />
))} ))}
</Stack> </Stack>
); );
})}
<Box ref={bottomRef} sx={{ height: 1 }} />
</Stack>
{showNewMessagePill ? (
<Box sx={{ position: 'sticky', bottom: 8, display: 'flex', justifyContent: 'center', pt: 1 }}>
<AppButton
variant="contained"
color="primary"
size="small"
onClick={scrollToNewest}
sx={{ borderRadius: 10, boxShadow: 'var(--bal-shadow-2)' }}
>
{t('new_message_pill')}
</AppButton>
</Box>
) : null}
</Box>
);
}; };
export default TicketMessageList; export default TicketMessageList;
@@ -1,6 +1,5 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { FunctionComponent } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Skeleton from '@mui/material/Skeleton'; import Skeleton from '@mui/material/Skeleton';
@@ -13,8 +12,7 @@ import { AppIcon } from '@/components/common';
import StatusChip from '@/components/StatusChip'; import StatusChip from '@/components/StatusChip';
import { ROUTES, nurseBookingDetailPath, ticketsBasePath } from '@/constants'; import { ROUTES, nurseBookingDetailPath, ticketsBasePath } from '@/constants';
import { useTicket } from '@/services/tickets'; import { useTicket } from '@/services/tickets';
import MessageComposer from './MessageComposer'; import TicketConversationPanel from './TicketConversationPanel';
import TicketMessageList from './TicketMessageList';
import { ticketCategoryIcon, ticketStatusKind } from './statusKind'; import { ticketCategoryIcon, ticketStatusKind } from './statusKind';
export interface TicketThreadScreenProps { export interface TicketThreadScreenProps {
@@ -95,30 +93,9 @@ const TicketThreadScreen: FunctionComponent<TicketThreadScreenProps> = ({ role,
</Stack> </Stack>
</Paper> </Paper>
<TicketMessageList ticketId={ticketId} /> {/* Key on ticketId so navigating threadthread (the App Router reuses the [id] subtree)
remounts the whole panel its draft + in-flight/failed send state never cross tickets. */}
<Box <TicketConversationPanel key={ticketId} ticketId={ticketId} closed={ticket.status === 'closed'} />
sx={{
position: 'sticky',
bottom: 0,
bgcolor: 'var(--bal-bg-default)',
pt: 1,
pb: 0.5,
}}
>
{ticket.status === 'closed' ? (
<Typography
variant="body2"
sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', py: 1 }}
>
{t('closed_notice')}
</Typography>
) : (
// Key on ticketId so navigating thread→thread (the App Router reuses the [id] subtree)
// remounts the composer — its draft + in-flight/failed send state never cross tickets.
<MessageComposer key={ticketId} ticketId={ticketId} />
)}
</Box>
</> </>
)} )}
</Stack> </Stack>
@@ -0,0 +1,5 @@
/** A client-generated id so the optimistic bubble reconciles to the server message (never a double-render). */
export function makeClientMessageId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
+7
View File
@@ -12,8 +12,15 @@ export { default as TicketListCard } from './TicketListCard';
export type { TicketListCardProps } from './TicketListCard'; export type { TicketListCardProps } from './TicketListCard';
export { default as EmergencyBanner } from './EmergencyBanner'; export { default as EmergencyBanner } from './EmergencyBanner';
export type { EmergencyBannerProps } from './EmergencyBanner'; export type { EmergencyBannerProps } from './EmergencyBanner';
export { default as EmergencyPlaybookRow } from './EmergencyPlaybookRow';
export type { EmergencyPlaybookRowProps } from './EmergencyPlaybookRow';
export { default as ContactSupportDialog } from './ContactSupportDialog'; export { default as ContactSupportDialog } from './ContactSupportDialog';
export type { ContactSupportDialogProps } from './ContactSupportDialog'; export type { ContactSupportDialogProps } from './ContactSupportDialog';
export { default as TicketInboxScreen } from './TicketInboxScreen'; export { default as TicketInboxScreen } from './TicketInboxScreen';
export { default as TicketThreadScreen } from './TicketThreadScreen'; export { default as TicketThreadScreen } from './TicketThreadScreen';
export { default as TicketConversationPanel } from './TicketConversationPanel';
export type { TicketConversationPanelProps } from './TicketConversationPanel';
export { default as BookingSupportEntry } from './BookingSupportEntry'; export { default as BookingSupportEntry } from './BookingSupportEntry';
// Consumable by phase 11's admin thread (same inverse-scroll bug) — §3.7 handshake.
export { useThreadScroll } from './useThreadScroll';
export type { UseThreadScrollResult } from './useThreadScroll';
@@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react';
/** How close to the bottom edge (in px) still counts as "already at the bottom" (§3.2). */
const NEAR_BOTTOM_THRESHOLD_PX = 120;
export interface UseThreadScrollResult {
/** Attach to a zero-height sentinel element rendered after the last message. */
bottomRef: (node: HTMLDivElement | null) => void;
/** A floating «پیام جدید ↓» pill should render while this is true. */
showNewMessagePill: boolean;
/** Scrolls to the newest message and dismisses the pill (the pill's own tap handler). */
scrollToNewest: () => void;
}
/**
* Chat scroll orchestration (§3.2), reusable across any message-list surface (this thread now; phase 11's
* admin thread scrollbox next it has the inverse bug, opening scrolled to the top). A thread opens
* scrolled to the **newest** message; sending always scrolls to the new bubble; receiving auto-scrolls only
* when the viewer is already near the bottom, otherwise shows a dismissible "new message" pill.
*
* Uses an `IntersectionObserver` on a bottom sentinel (not a tracked scroll container) so it works whether
* the *page* scrolls or an inner box does the observer's root is the viewport by default, which correctly
* accounts for whichever ancestor actually scrolls. Guarded for environments without
* `IntersectionObserver` (e.g. jsdom in tests): it degrades to "always treat as near the bottom".
*/
export function useThreadScroll(messageCount: number, lastMessageIsMine: boolean): UseThreadScrollResult {
const [showNewMessagePill, setShowNewMessagePill] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const isNearBottomRef = useRef(true);
const didInitialScrollRef = useRef(false);
const prevCountRef = useRef(0);
const scrollToNewest = () => {
sentinelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
setShowNewMessagePill(false);
};
const bottomRef = (node: HTMLDivElement | null) => {
sentinelRef.current = node;
};
useEffect(() => {
const node = sentinelRef.current;
if (!node || typeof IntersectionObserver === 'undefined') return undefined;
const observer = new IntersectionObserver(
([entry]) => {
isNearBottomRef.current = entry.isIntersecting;
if (entry.isIntersecting) setShowNewMessagePill(false);
},
{ rootMargin: `0px 0px ${NEAR_BOTTOM_THRESHOLD_PX}px 0px` },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (messageCount === 0) return;
if (!didInitialScrollRef.current) {
// Opens at the newest message (§3.2) — instant, no animation, on first load.
sentinelRef.current?.scrollIntoView({ behavior: 'auto', block: 'end' });
didInitialScrollRef.current = true;
prevCountRef.current = messageCount;
return;
}
if (messageCount > prevCountRef.current) {
if (lastMessageIsMine || isNearBottomRef.current) {
sentinelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
setShowNewMessagePill(false);
} else {
setShowNewMessagePill(true);
}
}
prevCountRef.current = messageCount;
}, [messageCount, lastMessageIsMine]);
return { bottomRef, showNewMessagePill, scrollToNewest };
}
@@ -1,9 +1,12 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { FunctionComponent, useState } from 'react';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { notificationsPath } from '@/constants'; import { notificationsPath } from '@/constants';
import { useUnreadCount } from '@/services/notifications'; import { useUnreadCount } from '@/services/notifications';
import NotificationBellPopover from './NotificationBellPopover';
import NotificationBellView from './NotificationBellView'; import NotificationBellView from './NotificationBellView';
export interface NotificationBellProps { export interface NotificationBellProps {
@@ -13,9 +16,10 @@ export interface NotificationBellProps {
/** /**
* The notification bell **container** mounted in the app chrome. It subscribes to the polling * The notification bell **container** mounted in the app chrome. It subscribes to the polling
* `useUnreadCount` (stale-while-revalidate) and navigates to the role's notification center on click. Because * `useUnreadCount` (stale-while-revalidate) the only thing in this component that re-renders on a count
* only this small container reads the fast-changing count, a count change re-renders just the bell not the * change, so the shell around it never does (§5 isolation). On the **nurse desktop** shell (§3.6) it opens
* whole shell. * a popover preview instead of navigating; every other case (customer mobile-first, always navigates;
* nurse mobile; admin) keeps the direct full-page navigation.
* @component NotificationBell * @component NotificationBell
*/ */
const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) => { const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) => {
@@ -23,13 +27,33 @@ const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) =>
const router = useRouter(); const router = useRouter();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations('notifications'); const t = useTranslations('notifications');
const theme = useTheme();
const isDesktop = useMediaQuery(theme.breakpoints.up('md'));
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const usesPopover = role === 'nurse' && isDesktop;
const onClick = () => {
if (usesPopover) {
setPopoverOpen(true);
return;
}
router.push(`/${locale}${notificationsPath(role)}`);
};
return ( return (
<NotificationBellView <>
count={count} <NotificationBellView ref={setAnchorEl} count={count} label={t('bell_aria', { count })} onClick={onClick} />
label={t('bell_aria', { count })} {usesPopover ? (
onClick={() => router.push(`/${locale}${notificationsPath(role)}`)} <NotificationBellPopover
open={popoverOpen}
anchorEl={anchorEl}
onClose={() => setPopoverOpen(false)}
role="nurse"
/> />
) : null}
</>
); );
}; };
@@ -0,0 +1,123 @@
'use client';
import { FunctionComponent } from 'react';
import Divider from '@mui/material/Divider';
import Popover from '@mui/material/Popover';
import Skeleton from '@mui/material/Skeleton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { useTheme } from '@mui/material/styles';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import AppButton from '@/components/common/AppButton';
import { notificationsPath } from '@/constants';
import {
notificationDeepLink,
useMarkAllRead,
useMarkNotificationRead,
useNotifications,
} from '@/services/notifications';
import type { AppNotification } from '@/services/notifications/types';
import { formatRelativeTime, formatShamsiDate } from '@/utils';
import NotificationRow from './NotificationRow';
/** The 5 most recent — a preview, not the full center (§3.6). */
const POPOVER_RECENT_COUNT = 5;
export interface NotificationBellPopoverProps {
open: boolean;
anchorEl: HTMLElement | null;
onClose: () => void;
role: 'nurse';
}
/**
* The nurse-desktop bell's popover preview (§3.6) the 5 most recent notifications, mark-all-read, and
* «مشاهده همه» to the full center. Fetches **on open** (`enabled: open`), reusing the same
* `notificationKeys` cache the full center reads never on the polled count's tick, and it never reads
* `useUnreadCount` itself (only the bell container does §5 isolation).
* @component NotificationBellPopover
*/
const NotificationBellPopover: FunctionComponent<NotificationBellPopoverProps> = ({ open, anchorEl, onClose, role }) => {
const t = useTranslations('notifications');
const locale = useLocale();
const router = useRouter();
const theme = useTheme();
const edge = theme.direction === 'rtl' ? 'left' : 'right';
const { data, isLoading } = useNotifications(POPOVER_RECENT_COUNT, { enabled: open });
const markRead = useMarkNotificationRead();
const markAll = useMarkAllRead();
const items = data?.items ?? [];
const hasUnread = items.some((n) => !n.isRead);
const openNotification = (notification: AppNotification) => {
if (!notification.isRead) markRead.mutate(notification.id);
const target = notificationDeepLink(notification, role);
if (target) {
router.push(`/${locale}${target}`);
onClose();
}
};
const viewAll = () => {
router.push(`/${locale}${notificationsPath(role)}`);
onClose();
};
return (
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: edge }}
transformOrigin={{ vertical: 'top', horizontal: edge }}
>
<Stack sx={{ width: 360, maxWidth: '90vw', p: 1.5, gap: 1.5 }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{t('title')}
</Typography>
{hasUnread ? (
<AppButton variant="text" color="primary" size="small" onClick={() => markAll.mutate()} disabled={markAll.isPending}>
{t('mark_all_read')}
</AppButton>
) : null}
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1 }}>
{[0, 1].map((i) => (
<Skeleton key={i} variant="rounded" height={64} />
))}
</Stack>
) : items.length === 0 ? (
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)', textAlign: 'center', py: 2 }}>
{t('empty_title')}
</Typography>
) : (
<Stack sx={{ gap: 1 }}>
{items.map((notification) => {
const target = notificationDeepLink(notification, role);
return (
<NotificationRow
key={notification.id}
notification={notification}
timeLabel={formatRelativeTime(notification.createdAt, locale, formatShamsiDate)}
navigable={target != null}
onOpen={() => openNotification(notification)}
/>
);
})}
</Stack>
)}
<Divider />
<AppButton variant="text" color="primary" onClick={viewAll} sx={{ alignSelf: 'center' }}>
{t('view_all')}
</AppButton>
</Stack>
</Popover>
);
};
export default NotificationBellPopover;
@@ -1,5 +1,5 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { forwardRef } from 'react';
import Badge from '@mui/material/Badge'; import Badge from '@mui/material/Badge';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import { AppIcon } from '@/components/common'; import { AppIcon } from '@/components/common';
@@ -15,12 +15,13 @@ export interface NotificationBellViewProps {
/** /**
* The notification bell a pure badge + icon button. `count` drives the badge (hidden at 0, capped at 99+). * The notification bell a pure badge + icon button. `count` drives the badge (hidden at 0, capped at 99+).
* Presentational and self-contained so the fast-changing count re-renders only the bell, never the shell * Presentational and self-contained so the fast-changing count re-renders only the bell, never the shell
* around it (the count is fed by the polling `useUnreadCount` in the `NotificationBell` container). * around it (the count is fed by the polling `useUnreadCount` in the `NotificationBell` container). Forwards
* its ref to the underlying button so the container can anchor a desktop popover to it (§3.6).
* @component NotificationBellView * @component NotificationBellView
*/ */
const NotificationBellView: FunctionComponent<NotificationBellViewProps> = ({ count, label, onClick }) => { const NotificationBellView = forwardRef<HTMLButtonElement, NotificationBellViewProps>(({ count, label, onClick }, ref) => {
return ( return (
<IconButton onClick={onClick} aria-label={label} data-testid="notification-bell" color="inherit"> <IconButton ref={ref} onClick={onClick} aria-label={label} data-testid="notification-bell" color="inherit">
<Badge <Badge
badgeContent={count} badgeContent={count}
max={99} max={99}
@@ -31,6 +32,8 @@ const NotificationBellView: FunctionComponent<NotificationBellViewProps> = ({ co
</Badge> </Badge>
</IconButton> </IconButton>
); );
}; });
NotificationBellView.displayName = 'NotificationBellView';
export default NotificationBellView; export default NotificationBellView;
@@ -15,18 +15,64 @@ import {
useNotifications, useNotifications,
} from '@/services/notifications'; } from '@/services/notifications';
import type { AppNotification } from '@/services/notifications/types'; import type { AppNotification } from '@/services/notifications/types';
import { formatShamsiDateTime } from '@/utils'; import { formatRelativeTime, formatShamsiDate } from '@/utils';
import NotificationRow from './NotificationRow'; import NotificationRow from './NotificationRow';
export interface NotificationCenterProps { export interface NotificationCenterProps {
role: 'customer' | 'nurse'; role: 'customer' | 'nurse';
} }
interface NotificationGroup {
key: string;
label: string;
items: AppNotification[];
}
/** /**
* The notification center a paged, **unread-first** list. Each row **marks itself read on open** (optimistic) * Buckets notifications into امروز/دیروز/این هفته, then per-day Shamsi headers for anything older
* and **deep-links via `notificationDeepLink`** (role-aware) when its `data` points somewhere. A "mark all read" * emitted **in list order** (the server's unread-first-then-newest ordering is preserved; §5 "keep the
* action clears the badge at once. Empty / loading-skeleton / errorretry states. Shared by the customer and * mark-read UX as is"), so a bucket can recur if an older unread item is pinned above newer read ones.
* nurse notification pages (role decides only the deep-link shell). * Compares already-formatted date strings (not raw ms diffs) so a day boundary is calendar-exact.
*/
function groupByDay(items: AppNotification[], locale: string, todayLabel: string, yesterdayLabel: string, thisWeekLabel: string): NotificationGroup[] {
const now = new Date();
const todayStr = formatShamsiDate(now, locale);
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = formatShamsiDate(yesterday, locale);
const thisWeekStrs = new Set<string>();
for (let i = 2; i < 7; i += 1) {
const d = new Date(now);
d.setDate(d.getDate() - i);
thisWeekStrs.add(formatShamsiDate(d, locale));
}
const groups: NotificationGroup[] = [];
for (const item of items) {
const dayStr = formatShamsiDate(item.createdAt, locale);
const bucket =
dayStr === todayStr
? { key: 'today', label: todayLabel }
: dayStr === yesterdayStr
? { key: 'yesterday', label: yesterdayLabel }
: thisWeekStrs.has(dayStr)
? { key: 'this_week', label: thisWeekLabel }
: { key: dayStr, label: dayStr };
const last = groups[groups.length - 1];
if (last && last.key === bucket.key) last.items.push(item);
else groups.push({ ...bucket, items: [item] });
}
return groups;
}
/**
* The notification center a paged, **unread-first** list, day-grouped (امروز/دیروز/این هفته, then Shamsi
* dates) with relative timestamps decaying to Shamsi. Each row **marks itself read on open** (optimistic);
* a row whose `data` deep-links renders interactive with a trailing chevron, a row with nothing to open
* renders as a plain, non-rippling surface. A "mark all read" action clears the badge at once. Empty /
* loading-skeleton / errorretry states. Shared by the customer and nurse notification pages (role decides
* only the deep-link shell).
* @component NotificationCenter * @component NotificationCenter
*/ */
const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }) => { const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }) => {
@@ -42,6 +88,7 @@ const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }
const items = data?.items ?? []; const items = data?.items ?? [];
const total = data?.total ?? 0; const total = data?.total ?? 0;
const hasUnread = items.some((n) => !n.isRead); const hasUnread = items.some((n) => !n.isRead);
const groups = groupByDay(items, locale, t('group_today'), t('group_yesterday'), t('group_this_week'));
const openNotification = (notification: AppNotification) => { const openNotification = (notification: AppNotification) => {
if (!notification.isRead) markRead.mutate(notification.id); if (!notification.isRead) markRead.mutate(notification.id);
@@ -94,14 +141,27 @@ const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }
</Typography> </Typography>
</Stack> </Stack>
) : ( ) : (
<Stack sx={{ gap: 2 }}>
{groups.map((group) => (
<Stack key={`${group.key}-${group.items[0].id}`} sx={{ gap: 1 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-text-secondary)' }}>
{group.label}
</Typography>
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
{items.map((notification) => ( {group.items.map((notification) => {
const target = notificationDeepLink(notification, role);
return (
<NotificationRow <NotificationRow
key={notification.id} key={notification.id}
notification={notification} notification={notification}
timeLabel={formatShamsiDateTime(notification.createdAt, locale)} timeLabel={formatRelativeTime(notification.createdAt, locale, formatShamsiDate)}
navigable={target != null}
onOpen={() => openNotification(notification)} onOpen={() => openNotification(notification)}
/> />
);
})}
</Stack>
</Stack>
))} ))}
{total > items.length ? ( {total > items.length ? (
<AppButton <AppButton
@@ -13,11 +13,11 @@ const base: AppNotification = {
data: { kind: 'booking', bookingId: 5001 }, data: { kind: 'booking', bookingId: 5001 },
}; };
function renderRow(overrides: Partial<AppNotification>) { function renderRow(overrides: Partial<AppNotification>, navigable = true) {
const onOpen = jest.fn(); const onOpen = jest.fn();
render( render(
<ThemeProvider> <ThemeProvider>
<NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" onOpen={onOpen} /> <NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" navigable={navigable} onOpen={onOpen} />
</ThemeProvider>, </ThemeProvider>,
); );
return onOpen; return onOpen;
@@ -47,4 +47,17 @@ describe('<NotificationRow/> component', () => {
fireEvent.click(screen.getByTestId('notification-row')); fireEvent.click(screen.getByTestId('notification-row'));
expect(onOpen).toHaveBeenCalledTimes(1); expect(onOpen).toHaveBeenCalledTimes(1);
}); });
it('renders a non-interactive surface (no button role) when not navigable, but still marks read', () => {
const onOpen = renderRow({}, false);
const row = screen.getByTestId('notification-row');
expect(row).toHaveAttribute('data-navigable', 'false');
fireEvent.click(row);
expect(onOpen).toHaveBeenCalledTimes(1);
});
it('renders as an interactive button with a real role when navigable', () => {
renderRow({}, true);
expect(screen.getByTestId('notification-row')).toHaveAttribute('data-navigable', 'true');
});
}); });
@@ -1,49 +1,55 @@
'use client'; 'use client';
import { FunctionComponent } from 'react'; import { FunctionComponent, KeyboardEvent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase'; import ButtonBase from '@mui/material/ButtonBase';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { AppIcon } from '@/components/common'; import { AppIcon } from '@/components/common';
import type { AppNotification } from '@/services/notifications/types'; import type { AppNotification } from '@/services/notifications/types';
import { notificationIcon } from './notificationIcon'; import { notificationIcon, notificationTint } from './notificationIcon';
export interface NotificationRowProps { export interface NotificationRowProps {
notification: AppNotification; notification: AppNotification;
/** Pre-formatted Shamsi time — the caller owns locale. */ /** Pre-formatted relative time (decaying to Shamsi) — the caller owns locale. */
timeLabel: string; timeLabel: string;
/** Marks the notification read (optimistic) and, when it deep-links, navigates. */ /** Whether `notificationDeepLink` resolved a route — drives interactive chrome vs a plain, static surface. */
navigable: boolean;
/** Marks the notification read (optimistic) and, when navigable, navigates. */
onOpen: () => void; onOpen: () => void;
} }
/** /**
* One row in the notification center. **Unread** rows are emphasised (a leading dot + bolded title + a soft * One row in the notification center. **Unread** rows are emphasised (a leading dot + bolded title + a soft
* tint); opening a row marks it read (optimistic) and, when its `data` deep-links, navigates there. The * tint); a **per-kind tinted icon container** (booking teal / payout success / ticket terracotta / refund
* icon is chosen from the parsed deep-link class. Purely presentational the caller supplies the formatted * info / nurse-trust) replaces the uniform primary icon. **Navigable** rows (their `data` deep-links
* time and the `onOpen` behaviour (mark-read + `notificationDeepLink`). `title`/`body` are server-rendered * somewhere) render as a real `ButtonBase` with a trailing chevron and a visible `:focus-visible` ring;
* copy (not client i18n keys). * **non-navigable** rows (`data.kind === 'none'` or a target that doesn't apply to this role) render as a
* plain, static surface no ripple, no pointer cursor, no chevron so a tap that does nothing never
* *looks* like it should do something, while still marking the row read. Purely presentational the caller
* supplies the formatted time, whether it's navigable, and the `onOpen` behaviour (mark-read +
* `notificationDeepLink`). `title`/`body` are server-rendered copy (not client i18n keys).
* @component NotificationRow * @component NotificationRow
*/ */
const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification, timeLabel, onOpen }) => { const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification, timeLabel, navigable, onOpen }) => {
const unread = !notification.isRead; const unread = !notification.isRead;
return ( const tint = notificationTint(notification.data.kind);
<ButtonBase
onClick={onOpen} const content = (
data-testid="notification-row" <Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start', width: '100%' }}>
data-unread={unread ? 'true' : 'false'} <Box
sx={{ sx={{
display: 'block', width: 36,
width: '100%', height: 36,
textAlign: 'start', borderRadius: '50%',
p: 1.5, bgcolor: tint.bg,
borderRadius: 2, display: 'flex',
border: '1px solid', alignItems: 'center',
borderColor: 'divider', justifyContent: 'center',
bgcolor: unread ? 'var(--bal-primary-soft)' : 'var(--bal-bg-paper)', flexShrink: 0,
}} }}
> >
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start', width: '100%' }}> <AppIcon icon={notificationIcon(notification.data.kind)} size={18} color={tint.fg} />
<AppIcon icon={notificationIcon(notification.data.kind)} size={20} color="var(--bal-primary)" /> </Box>
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}> <Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
{unread ? ( {unread ? (
@@ -65,9 +71,65 @@ const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification
{timeLabel} {timeLabel}
</Typography> </Typography>
</Stack> </Stack>
{navigable ? <AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" /> : null}
</Stack> </Stack>
);
if (navigable) {
return (
<ButtonBase
onClick={onOpen}
data-testid="notification-row"
data-unread={unread ? 'true' : 'false'}
data-navigable="true"
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: unread ? 'var(--bal-primary-soft)' : 'var(--bal-bg-paper)',
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
}}
>
{content}
</ButtonBase> </ButtonBase>
); );
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onOpen();
}
};
return (
<Box
onClick={onOpen}
onKeyDown={onKeyDown}
role="button"
tabIndex={0}
data-testid="notification-row"
data-unread={unread ? 'true' : 'false'}
data-navigable="false"
sx={{
width: '100%',
textAlign: 'start',
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: unread ? 'var(--bal-primary-soft)' : 'var(--bal-bg-paper)',
cursor: 'default',
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
}}
>
{content}
</Box>
);
}; };
export default NotificationRow; export default NotificationRow;
@@ -7,6 +7,9 @@ export { default as NotificationBell } from './NotificationBell';
export type { NotificationBellProps } from './NotificationBell'; export type { NotificationBellProps } from './NotificationBell';
export { default as NotificationBellView } from './NotificationBellView'; export { default as NotificationBellView } from './NotificationBellView';
export type { NotificationBellViewProps } from './NotificationBellView'; export type { NotificationBellViewProps } from './NotificationBellView';
// Consumable by phase 11's admin shell once it has a real feed (§3.7 handshake) — same popover, admin role.
export { default as NotificationBellPopover } from './NotificationBellPopover';
export type { NotificationBellPopoverProps } from './NotificationBellPopover';
export { default as NotificationRow } from './NotificationRow'; export { default as NotificationRow } from './NotificationRow';
export type { NotificationRowProps } from './NotificationRow'; export type { NotificationRowProps } from './NotificationRow';
export { default as NotificationCenter } from './NotificationCenter'; export { default as NotificationCenter } from './NotificationCenter';
@@ -18,3 +18,35 @@ export function notificationIcon(kind: NotificationData['kind']): string {
return 'info'; return 'info';
} }
} }
export interface NotificationTint {
/** Soft-tint background for the icon container. */
bg: string;
/** Foreground — the icon color + used for any accent on the row. */
fg: string;
}
/**
* Per-kind visual identity (§3.5) a soft-tinted icon container instead of the uniform primary icon, all
* from `--bal-*` semantic tokens so dark mode holds by construction. Booking reads teal (the brand's main
* color), payout success-green, a ticket/support notification warm terracotta (the one deliberate secondary
* accent, echoing "a human from Balinyaar"), a refund/nurse-trust signal info/trust, and a non-deep-linking
* notification a fully neutral tint.
*/
export function notificationTint(kind: NotificationData['kind']): NotificationTint {
switch (kind) {
case 'booking':
return { bg: 'var(--bal-primary-soft)', fg: 'var(--bal-primary)' };
case 'payout':
return { bg: 'var(--bal-success-soft)', fg: 'var(--bal-success)' };
case 'ticket':
return { bg: 'var(--bal-secondary-soft)', fg: 'var(--bal-secondary)' };
case 'refund':
return { bg: 'var(--bal-info-soft)', fg: 'var(--bal-info)' };
case 'nurse_profile':
return { bg: 'var(--bal-trust-soft)', fg: 'var(--bal-trust)' };
case 'none':
default:
return { bg: 'var(--bal-divider)', fg: 'var(--bal-text-secondary)' };
}
}
+5 -4
View File
@@ -1,7 +1,6 @@
'use client'; 'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { NotificationBell } from '@/components/notifications';
import { ProfileSummary } from '@/components'; import { ProfileSummary } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities } from '@/hooks';
@@ -13,8 +12,11 @@ import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
* Admin / backoffice shell the desktop ops console (f15). The sidebar is **sectioned** * Admin / backoffice shell the desktop ops console (f15). The sidebar is **sectioned**
* (اعتماد/مالی/پشتیبانی/سیستم) and **role-gated**: each console appears only when the current * (اعتماد/مالی/پشتیبانی/سیستم) and **role-gated**: each console appears only when the current
* admin role can act on it (`useAdminCapabilities`), unchanged from before grouping never adds, * admin role can act on it (`useAdminCapabilities`), unchanged from before grouping never adds,
* removes, or loosens a gate. The TopBar carries a page title, the notification bell (widened to * removes, or loosens a gate. The TopBar carries a page title and a compact identity chip showing
* the `'admin'` role), and a compact identity chip showing the admin's fine-grained role. * the admin's fine-grained role. **No notification bell** (ui-phase-10): admin notifications have no
* real feed yet (phase 11 owns that decision) and the bell was the only entry into the dead
* `admin/notifications` placeholder re-add it once phase 11 ships a feed, reusing
* `NotificationBellPopover` (already exported for that handoff).
* @layout AdminLayout * @layout AdminLayout
*/ */
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => { const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
@@ -50,7 +52,6 @@ const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
return ( return (
<TopBarAndSideBarLayout <TopBarAndSideBarLayout
sidebarItems={sidebarItems} sidebarItems={sidebarItems}
headerActions={<NotificationBell role="admin" />}
identity={ identity={
me && primaryRoleCode ? ( me && primaryRoleCode ? (
<ProfileSummary <ProfileSummary
+11 -1
View File
@@ -1,11 +1,12 @@
'use client'; 'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { Box, Stack, Tab, Tabs } from '@mui/material'; import { Badge, Box, Stack, Tab, Tabs } from '@mui/material';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { AppIcon, AppIconButton, ErrorBoundary } from '@/components'; import { AppIcon, AppIconButton, ErrorBoundary } from '@/components';
import { NotificationBell } from '@/components/notifications'; import { NotificationBell } from '@/components/notifications';
import { CONTENT_MAX_WIDTH } from '@/components/config'; import { CONTENT_MAX_WIDTH } from '@/components/config';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { useSupportUnreadTotal } from '@/services/tickets';
import { LinkToPage } from '@/utils'; import { LinkToPage } from '@/utils';
import { usePathname, useRouter } from '@/i18n/navigation'; import { usePathname, useRouter } from '@/i18n/navigation';
import { TopBar, BottomBar } from './components'; import { TopBar, BottomBar } from './components';
@@ -50,18 +51,27 @@ const CustomerHeader: FunctionComponent<{ bottomNavItems: Array<LinkToPage> }> =
const router = useRouter(); const router = useRouter();
const title = useRouteTitle(); const title = useRouteTitle();
const onRootTab = isCustomerRootTab(pathname); const onRootTab = isCustomerRootTab(pathname);
const supportUnreadTotal = useSupportUnreadTotal();
return ( return (
<TopBar <TopBar
align={onRootTab ? 'center' : 'start'} align={onRootTab ? 'center' : 'start'}
startNode={ startNode={
onRootTab ? ( onRootTab ? (
<Badge
badgeContent={supportUnreadTotal ?? 0}
max={99}
overlap="circular"
invisible={!supportUnreadTotal}
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
<AppIconButton <AppIconButton
icon="support" icon="support"
color="inherit" color="inherit"
title={t('support')} title={t('support')}
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)} onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
/> />
</Badge>
) : ( ) : (
<AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} /> <AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} />
) )
+10 -2
View File
@@ -8,6 +8,7 @@ import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils'; import { LinkToPage } from '@/utils';
import { useMe } from '@/services/auth'; import { useMe } from '@/services/auth';
import { useNurseProfile } from '@/services/profiles'; import { useNurseProfile } from '@/services/profiles';
import { useSupportUnreadTotal } from '@/services/tickets';
import { useVerificationStatus } from '@/services/verification'; import { useVerificationStatus } from '@/services/verification';
import { ownBadgeState } from '@/services/verification/types'; import { ownBadgeState } from '@/services/verification/types';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout'; import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
@@ -29,6 +30,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const { data: me } = useMe(); const { data: me } = useMe();
const { data: nurseProfile } = useNurseProfile(); const { data: nurseProfile } = useNurseProfile();
const { data: verification } = useVerificationStatus(); const { data: verification } = useVerificationStatus();
const supportUnreadTotal = useSupportUnreadTotal();
const identityLoading = !me; const identityLoading = !me;
const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : ''; const displayName = me ? [me.firstName, me.lastName].filter(Boolean).join(' ').trim() || me.phone : '';
@@ -48,9 +50,15 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification', group: groupProfession }, { title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification', group: groupProfession },
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings', group: groupFinance }, { title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings', group: groupFinance },
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank', group: groupFinance }, { title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank', group: groupFinance },
{ title: t('support'), path: ROUTES.NURSE_SUPPORT_TICKETS, icon: 'support', group: groupSupport }, {
title: t('support'),
path: ROUTES.NURSE_SUPPORT_TICKETS,
icon: 'support',
group: groupSupport,
badgeCount: supportUnreadTotal ?? undefined,
},
]; ];
}, [t]); }, [t, supportUnreadTotal]);
const mobileTabs = useMemo( const mobileTabs = useMemo(
(): Array<LinkToPage> => [ (): Array<LinkToPage> => [
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { FunctionComponent, MouseEventHandler } from 'react'; import { FunctionComponent, MouseEventHandler } from 'react';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { Badge, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { AppIcon } from '@/components'; import { AppIcon } from '@/components';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { LinkToPage } from '@/utils'; import { LinkToPage } from '@/utils';
@@ -14,13 +14,36 @@ interface Props extends LinkToPage {
* Renders a single SideBar navigation item over the locale-aware `Link` (`@/i18n/navigation`) * Renders a single SideBar navigation item over the locale-aware `Link` (`@/i18n/navigation`)
* `href` is the unprefixed `ROUTES.*` path; the wrapper adds the active locale, so a click is one * `href` is the unprefixed `ROUTES.*` path; the wrapper adds the active locale, so a click is one
* navigation with no middleware redirect hop. `selected` is computed by the caller (`SideBarNavList`) * navigation with no middleware redirect hop. `selected` is computed by the caller (`SideBarNavList`)
* via the shared `matchActivePath` helper. * via the shared `matchActivePath` helper. `badgeCount` renders a small unread dot on the icon (the
* nurse support entry, §3.1) omitted entirely when falsy, never a bare "0".
* @component SideBarNavItem * @component SideBarNavItem
*/ */
const SideBarNavItem: FunctionComponent<Props> = ({ icon, path, selected = false, subtitle, title, onClick }) => { const SideBarNavItem: FunctionComponent<Props> = ({
icon,
path,
selected = false,
subtitle,
title,
onClick,
badgeCount,
}) => {
const iconNode = icon && <AppIcon icon={icon} />;
return ( return (
<ListItemButton component={Link} href={path ?? '#'} selected={selected} onClick={onClick}> <ListItemButton component={Link} href={path ?? '#'} selected={selected} onClick={onClick}>
<ListItemIcon>{icon && <AppIcon icon={icon} />}</ListItemIcon> <ListItemIcon>
{badgeCount ? (
<Badge
badgeContent={badgeCount}
max={99}
overlap="circular"
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
>
{iconNode}
</Badge>
) : (
iconNode
)}
</ListItemIcon>
<ListItemText primary={title} secondary={subtitle} /> <ListItemText primary={title} secondary={subtitle} />
</ListItemButton> </ListItemButton>
); );
@@ -42,6 +42,7 @@ const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick })
subtitle={item.subtitle} subtitle={item.subtitle}
selected={item.path === activePath} selected={item.path === activePath}
onClick={onClick} onClick={onClick}
badgeCount={item.badgeCount}
/> />
</Fragment> </Fragment>
); );
@@ -10,7 +10,7 @@ import { NOTIFICATIONS_GC_TIME, NOTIFICATIONS_LIST_STALE_TIME, NOTIFICATIONS_PAG
* avoids a flash. **Not polled** only `useUnreadCount` revalidates on an interval; opening a notification / * avoids a flash. **Not polled** only `useUnreadCount` revalidates on an interval; opening a notification /
* mark-all `setQueryData`s this cache and invalidates on settle. * mark-all `setQueryData`s this cache and invalidates on settle.
*/ */
export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE) { export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE, options?: { enabled?: boolean }) {
const params = { page: 1, pageSize: limit }; const params = { page: 1, pageSize: limit };
return useQuery({ return useQuery({
queryKey: notificationKeys.list(params), queryKey: notificationKeys.list(params),
@@ -18,5 +18,6 @@ export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE) {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: NOTIFICATIONS_LIST_STALE_TIME, staleTime: NOTIFICATIONS_LIST_STALE_TIME,
gcTime: NOTIFICATIONS_GC_TIME, gcTime: NOTIFICATIONS_GC_TIME,
enabled: options?.enabled ?? true,
}); });
} }
@@ -33,6 +33,7 @@ interface TicketSummaryWire {
createdAt: string; createdAt: string;
lastMessageAt: string | null; lastMessageAt: string | null;
unreadCount: number; unreadCount: number;
// REQ-059 gap (extends REQ-028) — not yet on the wire; mapped as absent below until delivered.
} }
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */ /** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
@@ -72,6 +73,9 @@ function mapSummary(w: TicketSummaryWire): TicketSummary {
// REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire. // REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire.
lastMessageAt: w.lastMessageAt, lastMessageAt: w.lastMessageAt,
unreadCount: w.unreadCount, unreadCount: w.unreadCount,
// REQ-059 gap — the wire summary has neither field yet; the card degrades gracefully (§3.1).
lastMessagePreview: null,
lastAuthorRole: null,
}; };
} }
@@ -215,6 +219,9 @@ export const ticketsClientApi: TicketsApi = {
}), }),
), ),
// No wire aggregate yet (REQ-059) — the chrome badge (§3.1) renders only when a signal exists.
getUnreadTotal: async (): Promise<number | null> => null,
// ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ── // ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ──
listAdminTickets: async ( listAdminTickets: async (
filters: AdminTicketFilters, filters: AdminTicketFilters,
+30 -3
View File
@@ -167,10 +167,28 @@ function findTicket(id: number): StoredTicket {
return t; return t;
} }
/** Last non-internal message time — the inbox's "last activity" (REQ-028 stand-in for `lastMessageAt`). */ /** Last non-internal message — the inbox's "last activity" (visible messages only; internal notes never surface). */
function lastMessageAt(t: StoredTicket): string { function lastVisibleMessage(t: StoredTicket): StoredMessage | undefined {
const visible = t.messages.filter((m) => !m.internal); const visible = t.messages.filter((m) => !m.internal);
return visible.length ? visible[visible.length - 1].sentAt : t.messages[0]?.sentAt ?? new Date().toISOString(); return visible.length ? visible[visible.length - 1] : t.messages[0];
}
function lastMessageAt(t: StoredTicket): string {
return lastVisibleMessage(t)?.sentAt ?? new Date().toISOString();
}
/** First ~80 chars of the last visible message — the REQ-059 inbox preview line, mock-only until delivered. */
const PREVIEW_MAX_CHARS = 80;
function lastMessagePreview(t: StoredTicket): string | null {
const body = lastVisibleMessage(t)?.body?.trim();
if (!body) return null;
return body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}` : body;
}
function lastAuthorRole(t: StoredTicket): TicketAuthorRole | null {
const senderId = lastVisibleMessage(t)?.senderId;
if (senderId == null) return null;
return t.participants.find((p) => p.userId === senderId)?.roleOnTicket ?? 'system';
} }
function toSummary(t: StoredTicket): TicketSummary { function toSummary(t: StoredTicket): TicketSummary {
@@ -185,6 +203,8 @@ function toSummary(t: StoredTicket): TicketSummary {
createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(), createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
lastMessageAt: lastMessageAt(t), lastMessageAt: lastMessageAt(t),
unreadCount: t.unread, unreadCount: t.unread,
lastMessagePreview: lastMessagePreview(t),
lastAuthorRole: lastAuthorRole(t),
}; };
} }
@@ -331,6 +351,13 @@ export const ticketsMockApi: TicketsApi = {
return { ticketId: id, referenceCode: ticket.referenceCode, status: 'open', category: ticket.category }; return { ticketId: id, referenceCode: ticket.referenceCode, status: 'open', category: ticket.category };
}, },
// §3.1 chrome support-badge — sums unread across every seeded ticket (a demo-world stand-in for a
// real server-side aggregate; REQ-059).
getUnreadTotal: async (): Promise<number | null> => {
await sleep(MOCK_LATENCY_MS);
return tickets.reduce((sum, t) => sum + t.unread, 0);
},
postMessage: async (ticketId: number, body: PostMessageRequest): Promise<PostMessageResult> => { postMessage: async (ticketId: number, body: PostMessageRequest): Promise<PostMessageResult> => {
await sleep(MOCK_LATENCY_MS); await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId); const t = findTicket(ticketId);
+14
View File
@@ -26,6 +26,20 @@ export const TICKETS_LIST_STALE_TIME = 30 * 1000;
export const TICKET_THREAD_STALE_TIME = 15 * 1000; export const TICKET_THREAD_STALE_TIME = 15 * 1000;
export const TICKETS_GC_TIME = 5 * 60 * 1000; export const TICKETS_GC_TIME = 5 * 60 * 1000;
/**
* Poll the open thread while it's mounted (§3.2) TanStack Query only polls while the query has an
* active observer, so this is automatically scoped to the thread screen. Proportionate to a support
* conversation, not a real-time chat; SSE can replace this later behind the same query.
*/
export const TICKET_THREAD_REFETCH_INTERVAL = 15 * 1000;
/**
* Photo-attachment affordance capability gate (§3.3) the composer's attachment button is designed but
* renders only when this is on. Default **off**: the object-storage linkage for ticket messages is a
* backend gap (REQ-060); flip once that contract lands no component change beyond this flag.
*/
export const TICKETS_ATTACHMENTS_ENABLED = false;
/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */ /** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */
export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000; export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000;
@@ -0,0 +1,20 @@
import { useQueryClient } from '@tanstack/react-query';
import { ticketKeys } from '../keys';
import type { TicketDetail } from '../types';
/**
* The composer's "discard and retype" affordance on a failed bubble (§3.3) removes it from the cached
* thread so the caller can restore its text into the composer draft. Not a mutation: a message that never
* left the client has nothing to tell the server.
*/
export function useDiscardFailedMessage() {
const queryClient = useQueryClient();
return (ticketId: number, clientMessageId: string): void => {
const key = ticketKeys.detail(ticketId);
queryClient.setQueryData<TicketDetail>(key, (current) =>
current
? { ...current, messages: current.messages.filter((m) => m.clientMessageId !== clientMessageId) }
: current,
);
};
}
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketKeys } from '../keys'; import { ticketKeys } from '../keys';
import { ticketsApi } from '../apis'; import { ticketsApi } from '../apis';
import type { PostMessageResult, TicketDetail, TicketMessage } from '../types'; import type { PostMessageResult, TicketDetail } from '../types';
import { useTicketViewer } from './useTicketViewer'; import { useTicketViewer } from './useTicketViewer';
interface PostMessageVars { interface PostMessageVars {
@@ -10,33 +10,40 @@ interface PostMessageVars {
clientMessageId: string; clientMessageId: string;
} }
interface PostMessageContext {
previous?: TicketDetail;
}
/** /**
* The optimistic message send the interaction that must feel instant (phase §3.5). * The optimistic message send the interaction that must feel instant, and a failure must never lose the
* typed text (phase §3.3/§5 invariant).
* *
* `onMutate` appends a **pending** bubble to `detail(id)` (after `cancelQueries` + a snapshot) so it shows * `onMutate` is idempotent on `clientMessageId`: a **fresh** send appends a pending bubble; a **retry** (the
* immediately with a "sending" state. `onError` **rolls the thread back to the snapshot** (removing the * same id already sitting in the cache with `sendStatus: 'failed'`) flips it back to `sending` in place
* pending bubble) and rejects the composer keeps the typed draft and offers retry (never retype). `onSuccess` * instead of appending a duplicate so retry and first-send are the same call. `onError` does **not** roll
* replaces the pending bubble **by `clientMessageId`** with the server message (so it never double-renders). * the thread back it flips the bubble to `sendStatus: 'failed'` and leaves it in the thread with its typed
* `onSettled` invalidates the thread + the inbox lists (last-activity/unread move). The composer clears the * body, so the failed-bubble UI (retry / discard-and-retype) always has something to act on. `onSuccess`
* draft **only** in its own `onSuccess`. * replaces the bubble **by `clientMessageId`** with the server message. `onSettled` invalidates the thread,
* the inbox lists (last-activity/unread move), and the chrome unread-total badge.
*/ */
export function usePostMessage(ticketId: number) { export function usePostMessage(ticketId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { role } = useTicketViewer(); const { role } = useTicketViewer();
const key = ticketKeys.detail(ticketId);
return useMutation<PostMessageResult, unknown, PostMessageVars, PostMessageContext>({ return useMutation<PostMessageResult, unknown, PostMessageVars>({
mutationFn: ({ body, clientMessageId }) => ticketsApi.postMessage(ticketId, { body, clientMessageId }), mutationFn: ({ body, clientMessageId }) => ticketsApi.postMessage(ticketId, { body, clientMessageId }),
onMutate: async ({ body, clientMessageId }) => { onMutate: async ({ body, clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
await queryClient.cancelQueries({ queryKey: key }); await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<TicketDetail>(key); const current = queryClient.getQueryData<TicketDetail>(key);
if (previous) { if (!current) return;
const pending: TicketMessage = { const alreadyPending = current.messages.some((m) => m.clientMessageId === clientMessageId);
queryClient.setQueryData<TicketDetail>(key, {
...current,
messages: alreadyPending
? current.messages.map((m) =>
m.clientMessageId === clientMessageId ? { ...m, body, sendStatus: 'sending' as const } : m,
)
: [
...current.messages,
{
id: null, id: null,
clientMessageId, clientMessageId,
ticketId, ticketId,
@@ -44,19 +51,24 @@ export function usePostMessage(ticketId: number) {
authorRole: role, authorRole: role,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
isMine: true, isMine: true,
sendStatus: 'sending', sendStatus: 'sending' as const,
}; },
queryClient.setQueryData<TicketDetail>(key, { ...previous, messages: [...previous.messages, pending] }); ],
} });
return { previous };
}, },
onError: (_err, _vars, context) => { onError: (_err, { clientMessageId }) => {
if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous); const current = queryClient.getQueryData<TicketDetail>(key);
if (!current) return;
queryClient.setQueryData<TicketDetail>(key, {
...current,
messages: current.messages.map((m) =>
m.clientMessageId === clientMessageId ? { ...m, sendStatus: 'failed' as const } : m,
),
});
}, },
onSuccess: (result, { clientMessageId }) => { onSuccess: (result, { clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
const current = queryClient.getQueryData<TicketDetail>(key); const current = queryClient.getQueryData<TicketDetail>(key);
if (current) { if (current) {
queryClient.setQueryData<TicketDetail>(key, { queryClient.setQueryData<TicketDetail>(key, {
@@ -71,8 +83,9 @@ export function usePostMessage(ticketId: number) {
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) }); queryClient.invalidateQueries({ queryKey: key });
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
queryClient.invalidateQueries({ queryKey: ticketKeys.unreadTotal() });
}, },
}); });
} }
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKETS_LIST_STALE_TIME } from '../constants';
/**
* The chrome support-entry unread badge (§3.1) a single number summed across the caller's tickets, or
* `null` when there's no signal to show (the real path, until REQ-059 lands). Callers must render the
* badge only when this is a positive number never a fake "0 unread" placeholder. Gated on
* authentication, same posture as `useUnreadCount`.
*/
export function useSupportUnreadTotal(): number | null {
const isAuthenticated = useIsAuthenticated();
const { data } = useQuery({
queryKey: ticketKeys.unreadTotal(),
queryFn: () => ticketsApi.getUnreadTotal(),
enabled: isAuthenticated,
staleTime: TICKETS_LIST_STALE_TIME,
gcTime: TICKETS_GC_TIME,
});
return data ?? null;
}
@@ -1,14 +1,16 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ticketsApi } from '../apis'; import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys'; import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants'; import { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, TICKET_THREAD_STALE_TIME } from '../constants';
import { useTicketViewer } from './useTicketViewer'; import { useTicketViewer } from './useTicketViewer';
/** /**
* The full ticket thread (header + participants + messages, user view internal messages already stripped). * The full ticket thread (header + participants + messages, user view internal messages already stripped).
* A single cached `detail(id)` entry: the contract returns the whole thread in one call (no message * A single cached `detail(id)` entry: the contract returns the whole thread in one call (no message
* pagination). The viewer id (from `/me`, or the mock fallback) drives which bubbles are "mine". * pagination). The viewer id (from `/me`, or the mock fallback) drives which bubbles are "mine".
* `usePostMessage` mutates this same entry optimistically. * `usePostMessage` mutates this same entry optimistically. **Polls while mounted** (§3.2) TanStack Query
* scopes `refetchInterval` to active observers, so this only ticks while a thread screen is open; no
* `refetchIntervalInBackground` (the global polling posture stays polite §5).
*/ */
export function useTicket(ticketId: number | undefined) { export function useTicket(ticketId: number | undefined) {
const { userId } = useTicketViewer(); const { userId } = useTicketViewer();
@@ -18,5 +20,6 @@ export function useTicket(ticketId: number | undefined) {
enabled: ticketId != null && ticketId > 0, enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME, staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME, gcTime: TICKETS_GC_TIME,
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
}); });
} }
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ticketsApi } from '../apis'; import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys'; import { ticketKeys } from '../keys';
import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants'; import { TICKETS_GC_TIME, TICKET_THREAD_REFETCH_INTERVAL, TICKET_THREAD_STALE_TIME } from '../constants';
import type { TicketMessage } from '../types'; import type { TicketMessage } from '../types';
import { useTicketViewer } from './useTicketViewer'; import { useTicketViewer } from './useTicketViewer';
@@ -9,7 +9,7 @@ import { useTicketViewer } from './useTicketViewer';
* Just the messages of a thread a `select` over the same `detail(id)` cache the header reads (mirrors the * Just the messages of a thread a `select` over the same `detail(id)` cache the header reads (mirrors the
* f8 `useBookingSessions = select over detail`). One network fetch feeds both; the message list re-renders on * f8 `useBookingSessions = select over detail`). One network fetch feeds both; the message list re-renders on
* a new message without re-rendering the thread header. Optimistic sends mutate `detail(id)`, so the list * a new message without re-rendering the thread header. Optimistic sends mutate `detail(id)`, so the list
* updates instantly. * updates instantly. Polls while mounted, same as `useTicket` (§3.2) both observers share one cache entry.
*/ */
export function useTicketThread(ticketId: number | undefined) { export function useTicketThread(ticketId: number | undefined) {
const { userId } = useTicketViewer(); const { userId } = useTicketViewer();
@@ -19,6 +19,7 @@ export function useTicketThread(ticketId: number | undefined) {
enabled: ticketId != null && ticketId > 0, enabled: ticketId != null && ticketId > 0,
staleTime: TICKET_THREAD_STALE_TIME, staleTime: TICKET_THREAD_STALE_TIME,
gcTime: TICKETS_GC_TIME, gcTime: TICKETS_GC_TIME,
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
select: (detail): TicketMessage[] => detail.messages, select: (detail): TicketMessage[] => detail.messages,
}); });
} }
+2
View File
@@ -7,6 +7,8 @@ export { useTicket } from './hooks/useTicket';
export { useTicketThread } from './hooks/useTicketThread'; export { useTicketThread } from './hooks/useTicketThread';
export { useOpenTicket } from './hooks/useOpenTicket'; export { useOpenTicket } from './hooks/useOpenTicket';
export { usePostMessage } from './hooks/usePostMessage'; export { usePostMessage } from './hooks/usePostMessage';
export { useSupportUnreadTotal } from './hooks/useSupportUnreadTotal';
export { useDiscardFailedMessage } from './hooks/useDiscardFailedMessage';
// Admin ticket lens (b15) — the global queue + admin thread (internal INCLUDED) + staff internal-note post. // Admin ticket lens (b15) — the global queue + admin thread (internal INCLUDED) + staff internal-note post.
export { useAdminTickets } from './hooks/useAdminTickets'; export { useAdminTickets } from './hooks/useAdminTickets';
+3
View File
@@ -20,6 +20,9 @@ export const ticketKeys = {
details: () => [...ticketKeys.all, 'detail'] as const, details: () => [...ticketKeys.all, 'detail'] as const,
detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const, detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const,
/** The chrome support-badge total (§3.1) — its own tiny key so it never collides with a list page's cache. */
unreadTotal: () => [...ticketKeys.all, 'unread_total'] as const,
// Admin lens — a separate subtree so the internal-carrying admin caches never collide with the user // Admin lens — a separate subtree so the internal-carrying admin caches never collide with the user
// caches above (and invalidating one never touches the other). Filters + page key the global queue. // caches above (and invalidating one never touches the other). Filters + page key the global queue.
adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const, adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const,
+14 -5
View File
@@ -40,9 +40,10 @@ export type TicketAuthorRole = 'customer' | 'nurse' | 'admin' | 'system';
export type MessageSendStatus = 'sent' | 'sending' | 'failed'; export type MessageSendStatus = 'sent' | 'sending' | 'failed';
/** /**
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are **not** * A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are real
* on the wire summary (REQ-028) they are optional and only the mock supplies them today; the inbox * (REQ-028, delivered). `lastMessagePreview`/`lastAuthorRole` are **not** on the wire summary yet
* renders the unread indicator / last-activity time only when present, else falls back to `createdAt`. * (REQ-059, an extension of REQ-028) they are optional and only the mock supplies them today; the
* inbox card degrades gracefully (no preview line, no empty slot) when they're absent.
*/ */
export interface TicketSummary { export interface TicketSummary {
id: number; id: number;
@@ -53,10 +54,12 @@ export interface TicketSummary {
bookingId: number | null; bookingId: number | null;
refundId: number | null; refundId: number | null;
createdAt: string; createdAt: string;
/** REQ-028 gap — the wire summary has no last-activity timestamp; mock-only until delivered. */
lastMessageAt?: string | null; lastMessageAt?: string | null;
/** REQ-028 gap — the wire summary has no unread count; mock-only until delivered. */
unreadCount?: number; unreadCount?: number;
/** REQ-059 gap — the first ~80 chars of the last non-internal message; mock-only until delivered. */
lastMessagePreview?: string | null;
/** REQ-059 gap — the last message's author role (labels the preview "you:" vs "them:"); mock-only. */
lastAuthorRole?: TicketAuthorRole | null;
} }
/** A participant on a ticket (`TicketParticipantDto`) — used to derive a message's author role. */ /** A participant on a ticket (`TicketParticipantDto`) — used to derive a message's author role. */
@@ -213,6 +216,12 @@ export interface TicketsApi {
*/ */
openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>; openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>;
postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>; postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>;
/**
* The chrome support-badge total (§3.1) the sum of unread across the caller's tickets. There is no
* wire endpoint for this yet (REQ-059); the real implementation returns `null` (no signal the badge
* renders only when a signal exists) and the mock sums its own `unreadCount`s.
*/
getUnreadTotal(): Promise<number | null>;
/* Admin lens (b15). Distinct methods so the internal-carrying admin view can never be reached through a /* Admin lens (b15). Distinct methods so the internal-carrying admin view can never be reached through a
* user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket` * user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket`
+35
View File
@@ -41,3 +41,38 @@ export function formatShamsiDateTime(iso: string | Date, locale: string = 'fa'):
export function formatShamsiMonthYear(iso: string | Date, locale: string = 'fa'): string { export function formatShamsiMonthYear(iso: string | Date, locale: string = 'fa'): string {
return formatShamsiDate(iso, locale, { year: 'numeric', month: 'long' }); return formatShamsiDate(iso, locale, { year: 'numeric', month: 'long' });
} }
/** Time only (hh:mm) — a chat bubble's stamp once its date lives on a separator instead (§3.2). */
export function formatShamsiTime(iso: string | Date, locale: string = 'fa'): string {
return formatShamsiDate(iso, locale, { hour: '2-digit', minute: '2-digit' });
}
/**
* Whole calendar days between two timestamps, comparing the **formatted date string** (not raw ms
* subtraction, which is wrong across a DST/timezone boundary) two timestamps format identically iff
* they fall on the same calendar day in the active calendar system.
*/
function isSameCalendarDay(a: Date, b: Date, locale: string): boolean {
return formatShamsiDate(a, locale) === formatShamsiDate(b, locale);
}
/**
* A chat-style day-separator label: the caller's «امروز»/«دیروز» for the last two calendar days, else the
* plain Shamsi date (e.g. «۲۵ تیر ۱۴۰۵»). Calendar-agnostic it never does its own Jalali arithmetic,
* only compares already-formatted date strings (see `isSameCalendarDay`).
*/
export function formatDaySeparator(
iso: string | Date,
locale: string,
todayLabel: string,
yesterdayLabel: string,
): string {
const date = iso instanceof Date ? iso : new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
if (isSameCalendarDay(date, now, locale)) return todayLabel;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (isSameCalendarDay(date, yesterday, locale)) return yesterdayLabel;
return formatShamsiDate(date, locale);
}
+1
View File
@@ -11,4 +11,5 @@ export type LinkToPage = {
subtitle?: string; // Sub-title or secondary text to display subtitle?: string; // Sub-title or secondary text to display
group?: string; // Already-translated section label; consecutive items sharing a group render under one subheader group?: string; // Already-translated section label; consecutive items sharing a group render under one subheader
onSelect?: () => void; // When set, BottomBar runs this instead of navigating (e.g. a "more" tab opening a drawer) onSelect?: () => void; // When set, BottomBar runs this instead of navigating (e.g. a "more" tab opening a drawer)
badgeCount?: number; // Renders a small unread-count badge on the item's icon when > 0 (e.g. the support entry)
}; };
@@ -934,3 +934,47 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Proposed shape:** `PatientDto { …, avatarUrl: string | null }` + `POST patients/{id}/avatar` (multipart), - **Proposed shape:** `PatientDto { …, avatarUrl: string | null }` + `POST patients/{id}/avatar` (multipart),
same seam shape as the nurse profile's `uploadAvatar`. same seam shape as the nurse profile's `uploadAvatar`.
- **Status:** deferred, non-blocking — no UI currently reads a patient `avatarUrl`; initials-only ships either way. - **Status:** deferred, non-blocking — no UI currently reads a patient `avatarUrl`; initials-only ships either way.
## REQ-059 — Ticket inbox enrichment: last-message preview + author role + an unread-total read — filed by ui-phase-10 — 2026-07-19
- **Need:** Extends REQ-028 (delivered: `unreadCount`/`lastMessageAt` are now real on `TicketSummaryDto`).
Three more additions:
1. `lastMessagePreview: string | null` on `TicketSummaryDto` — the first ~80 chars of the last
**non-internal** message (server-truncated so an internal admin note can never leak into a preview
snippet, same boundary REQ-028/the contract already enforces for the thread).
2. `lastAuthorRole: 'customer' | 'nurse' | 'admin' | 'system' | null` on `TicketSummaryDto` — the last
visible message's author role, so the inbox card can label the preview ("پرستار: …" vs "شما: …").
3. A cheap unread-**total** read for the caller (e.g. `GET tickets/unread_count`, mirroring
`notifications/unread_count`) — a single number summed server-side across the caller's tickets.
- **Why:** The inbox redesign (phase §3.1) turns the ticket list into a real messaging-app inbox: a bold
subject + one-line last-message snippet + unread pill + relative last-activity time, and a small unread
badge on the chrome's support entry (TopBar icon on the customer shell, the sidebar item on the nurse
shell). `unreadCount`/`lastMessageAt` already ship (REQ-028); the preview/author-role fields don't exist
anywhere in the wire summary, and there is no chrome-badge-sized aggregate read at all today (fetching the
whole first page of tickets just to sum `unreadCount` client-side doesn't scale and isn't the badge's job).
The client's `TicketSummary` type carries `lastMessagePreview`/`lastAuthorRole` as optional, mock-tolerant
fields (`services/tickets/types.ts`); the mock computes them from its own store
(`services/tickets/apis/mockApi.ts`); the real `ticketsClientApi` maps them to `null` and the card
degrades to subject + status chip + time — never an empty slot, never a fake "0 unread". The chrome badge
is behind a new `TicketsApi.getUnreadTotal()` seam method — the mock sums its tickets' `unreadCount`, the
real implementation returns `null` (no signal) until this lands, and the badge simply doesn't render.
- **Proposed shape:** `TicketSummaryDto { …, lastMessagePreview: string | null, lastAuthorRole: string | null
}`; `GET tickets/unread_count → { unreadCount: number }` (or fold the total into the existing list envelope
as a `totalUnreadCount` sibling of `items`/`total`, whichever fits the pagination envelope better).
- **Status:** open — mock-only; the real path shows subject + status + Shamsi/relative time with no preview
line, and the support-entry chrome badge renders nothing until a signal exists.
## REQ-060 — Ticket message photo attachments — filed by ui-phase-10 — 2026-07-19
- **Need:** Upload + serve a photo attachment on a ticket message, using the existing object-storage seam
(`IObjectStorage`, already wired for verification documents — REQ-006's avatar upload is the closest
precedent). A message-attachment linkage (one or more attachments per message), server-side size/type
validation (images only, a sane size ceiling), and a signed-URL-style read for displaying them in a thread.
- **Why:** Refund and coordination tickets routinely need photo evidence (a receipt, a care-situation photo)
and today the only channel (tickets, by product design — no free chat) has no way to attach one. The
composer's attachment button + pending-upload chip are **designed but gated** behind
`TICKETS_ATTACHMENTS_ENABLED` (`services/tickets/constants.ts`, default `false`) — no dead button ships in
production; flipping the flag once this lands is the only client change needed.
- **Proposed shape:** `POST tickets/{id}/messages` gains an optional `attachmentIds: string[]` (uploaded
beforehand via a new `POST tickets/{id}/attachments`, multipart, returning an id + a short-lived signed
URL), and `TicketMessageDto` gains `attachments: { id: string, url: string, contentType: string }[]`.
- **Status:** deferred — the affordance is designed and gated off; nothing renders until this lands and the
capability flag flips.
@@ -130,7 +130,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) | | `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 | | `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) | | f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) |
| `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false` `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟢 (real, refinement-phase-4) | | `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1; **REQ-028 is now delivered** (`unreadCount`/`lastMessageAt` + `clientMessageId` idempotency are real on the wire), so `USE_TICKETS_MOCK = false` by default — the mock stays available for offline/demo use. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`**; `postMessage` appends as the current viewer, throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path — **ui-phase-10:** a failed send now flips the bubble to `sendStatus:'failed'` in place instead of rolling it back, and a retry re-mutates the same `clientMessageId`). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types**. **ui-phase-10 additions:** `toSummary()` now also computes `lastMessagePreview` (first ~80 chars of the last non-internal message) + `lastAuthorRole` (REQ-059 gap — real path maps both `null`, card degrades gracefully); a new `getUnreadTotal()` sums `unread` across every seeded ticket for the chrome support-badge (REQ-059 — real path returns `null`, badge doesn't render) | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default **false** — REQ-028 delivered) | Deliver **REQ-059** (`lastMessagePreview`/`lastAuthorRole` on the summary + a cheap unread-total read) to fully retire the mock's enrichment role; **REQ-060** (message photo attachments) gates the composer's designed-but-off attachment affordance (`TICKETS_ATTACHMENTS_ENABLED`, default `false`). `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively) | 🟢 real by default (REQ-028); 🟡 REQ-059/060 gaps remain mock-only |
| `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) | | `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 | | `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 |
| `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 | | `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 |
@@ -0,0 +1,222 @@
# UI Phase 10 — Messaging & notifications — Report (2026-07-19)
## What was built
### Ticket inbox → a real inbox (`TicketInboxScreen`, `TicketListCard`)
- **Status filter chips** (همه/باز/بسته) + **load-more paging**: `useMyTickets({ status, pageSize, page: 1 })`
with a growing `pageSize` (the same "growing limit" pattern `NotificationCenter` already used), reset to
`TICKETS_PAGE_SIZE` on a filter change. `keepPreviousData` (already set on the hook) means a chip switch
never flashes.
- **`TicketListCard` redesigned around activity:** bold subject + unread pill (unchanged behavior — it turns
out `unreadCount`/`lastMessageAt` are **already real** on the wire, REQ-028 having been delivered since the
audit was written — see "Stale audit note" below) **plus** a one-line last-message preview prefixed with
the translated author label (e.g. "پرستار: ساعت ۵ عصر هماهنگ شد") and a **relative** last-activity time
(`formatRelativeTime`, decaying to Shamsi past 7 days) instead of the old absolute
`formatShamsiDateTime`. The preview/author-role fields don't exist on the wire summary yet — filed as
**REQ-059** — so `TicketSummary.lastMessagePreview`/`lastAuthorRole` are optional and the card renders
subject + status + time only when they're absent (no empty slot, never a fake value).
- **Support-entry unread badge in the chrome:** a new `useSupportUnreadTotal()` read behind the `services/tickets`
seam (mock sums its tickets' `unreadCount`; real returns `null` — REQ-059). Mounted as a `Badge` on the
customer TopBar's support icon (only on the 5 root tabs) and on the nurse sidebar's support item (a new
`LinkToPage.badgeCount` prop, rendered by `SideBarNavItem` — a minimal, intentional touch to the
phase-2-owned `layout/` files, noted per the ownership rules).
- **Emergency affordance right-sized:** the permanent alarm-red `EmergencyBanner` is gone from both ticket
inboxes, replaced by a new **`EmergencyPlaybookRow`** — a compact, neutral, collapsed-by-default row that
expands to rewritten copy (never instructs calling a number the inbox can't show) + the "open a ticket"
action. `EmergencyBanner` itself is untouched and stays exactly where it belongs: the nurse's
post-confirmation booking detail (`BookingSupportEntry`), the only surface with a real `tel:` contact.
- Unified the messaging surface width (inbox now 720px, matching the thread — was 640/720 mismatched).
### Live thread (`useTicket`/`useTicketThread`, `useThreadScroll`, `TicketMessageList`)
- **Polls while mounted:** `useTicket`/`useTicketThread` both got `refetchInterval: TICKET_THREAD_REFETCH_INTERVAL`
(15s, new constant) — TanStack Query scopes this to active observers, so it's automatically off once the
thread unmounts. No `refetchIntervalInBackground`. The 60s notification-count poll stays the only
always-on poll (§5 posture unchanged).
- **`useThreadScroll`** — a new, reusable scroll-orchestration hook (`components/messaging/useThreadScroll.ts`,
exported from the barrel): opens the thread scrolled to the **newest** message (instant, on first load),
always scrolls to a newly-sent (mine) message, and auto-scrolls on a received message only when the
viewer is already within ~120px of the bottom — otherwise shows a floating «پیام جدید ↓» pill. Uses an
`IntersectionObserver` on a bottom sentinel rather than tracking a specific scroll container, so it works
whether the *page* or an inner box scrolls (guarded for environments without `IntersectionObserver`, e.g.
jsdom). Deliberately exported as a consumable for **phase 11's admin thread**, which has the inverse bug
(a 520px scrollbox that also opens at the top).
- **Chat typography** (`TicketMessageList`): messages are grouped into date-separator / system-event /
consecutive-same-author blocks (`buildBlocks`); centered Shamsi separators read امروز/دیروز/an absolute
date (`formatDaySeparator`, new in `utils/date.ts`, string-equality comparison — never raw ms diffs, so
it's calendar-exact); consecutive messages from the same author collapse under one author label; bubbles
show **hh:mm only** (`formatShamsiTime`, new in `utils/date.ts`); `authorRole === 'system'` renders as a
centered neutral chip, never a bubble.
- **Fixed the bidi timestamp bug:** removed the forced `direction: 'ltr'` from the bubble's time label
(`MessageBubble.tsx`) — with hh:mm-only Persian-digit stamps, no forced direction is needed; the Latin
`referenceCode` keeps its forced LTR everywhere it's shown.
- **Sticky composer separation:** the composer's sticky box now has a real `borderTop` (divider token)
instead of a bare `bgcolor` block, so bubbles no longer scroll flush into the input.
### Composer (`MessageComposer`, `TicketConversationPanel`, `usePostMessage`, `useDiscardFailedMessage`)
- **Enter semantics per input modality:** `useMediaQuery('(pointer: coarse)')` branches the composer's
`onKeyDown` — desktop (fine pointer): Enter sends, Shift+Enter newlines (unchanged); touch (coarse
pointer): Enter always inserts a newline, the send button is the only send path.
- **Retry-in-place on failure** — the invariant-preserving rewrite:
- `usePostMessage`'s `onMutate` is now **idempotent on `clientMessageId`**: a fresh send appends a pending
bubble; a retry (the same id already in the cache as `sendStatus: 'failed'`) flips it back to `sending`
in place instead of appending a duplicate.
- `onError` **no longer rolls the thread back** — it flips the bubble to `sendStatus: 'failed'` and leaves
it in place with its typed body.
- `MessageBubble`'s failed state renders `role="alert"` (screen readers are told), the error-accented
bubble, a «تلاش مجدد» action (re-mutates the same `clientMessageId`), and a discard (delete) action.
- A new `useDiscardFailedMessage()` (not a mutation — nothing was ever sent) removes the failed message
from the cached thread; `TicketConversationPanel` wires its `onDiscard` to also restore the message's
body into the composer's draft — **a failure never loses typed text**, and `clientMessageId`
reconciliation still never double-renders. Both invariants are proven by the updated
`MessageBubble.test.tsx`.
- **`TicketConversationPanel`** (new) is the component that makes this possible without leaking state
across tickets: it owns the one `usePostMessage`/draft instance both `TicketMessageList`'s retry/discard
and the composer's own send now share, and it's mounted `key={ticketId}` from `TicketThreadScreen` — so
navigating thread→thread (the App Router reuses the `[id]` subtree) remounts the whole panel, exactly
preserving the pre-existing "drafts/in-flight state never cross tickets" invariant (previously enforced
by keying only `MessageComposer`).
- **Attachment affordance — designed, gated:** the composer has an attachment icon button + the capability
flag `TICKETS_ATTACHMENTS_ENABLED` (`services/tickets/constants.ts`, default `false`) — it renders nothing
until the seam lands (REQ-060). No dead button ships.
- **RTL send-icon mirror:** `send` added to `AppIcon`'s `DIRECTIONAL_ICONS` set (config.ts) — phase 0's
auto-mirroring mechanism already existed, this was a one-line registry addition.
### Emergency affordance (recap)
- Full alarm-red `EmergencyBanner` + `tel:` stays **only** on the nurse's post-confirmation booking read
(untouched). Both ticket inboxes now show the compact `EmergencyPlaybookRow` instead.
### Notification center (`NotificationCenter`, `NotificationRow`, `notificationIcon.ts`)
- **Day grouping:** امروز/دیروز/این‌هفته, then a Shamsi date header for anything older — grouped **in list
order** (the server's unread-first-then-newest ordering is preserved per §5's "keep the mark-read UX as
is"; a bucket can recur if an older unread item sits above newer read ones — a deliberate, documented
trade-off rather than silently reordering the list).
- **Relative timestamps** (`formatRelativeTime`, decaying to Shamsi) replace the absolute
`formatShamsiDateTime` on every row.
- **Per-kind tinted icon container:** a new `notificationTint(kind)` helper (`notificationIcon.ts`) — booking
teal, payout success-green, ticket/support terracotta (the deliberate "a human from Balinyaar" accent),
refund info, nurse-trust the dedicated `--bal-trust` token, `none` fully neutral. All existing `--bal-*-soft`
tokens — no new tokens needed.
- **Non-navigable rows render non-interactive:** `NotificationRow` now takes a `navigable` prop (the caller
computes it from `notificationDeepLink(...) != null`). Navigable rows are a real `ButtonBase` with a
trailing chevron (`forward` icon) and a visible `:focus-visible` ring; non-navigable rows render as a
plain, static surface (no ripple, no pointer cursor, no chevron) that still marks the notification read on
click/Enter/Space (kept keyboard-operable via `role="button" tabIndex={0}`, even though visually inert).
- **Kept unchanged:** mark-read-on-open (optimistic), mark-all-read, load-more.
### Bell behavior (`NotificationBell`, `NotificationBellPopover`, `NotificationBellView`)
- **Desktop popover on the nurse shell:** `NotificationBell` now branches on `role === 'nurse' && isDesktop`
(`useMediaQuery(theme.breakpoints.up('md'))`) — opens a new `NotificationBellPopover` (5 most recent,
mark-all-read, «مشاهده همه» to the full center) instead of navigating. The popover fetches **on open**
(`useNotifications(5, { enabled: open })``useNotifications` gained the optional `{enabled}` param),
reusing the same `notificationKeys` cache the full center reads, never on the polled count's tick.
Everywhere else (customer — always, mobile-first by design; nurse mobile) keeps direct navigation.
- **Isolation preserved:** only `NotificationBell` (the container) calls `useUnreadCount()`; the popover's
content and the shell around it never do. `NotificationBellView` now `forwardRef`s to its `IconButton` so
the container can anchor the popover to it (via `anchorEl` state set through the ref callback — not a
read-during-render, which the `react-hooks/refs` lint rule correctly flagged and blocked).
- Badge pulse-on-increment (the phase's "optional polish") was **not** built this pass — noted under
Follow-ups.
### Admin notifications — the phase-11 handshake (§3.7)
- `admin/notifications/page.tsx` stays a `PlaceholderScreen` (untouched — building it is phase 11's call),
but its **only entry point was the TopBar bell** (`AdminLayout` had no separate "notifications" nav item —
the bell click was the dead link). Removed `<NotificationBell role="admin" />` from `AdminLayout` entirely
— a documented, intentional removal (comment in `AdminLayout.tsx`), not a silent regression. Re-add it
(reusing `NotificationBellPopover`, already exported from the barrel) once phase 11 ships a real admin
feed.
- `useThreadScroll` and `NotificationBellPopover` are both exported from their barrels specifically for
phase 11 to consume for the admin thread's inverse scroll bug and (eventually) an admin bell popover.
## Stale-audit note (recorded for the record, not re-litigated)
The phase doc's problem list (drawn from `audit/messaging-notifications.md`) describes `unreadCount`/
`lastMessageAt` as "dead on the real API, REQ-028 gap, mock-only." That was true when the audit was
written, but by the time this phase ran, **REQ-028 had already been delivered**`USE_TICKETS_MOCK` and
`USE_NOTIFICATIONS_MOCK` are both `false` in this codebase, and `ticketsClientApi.mapSummary` genuinely maps
`unreadCount`/`lastMessageAt` off the wire (confirmed by reading the current code, not the audit). This
phase's actual REQ-039/040-equivalent gaps turned out to be narrower than described — `lastMessagePreview`/
`lastAuthorRole`/the unread-total badge (filed as **REQ-059**, since REQ-039/040 were already taken by
ui-phase-3/4 by the time this phase ran — always check the tracker's live highest number, not a phase doc's
indicative one) and the attachment affordance (**REQ-060**). This mirrors the same "audit predates a
same-day fix" pattern the ui-phase-9 stale-audit memory already flagged — worth a standing reminder for
future phases to verify audit claims against current code before treating them as gaps to close.
## What is now testable (and exactly how)
1. `/fa` customer → پشتیبانی (`/support/tickets`): status chips filter the list without a flash; if there
are >20 tickets in a scenario, «نمایش بیشتر» loads more; the compact «موارد اضطراری» row is collapsed by
default and expands on tap; each card shows a bold subject, unread pill (mock scenario), one-line
preview + relative time.
2. Open a thread with a booking-linked coordination ticket → opens scrolled to the newest message,
date-separated and author-grouped, `hh:mm` stamps, a centered system event line if present. Scroll up,
wait ~15s for the poll (or use the mock's reply path) → a reply appears and, if scrolled up, shows
«پیام جدید ↓»; tap it → scrolls to newest.
3. Send a message → bubble appears instantly with "در حال ارسال…"; on confirm it shows `hh:mm`. Post the
dev sentinel `/fail` as the body → the bubble turns error-accented with «تلاش مجدد» + a delete icon,
`role="alert"` (verify with a screen reader or the accessibility tree); tap «تلاش مجدد» → succeeds (a
real send) with **exactly one** bubble, never a duplicate. Try again and tap the delete icon instead →
the bubble disappears and its text reappears in the composer.
4. On a touch-emulated viewport (devtools device toolbar), Enter in the composer inserts a newline; on a
desktop viewport, Enter sends. `/fa`: the send arrow points out of the field (toward the inline-end).
5. `/nurse/support/tickets`: the sidebar's «پشتیبانی» item shows a small unread badge when the mock's
`getUnreadTotal()` is positive.
6. Notification center (`/fa` + `/en`, light + dark): امروز/دیروز/این‌هفته day groups, «۵ دقیقه پیش» decaying
to a Shamsi date past 7 days, tinted per-kind icon circles (teal/success/terracotta/info/trust); a
non-deep-linking row (seeded via the mock's unknown-type row) doesn't ripple and has no chevron, but a
click still marks it read; navigable rows show a trailing chevron and a visible focus ring on Tab.
7. Nurse desktop (`≥md` viewport): the bell opens a popover (5 recent + mark-all + «مشاهده همه»); resize to
mobile → the same bell now navigates to the full center. Customer shell: the bell always navigates,
any viewport. Devtools Network tab: only the unread-count endpoint polls (60s) plus the thread endpoint
while a thread screen is mounted (15s) — nothing else ticks.
8. `/admin`: no notification bell in the TopBar at all (removed, not a dead link).
## What is mocked / waiting on a real service
- `TicketsApi` (`services/tickets/apis/mockApi.ts`) — mock stays available for the two REQ-059 fields
(`lastMessagePreview`/`lastAuthorRole`) + `getUnreadTotal()`; the real client maps the first two to `null`
and the third to a constant `null`. See `mocks-registry.md`'s updated `TicketsApi` row.
- The composer's attachment affordance is fully designed but inert (`TICKETS_ATTACHMENTS_ENABLED = false`)
until REQ-060 ships an object-storage-backed attachment endpoint.
## Contracts
- No contract consumed this phase (no new backend endpoint existed to consume — REQ-028 was already
delivered before this phase started).
- Requests filed in `dev/shared-working-context/frontend/requests/for-backend.md`:
- **REQ-059** — Ticket inbox enrichment: `lastMessagePreview` + `lastAuthorRole` on `TicketSummaryDto`
(extends REQ-028) + a cheap unread-total read for the chrome badge.
- **REQ-060** — Ticket message photo attachments (object-storage-backed), gating the composer's
already-built affordance.
## Docs updated
- `client/CLAUDE.md` — the `messaging/` and `notifications/` Project Structure entries rewritten for the
new components/hooks (`TicketConversationPanel`, `useThreadScroll`, `EmergencyPlaybookRow`,
`NotificationBellPopover`, `notificationTint`); the `services/tickets`/`services/notifications` domain
bullets updated (retry-in-place semantics, thread polling, `useSupportUnreadTotal`,
`useDiscardFailedMessage`, REQ-028-delivered vs REQ-059-gap distinction); the `'tickets'`/`'notifications'`
i18n namespace descriptions updated with the new keys; `CustomerLayout.tsx`/`NurseLayout.tsx`/
`AdminLayout.tsx`/`SideBarNavItem.tsx` structure-tree lines updated for the badge + admin-bell-removal
changes.
- `dev/shared-working-context/reports/mocks-registry.md` — the `TicketsApi` row corrected (it was stale:
marked "deliver REQ-028, then set flag false" when the flag was already `false`) and updated with the
REQ-059/060 gaps.
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-059/060 appended (see Contracts).
## Foundation extensions (minimal, per the ownership rules)
- **`layout/` (phase 2-owned):** `LinkToPage` gained an optional `badgeCount` field; `SideBarNavItem`/
`SideBarNavList` render it as a small `Badge`; `CustomerLayout`/`NurseLayout` wire it to
`useSupportUnreadTotal()`; `AdminLayout` lost its notification bell (§3.7 handshake, documented above).
- **`utils/date.ts`:** added `formatShamsiTime` (hh:mm-only) and `formatDaySeparator`
(امروز/دیروز/absolute-date, calendar-exact via formatted-string comparison, not ms diffs).
- **`AppIcon/config.ts`:** `send` added to `DIRECTIONAL_ICONS` (one-line registry addition, phase 0's
existing mechanism).
- **`services/notifications`:** `useNotifications` gained an optional `{ enabled }` param (for the popover's
fetch-on-open); no breaking change to existing callers.
## Follow-ups for later phases
- **Phase 11 (admin console):** build the real admin notification feed (or keep it hidden) — the bell +
popover pattern (`NotificationBellPopover`) and the scroll hook (`useThreadScroll`) are ready to reuse for
the admin thread's inverse scrollbox bug.
- **REQ-059**/**REQ-060** as filed.
- **Badge pulse-on-increment** (§3.6's "optional polish") and a document-title/favicon unread hint were not
built this pass — low-risk, low-priority visual polish, safe to pick up whenever.
- **Ticket lifecycle + trust affordances** (user-side close/reopen, an expected-response-time promise, a
"support has seen this" state) — called out as an opportunity in the audit but out of this phase's
explicit scope (§3 didn't ask for it); worth a future phase if support-response-time becomes a measured
product metric.