ui phase 10
This commit is contained in:
+10
-10
@@ -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)
|
||||
│ ├── 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)
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
│ ├── 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), 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)
|
||||
├── i18n/
|
||||
│ ├── 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
|
||||
├── 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
|
||||
│ ├── 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
|
||||
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer)
|
||||
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role) + bell
|
||||
│ ├── 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); 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); 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)
|
||||
│ ├── 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
|
||||
@@ -312,7 +312,7 @@ client/
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
│ ├── 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()
|
||||
@@ -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=gross−clawback; Σ 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
|
||||
│ ├── 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
|
||||
│ ├── 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
|
||||
│ ├── 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, 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
|
||||
│ ├── 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):
|
||||
@@ -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`
|
||||
- `'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`
|
||||
- `'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`
|
||||
- `'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`
|
||||
- `'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`, 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`
|
||||
- `'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
|
||||
|
||||
+14
-1
@@ -1401,6 +1401,15 @@
|
||||
"empty_body": "Open a ticket to coordinate or get support.",
|
||||
"error_body": "Couldn't load your tickets.",
|
||||
"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_coordination": "Visit coordination",
|
||||
"category_refund": "Refund",
|
||||
@@ -1450,7 +1459,11 @@
|
||||
"retry": "Retry",
|
||||
"mark_all_read": "Mark all read",
|
||||
"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": {
|
||||
"overview_title": "Backoffice",
|
||||
|
||||
+14
-1
@@ -1401,6 +1401,15 @@
|
||||
"empty_body": "برای هماهنگی یا پشتیبانی، یک تیکت جدید باز کنید.",
|
||||
"error_body": "بارگذاری تیکتها ممکن نشد.",
|
||||
"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_coordination": "هماهنگی ویزیت",
|
||||
"category_refund": "بازپرداخت",
|
||||
@@ -1450,7 +1459,11 @@
|
||||
"retry": "تلاش مجدد",
|
||||
"mark_all_read": "علامتگذاری همه بهعنوان خواندهشده",
|
||||
"load_more": "نمایش بیشتر",
|
||||
"bell_aria": "{count, number} اعلان خواندهنشده"
|
||||
"bell_aria": "{count, number} اعلان خواندهنشده",
|
||||
"group_today": "امروز",
|
||||
"group_yesterday": "دیروز",
|
||||
"group_this_week": "این هفته",
|
||||
"view_all": "مشاهده همه"
|
||||
},
|
||||
"admin": {
|
||||
"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`
|
||||
* 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 MessageBubble from './MessageBubble';
|
||||
import type { TicketMessage } from '@/services/tickets/types';
|
||||
@@ -13,14 +13,20 @@ const base: TicketMessage = {
|
||||
sendStatus: 'sent',
|
||||
};
|
||||
|
||||
function renderBubble(overrides: Partial<TicketMessage>) {
|
||||
function renderBubble(overrides: Partial<TicketMessage>, extra: { showAuthorLabel?: boolean; onRetry?: () => void; onDiscard?: () => void } = {}) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<MessageBubble
|
||||
message={{ ...base, ...overrides }}
|
||||
authorLabel="پشتیبانی"
|
||||
showAuthorLabel={extra.showAuthorLabel ?? true}
|
||||
timeLabel="۱۰:۰۰"
|
||||
sendingLabel="در حال ارسال…"
|
||||
failedLabel="پیام ارسال نشد"
|
||||
retryLabel="تلاش مجدد"
|
||||
discardLabel="حذف"
|
||||
onRetry={extra.onRetry}
|
||||
onDiscard={extra.onDiscard}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
@@ -35,6 +41,11 @@ describe('<MessageBubble/> component', () => {
|
||||
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', () => {
|
||||
renderBubble({ isMine: true });
|
||||
expect(screen.getByTestId('message-bubble')).toHaveAttribute('data-mine', 'true');
|
||||
@@ -46,4 +57,20 @@ describe('<MessageBubble/> component', () => {
|
||||
expect(screen.getByText('در حال ارسال…')).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';
|
||||
import { FunctionComponent } from 'react';
|
||||
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 Typography from '@mui/material/Typography';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import type { TicketMessage } from '@/services/tickets/types';
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
message: TicketMessage;
|
||||
/** Translated author label (only shown for others' messages — mine is obvious). */
|
||||
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;
|
||||
/** "در حال ارسال…" — shown while an optimistic message is still sending (in place of the time). */
|
||||
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**
|
||||
* 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
|
||||
* by `usePostMessage` and the draft is retried from the composer (§3.5), so this bubble only ever renders the
|
||||
* sending/sent states. Purely presentational — the caller supplies the translated author/time strings. Never
|
||||
* renders any internal-note content or styling (there is no internal message in the user view — §5).
|
||||
* send shows the "sending" state in place of the timestamp. On **failure** the bubble stays in place
|
||||
* (`sendStatus: 'failed'`, error-token accented, `role="alert"` so screen readers are told) with a retry
|
||||
* chip (re-mutates the same `clientMessageId` — never a duplicate) and a discard action that restores the
|
||||
* 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
|
||||
*/
|
||||
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 sending = message.sendStatus === 'sending';
|
||||
const failed = message.sendStatus === 'failed';
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid="message-bubble"
|
||||
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%' }}
|
||||
>
|
||||
<Stack
|
||||
@@ -40,17 +69,17 @@ const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorL
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: isMine ? 'var(--bal-primary)' : 'var(--bal-bg-paper)',
|
||||
color: isMine ? 'var(--bal-primary-contrast)' : 'var(--bal-text-primary)',
|
||||
border: isMine ? 'none' : '1px solid',
|
||||
borderColor: isMine ? undefined : 'divider',
|
||||
bgcolor: failed ? 'var(--bal-error-soft)' : isMine ? 'var(--bal-primary)' : 'var(--bal-bg-paper)',
|
||||
color: failed ? 'var(--bal-text-primary)' : isMine ? 'var(--bal-primary-contrast)' : 'var(--bal-text-primary)',
|
||||
border: failed ? '1px solid' : isMine ? 'none' : '1px solid',
|
||||
borderColor: failed ? 'var(--bal-error)' : isMine ? undefined : 'divider',
|
||||
// Mine leans to the inline-end corner, theirs to the inline-start — a subtle "tail".
|
||||
borderStartEndRadius: isMine ? 4 : undefined,
|
||||
borderStartStartRadius: isMine ? undefined : 4,
|
||||
opacity: sending ? 0.75 : 1,
|
||||
}}
|
||||
>
|
||||
{!isMine ? (
|
||||
{!isMine && showAuthorLabel ? (
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-primary)', mb: 0.25 }}>
|
||||
{authorLabel}
|
||||
</Typography>
|
||||
@@ -60,18 +89,43 @@ const MessageBubble: FunctionComponent<MessageBubbleProps> = ({ message, authorL
|
||||
{message.body}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
alignSelf: 'flex-end',
|
||||
opacity: isMine ? 0.8 : 1,
|
||||
color: isMine ? 'inherit' : 'var(--bal-text-secondary)',
|
||||
direction: 'ltr',
|
||||
}}
|
||||
>
|
||||
{sending ? sendingLabel : timeLabel}
|
||||
</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
|
||||
variant="caption"
|
||||
sx={{
|
||||
mt: 0.25,
|
||||
display: 'block',
|
||||
textAlign: 'end',
|
||||
opacity: isMine ? 0.8 : 1,
|
||||
color: isMine ? 'inherit' : 'var(--bal-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{sending ? sendingLabel : timeLabel}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,92 +1,77 @@
|
||||
'use client';
|
||||
import { FunctionComponent, KeyboardEvent, useState } from 'react';
|
||||
import { FunctionComponent, KeyboardEvent } from 'react';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import { usePostMessage } from '@/services/tickets';
|
||||
import { AppIcon, AppIconButton } from '@/components/common';
|
||||
import { TICKETS_ATTACHMENTS_ENABLED } from '@/services/tickets/constants';
|
||||
|
||||
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. */
|
||||
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
|
||||
* draft is **kept until the server confirms** — cleared only in `onSuccess` — so a failure leaves the text in
|
||||
* place to retry without retyping (the pending bubble is rolled back by the mutation; §3.5). Submit is
|
||||
* disabled while sending; Enter sends, Shift+Enter newlines.
|
||||
* The sticky thread composer — a controlled input (the draft lives in `TicketConversationPanel`, which
|
||||
* owns the send/retry/discard mutation so a failed bubble and the composer share one source of truth).
|
||||
* **Enter semantics are input-modality aware** (§3.3): on a fine pointer (desktop) Enter sends and
|
||||
* 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
|
||||
*/
|
||||
const MessageComposer: FunctionComponent<MessageComposerProps> = ({ ticketId, disabled }) => {
|
||||
const MessageComposer: FunctionComponent<MessageComposerProps> = ({ value, onChange, onSubmit, sending, disabled }) => {
|
||||
const t = useTranslations('tickets');
|
||||
const [draft, setDraft] = useState('');
|
||||
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 isCoarsePointer = useMediaQuery('(pointer: coarse)');
|
||||
|
||||
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) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-end' }}>
|
||||
{TICKETS_ATTACHMENTS_ENABLED ? (
|
||||
<AppIconButton icon="attachment" title={t('attach_photo')} disabled={disabled || sending} />
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-end' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={4}
|
||||
size="small"
|
||||
placeholder={t('composer_placeholder')}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={submit}
|
||||
disabled={disabled || sending || draft.trim().length === 0}
|
||||
aria-label={t('send')}
|
||||
sx={{
|
||||
bgcolor: 'var(--bal-primary)',
|
||||
color: 'var(--bal-primary-contrast)',
|
||||
'&:hover': { bgcolor: 'var(--bal-primary-dark)' },
|
||||
'&.Mui-disabled': { bgcolor: 'var(--bal-divider)', color: 'var(--bal-text-secondary)' },
|
||||
}}
|
||||
>
|
||||
{sending ? (
|
||||
<CircularProgress size={18} sx={{ color: 'var(--bal-primary-contrast)' }} />
|
||||
) : (
|
||||
<AppIcon icon="send" size={18} color="var(--bal-primary-contrast)" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={4}
|
||||
size="small"
|
||||
placeholder={t('composer_placeholder')}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={onSubmit}
|
||||
disabled={disabled || sending || value.trim().length === 0}
|
||||
aria-label={t('send')}
|
||||
sx={{
|
||||
bgcolor: 'var(--bal-primary)',
|
||||
color: 'var(--bal-primary-contrast)',
|
||||
'&:hover': { bgcolor: 'var(--bal-primary-dark)' },
|
||||
'&.Mui-disabled': { bgcolor: 'var(--bal-divider)', color: 'var(--bal-text-secondary)' },
|
||||
}}
|
||||
>
|
||||
{sending ? (
|
||||
<CircularProgress size={18} sx={{ color: 'var(--bal-primary-contrast)' }} />
|
||||
) : (
|
||||
<AppIcon icon="send" size={18} color="var(--bal-primary-contrast)" />
|
||||
)}
|
||||
</IconButton>
|
||||
</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 thread→thread (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';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -9,21 +10,29 @@ import AppButton from '@/components/common/AppButton';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import { ticketThreadPath } from '@/constants';
|
||||
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 EmergencyBanner from './EmergencyBanner';
|
||||
import EmergencyPlaybookRow from './EmergencyPlaybookRow';
|
||||
import TicketListCard from './TicketListCard';
|
||||
|
||||
export interface TicketInboxScreenProps {
|
||||
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
|
||||
* new ticket (and shows its `referenceCode`), and the paginated ticket list. Cards show the **`referenceCode`
|
||||
* prominently**, the status chip, an unread indicator, and a null-safe linked-booking/refund hint.
|
||||
* Empty / loading-skeleton / error→retry states. Shared by the customer and nurse inbox pages (role decides
|
||||
* the thread route + the ticket shell, not the components).
|
||||
* The "My Tickets" inbox — a status filter chip row, the compact emergency playbook row, a "Contact
|
||||
* support" CTA that opens a new ticket (and shows its `referenceCode`), and the paginated ticket list.
|
||||
* Cards show the **`referenceCode`** prominently, the status chip, an unread indicator + last-message
|
||||
* preview + relative last-activity time (mock-tolerant: they degrade gracefully when the real API hasn't
|
||||
* shipped the enrichment fields yet, REQ-059), and a null-safe linked-booking/refund hint. Empty /
|
||||
* loading-skeleton / error→retry states. Shared by the customer and nurse inbox pages (role decides the
|
||||
* thread route + the ticket shell, not the components).
|
||||
* @component TicketInboxScreen
|
||||
*/
|
||||
const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role }) => {
|
||||
@@ -31,14 +40,22 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
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 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)}`);
|
||||
|
||||
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 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800 }}>
|
||||
{t('title')}
|
||||
@@ -53,7 +70,19 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
|
||||
</AppButton>
|
||||
</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 ? (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
@@ -89,12 +118,30 @@ const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role })
|
||||
ticket={ticket}
|
||||
categoryLabel={t(`category_${ticket.category}`)}
|
||||
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}
|
||||
linkedRefundLabel={ticket.refundId != null ? t('linked_refund', { id: ticket.refundId }) : null}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -66,4 +66,27 @@ describe('<TicketListCard/> component', () => {
|
||||
fireEvent.click(screen.getByTestId('ticket-card'));
|
||||
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;
|
||||
/** Translated status label. */
|
||||
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;
|
||||
/**
|
||||
* 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). */
|
||||
linkedBookingLabel?: string | null;
|
||||
/** 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
|
||||
* 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
|
||||
* ticket has unread activity. Purely presentational — the caller supplies translated labels + the formatted
|
||||
* time and handles navigation via `onOpen`.
|
||||
* only when present (null-safe), and an **unread indicator** (a count pill + bolded subject) shows when the
|
||||
* ticket has unread activity. `previewLabel` renders a one-line last-message snippet when the enrichment
|
||||
* 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
|
||||
*/
|
||||
const TicketListCard: FunctionComponent<TicketListCardProps> = ({
|
||||
@@ -38,6 +47,7 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
|
||||
categoryLabel,
|
||||
statusLabel,
|
||||
timeLabel,
|
||||
previewLabel,
|
||||
linkedBookingLabel,
|
||||
linkedRefundLabel,
|
||||
onOpen,
|
||||
@@ -58,6 +68,7 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'var(--bal-bg-paper)',
|
||||
'&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.75, width: '100%' }}>
|
||||
@@ -96,6 +107,21 @@ const TicketListCard: FunctionComponent<TicketListCardProps> = ({
|
||||
{ticket.referenceCode}
|
||||
</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' }}>
|
||||
{linkedBookingLabel ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
|
||||
@@ -1,30 +1,86 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import { useTicketThread } from '@/services/tickets';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import type { TicketAuthorRole, TicketMessage } from '@/services/tickets/types';
|
||||
import { formatDaySeparator, formatShamsiDate, formatShamsiTime } from '@/utils';
|
||||
import { authorLabelKey } from './authorLabel';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import { useThreadScroll } from './useThreadScroll';
|
||||
|
||||
export interface TicketMessageListProps {
|
||||
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
|
||||
* new (optimistic) message without re-rendering the thread header. Empty ("no messages yet — start
|
||||
* coordinating"), skeleton, and populated states. Each bubble's author label + Shamsi time are resolved here;
|
||||
* the bubbles never render an internal note (there are none in the user view — §5).
|
||||
* new (optimistic) message without re-rendering the thread header. Reads as a **timeline**: centered Shamsi
|
||||
* date separators (امروز/دیروز/older date), consecutive same-author messages grouped under one author label,
|
||||
* `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
|
||||
*/
|
||||
const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId }) => {
|
||||
const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId, onRetry, onDiscard }) => {
|
||||
const t = useTranslations('tickets');
|
||||
const locale = useLocale();
|
||||
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) {
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
@@ -48,18 +104,79 @@ const TicketMessageList: FunctionComponent<TicketMessageListProps> = ({ ticketId
|
||||
);
|
||||
}
|
||||
|
||||
const blocks = buildBlocks(messages, locale, t('day_today'), t('day_yesterday'));
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{messages.map((message) => (
|
||||
<MessageBubble
|
||||
key={message.clientMessageId ?? String(message.id)}
|
||||
message={message}
|
||||
authorLabel={t(authorLabelKey(message.authorRole))}
|
||||
timeLabel={formatShamsiDateTime(message.createdAt, locale)}
|
||||
sendingLabel={t('sending')}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<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
|
||||
key={message.clientMessageId ?? String(message.id)}
|
||||
message={message}
|
||||
authorLabel={t(authorLabelKey(message.authorRole))}
|
||||
showAuthorLabel={index === 0 && !message.isMine}
|
||||
timeLabel={formatShamsiTime(message.createdAt, locale)}
|
||||
sendingLabel={t('sending')}
|
||||
failedLabel={t('send_failed')}
|
||||
retryLabel={t('retry')}
|
||||
discardLabel={t('discard_failed')}
|
||||
onRetry={() => onRetry(message)}
|
||||
onDiscard={() => onDiscard(message)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
@@ -13,8 +12,7 @@ import { AppIcon } from '@/components/common';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import { ROUTES, nurseBookingDetailPath, ticketsBasePath } from '@/constants';
|
||||
import { useTicket } from '@/services/tickets';
|
||||
import MessageComposer from './MessageComposer';
|
||||
import TicketMessageList from './TicketMessageList';
|
||||
import TicketConversationPanel from './TicketConversationPanel';
|
||||
import { ticketCategoryIcon, ticketStatusKind } from './statusKind';
|
||||
|
||||
export interface TicketThreadScreenProps {
|
||||
@@ -95,30 +93,9 @@ const TicketThreadScreen: FunctionComponent<TicketThreadScreenProps> = ({ role,
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<TicketMessageList ticketId={ticketId} />
|
||||
|
||||
<Box
|
||||
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>
|
||||
{/* Key on ticketId so navigating thread→thread (the App Router reuses the [id] subtree)
|
||||
remounts the whole panel — its draft + in-flight/failed send state never cross tickets. */}
|
||||
<TicketConversationPanel key={ticketId} ticketId={ticketId} closed={ticket.status === 'closed'} />
|
||||
</>
|
||||
)}
|
||||
</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)}`;
|
||||
}
|
||||
@@ -12,8 +12,15 @@ export { default as TicketListCard } from './TicketListCard';
|
||||
export type { TicketListCardProps } from './TicketListCard';
|
||||
export { default as EmergencyBanner } 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 type { ContactSupportDialogProps } from './ContactSupportDialog';
|
||||
export { default as TicketInboxScreen } from './TicketInboxScreen';
|
||||
export { default as TicketThreadScreen } from './TicketThreadScreen';
|
||||
export { default as TicketConversationPanel } from './TicketConversationPanel';
|
||||
export type { TicketConversationPanelProps } from './TicketConversationPanel';
|
||||
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';
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import { notificationsPath } from '@/constants';
|
||||
import { useUnreadCount } from '@/services/notifications';
|
||||
import NotificationBellPopover from './NotificationBellPopover';
|
||||
import NotificationBellView from './NotificationBellView';
|
||||
|
||||
export interface NotificationBellProps {
|
||||
@@ -13,9 +16,10 @@ export interface NotificationBellProps {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* only this small container reads the fast-changing count, a count change re-renders just the bell — not the
|
||||
* whole shell.
|
||||
* `useUnreadCount` (stale-while-revalidate) — the only thing in this component that re-renders on a count
|
||||
* change, so the shell around it never does (§5 isolation). On the **nurse desktop** shell (§3.6) it opens
|
||||
* 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
|
||||
*/
|
||||
const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) => {
|
||||
@@ -23,13 +27,33 @@ const NotificationBell: FunctionComponent<NotificationBellProps> = ({ role }) =>
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
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 (
|
||||
<NotificationBellView
|
||||
count={count}
|
||||
label={t('bell_aria', { count })}
|
||||
onClick={() => router.push(`/${locale}${notificationsPath(role)}`)}
|
||||
/>
|
||||
<>
|
||||
<NotificationBellView ref={setAnchorEl} count={count} label={t('bell_aria', { count })} onClick={onClick} />
|
||||
{usesPopover ? (
|
||||
<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';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
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+).
|
||||
* 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
|
||||
*/
|
||||
const NotificationBellView: FunctionComponent<NotificationBellViewProps> = ({ count, label, onClick }) => {
|
||||
const NotificationBellView = forwardRef<HTMLButtonElement, NotificationBellViewProps>(({ count, label, onClick }, ref) => {
|
||||
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
|
||||
badgeContent={count}
|
||||
max={99}
|
||||
@@ -31,6 +32,8 @@ const NotificationBellView: FunctionComponent<NotificationBellViewProps> = ({ co
|
||||
</Badge>
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NotificationBellView.displayName = 'NotificationBellView';
|
||||
|
||||
export default NotificationBellView;
|
||||
|
||||
@@ -15,18 +15,64 @@ import {
|
||||
useNotifications,
|
||||
} from '@/services/notifications';
|
||||
import type { AppNotification } from '@/services/notifications/types';
|
||||
import { formatShamsiDateTime } from '@/utils';
|
||||
import { formatRelativeTime, formatShamsiDate } from '@/utils';
|
||||
import NotificationRow from './NotificationRow';
|
||||
|
||||
export interface NotificationCenterProps {
|
||||
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)
|
||||
* and **deep-links via `notificationDeepLink`** (role-aware) when its `data` points somewhere. A "mark all read"
|
||||
* action clears the badge at once. Empty / loading-skeleton / error→retry states. Shared by the customer and
|
||||
* nurse notification pages (role decides only the deep-link shell).
|
||||
* Buckets notifications into امروز/دیروز/این هفته, then per-day Shamsi headers for anything older —
|
||||
* emitted **in list order** (the server's unread-first-then-newest ordering is preserved; §5 "keep the
|
||||
* mark-read UX as is"), so a bucket can recur if an older unread item is pinned above newer read ones.
|
||||
* 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 / error→retry states. Shared by the customer and nurse notification pages (role decides
|
||||
* only the deep-link shell).
|
||||
* @component NotificationCenter
|
||||
*/
|
||||
const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }) => {
|
||||
@@ -42,6 +88,7 @@ const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
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) => {
|
||||
if (!notification.isRead) markRead.mutate(notification.id);
|
||||
@@ -94,14 +141,27 @@ const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ role }
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{items.map((notification) => (
|
||||
<NotificationRow
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
timeLabel={formatShamsiDateTime(notification.createdAt, locale)}
|
||||
onOpen={() => openNotification(notification)}
|
||||
/>
|
||||
<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 }}>
|
||||
{group.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>
|
||||
</Stack>
|
||||
))}
|
||||
{total > items.length ? (
|
||||
<AppButton
|
||||
|
||||
@@ -13,11 +13,11 @@ const base: AppNotification = {
|
||||
data: { kind: 'booking', bookingId: 5001 },
|
||||
};
|
||||
|
||||
function renderRow(overrides: Partial<AppNotification>) {
|
||||
function renderRow(overrides: Partial<AppNotification>, navigable = true) {
|
||||
const onOpen = jest.fn();
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" onOpen={onOpen} />
|
||||
<NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" navigable={navigable} onOpen={onOpen} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return onOpen;
|
||||
@@ -47,4 +47,17 @@ describe('<NotificationRow/> component', () => {
|
||||
fireEvent.click(screen.getByTestId('notification-row'));
|
||||
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,38 +1,121 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { FunctionComponent, KeyboardEvent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { AppIcon } from '@/components/common';
|
||||
import type { AppNotification } from '@/services/notifications/types';
|
||||
import { notificationIcon } from './notificationIcon';
|
||||
import { notificationIcon, notificationTint } from './notificationIcon';
|
||||
|
||||
export interface NotificationRowProps {
|
||||
notification: AppNotification;
|
||||
/** Pre-formatted Shamsi time — the caller owns locale. */
|
||||
/** Pre-formatted relative time (decaying to Shamsi) — the caller owns locale. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* icon is chosen from the parsed deep-link class. Purely presentational — the caller supplies the formatted
|
||||
* time and the `onOpen` behaviour (mark-read + `notificationDeepLink`). `title`/`body` are server-rendered
|
||||
* copy (not client i18n keys).
|
||||
* tint); a **per-kind tinted icon container** (booking teal / payout success / ticket terracotta / refund
|
||||
* info / nurse-trust) replaces the uniform primary icon. **Navigable** rows (their `data` deep-links
|
||||
* somewhere) render as a real `ButtonBase` with a trailing chevron and a visible `:focus-visible` ring;
|
||||
* **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
|
||||
*/
|
||||
const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification, timeLabel, onOpen }) => {
|
||||
const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification, timeLabel, navigable, onOpen }) => {
|
||||
const unread = !notification.isRead;
|
||||
const tint = notificationTint(notification.data.kind);
|
||||
|
||||
const content = (
|
||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start', width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
bgcolor: tint.bg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={notificationIcon(notification.data.kind)} size={18} color={tint.fg} />
|
||||
</Box>
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
{unread ? (
|
||||
<Box
|
||||
data-testid="notification-unread-dot"
|
||||
sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)', flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: unread ? 800 : 600 }}>
|
||||
{notification.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{notification.body ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{notification.body}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{timeLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{navigable ? <AppIcon icon="forward" size={18} color="var(--bal-text-secondary)" /> : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
<Box
|
||||
onClick={onOpen}
|
||||
onKeyDown={onKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="notification-row"
|
||||
data-unread={unread ? 'true' : 'false'}
|
||||
data-navigable="false"
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'start',
|
||||
p: 1.5,
|
||||
@@ -40,33 +123,12 @@ const NotificationRow: FunctionComponent<NotificationRowProps> = ({ notification
|
||||
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 },
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start', width: '100%' }}>
|
||||
<AppIcon icon={notificationIcon(notification.data.kind)} size={20} color="var(--bal-primary)" />
|
||||
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
{unread ? (
|
||||
<Box
|
||||
data-testid="notification-unread-dot"
|
||||
sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)', flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: unread ? 800 : 600 }}>
|
||||
{notification.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{notification.body ? (
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{notification.body}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
|
||||
{timeLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ButtonBase>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ export { default as NotificationBell } from './NotificationBell';
|
||||
export type { NotificationBellProps } from './NotificationBell';
|
||||
export { default as NotificationBellView } 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 type { NotificationRowProps } from './NotificationRow';
|
||||
export { default as NotificationCenter } from './NotificationCenter';
|
||||
|
||||
@@ -18,3 +18,35 @@ export function notificationIcon(kind: NotificationData['kind']): string {
|
||||
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)' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { NotificationBell } from '@/components/notifications';
|
||||
import { ProfileSummary } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
@@ -13,8 +12,11 @@ import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||
* Admin / backoffice shell — the desktop ops console (f15). The sidebar is **sectioned**
|
||||
* (اعتماد/مالی/پشتیبانی/سیستم) and **role-gated**: each console appears only when the current
|
||||
* 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
|
||||
* the `'admin'` role), and a compact identity chip showing the admin's fine-grained role.
|
||||
* removes, or loosens a gate. The TopBar carries a page title and a compact identity chip showing
|
||||
* 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
|
||||
*/
|
||||
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
@@ -50,7 +52,6 @@ const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<TopBarAndSideBarLayout
|
||||
sidebarItems={sidebarItems}
|
||||
headerActions={<NotificationBell role="admin" />}
|
||||
identity={
|
||||
me && primaryRoleCode ? (
|
||||
<ProfileSummary
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
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 { AppIcon, AppIconButton, ErrorBoundary } from '@/components';
|
||||
import { NotificationBell } from '@/components/notifications';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useSupportUnreadTotal } from '@/services/tickets';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||
import { TopBar, BottomBar } from './components';
|
||||
@@ -50,18 +51,27 @@ const CustomerHeader: FunctionComponent<{ bottomNavItems: Array<LinkToPage> }> =
|
||||
const router = useRouter();
|
||||
const title = useRouteTitle();
|
||||
const onRootTab = isCustomerRootTab(pathname);
|
||||
const supportUnreadTotal = useSupportUnreadTotal();
|
||||
|
||||
return (
|
||||
<TopBar
|
||||
align={onRootTab ? 'center' : 'start'}
|
||||
startNode={
|
||||
onRootTab ? (
|
||||
<AppIconButton
|
||||
icon="support"
|
||||
color="inherit"
|
||||
title={t('support')}
|
||||
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
|
||||
/>
|
||||
<Badge
|
||||
badgeContent={supportUnreadTotal ?? 0}
|
||||
max={99}
|
||||
overlap="circular"
|
||||
invisible={!supportUnreadTotal}
|
||||
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)' } }}
|
||||
>
|
||||
<AppIconButton
|
||||
icon="support"
|
||||
color="inherit"
|
||||
title={t('support')}
|
||||
onClick={() => router.push(ROUTES.SUPPORT_TICKETS)}
|
||||
/>
|
||||
</Badge>
|
||||
) : (
|
||||
<AppIconButton icon="back" title={tc('back')} onClick={() => router.back()} />
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ROUTES } from '@/constants';
|
||||
import { LinkToPage } from '@/utils';
|
||||
import { useMe } from '@/services/auth';
|
||||
import { useNurseProfile } from '@/services/profiles';
|
||||
import { useSupportUnreadTotal } from '@/services/tickets';
|
||||
import { useVerificationStatus } from '@/services/verification';
|
||||
import { ownBadgeState } from '@/services/verification/types';
|
||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||
@@ -29,6 +30,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
const { data: me } = useMe();
|
||||
const { data: nurseProfile } = useNurseProfile();
|
||||
const { data: verification } = useVerificationStatus();
|
||||
const supportUnreadTotal = useSupportUnreadTotal();
|
||||
|
||||
const identityLoading = !me;
|
||||
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('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings', 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(
|
||||
(): Array<LinkToPage> => [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
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 { Link } from '@/i18n/navigation';
|
||||
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`) —
|
||||
* `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`)
|
||||
* 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
|
||||
*/
|
||||
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 (
|
||||
<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} />
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ const SideBarNavList: FunctionComponent<Props> = ({ items, showIcons, onClick })
|
||||
subtitle={item.subtitle}
|
||||
selected={item.path === activePath}
|
||||
onClick={onClick}
|
||||
badgeCount={item.badgeCount}
|
||||
/>
|
||||
</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 /
|
||||
* 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 };
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.list(params),
|
||||
@@ -18,5 +18,6 @@ export function useNotifications(limit: number = NOTIFICATIONS_PAGE_SIZE) {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: NOTIFICATIONS_LIST_STALE_TIME,
|
||||
gcTime: NOTIFICATIONS_GC_TIME,
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ interface TicketSummaryWire {
|
||||
createdAt: string;
|
||||
lastMessageAt: string | null;
|
||||
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). */
|
||||
@@ -72,6 +73,9 @@ function mapSummary(w: TicketSummaryWire): TicketSummary {
|
||||
// REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire.
|
||||
lastMessageAt: w.lastMessageAt,
|
||||
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. ──
|
||||
listAdminTickets: async (
|
||||
filters: AdminTicketFilters,
|
||||
|
||||
@@ -167,10 +167,28 @@ function findTicket(id: number): StoredTicket {
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Last non-internal message time — the inbox's "last activity" (REQ-028 stand-in for `lastMessageAt`). */
|
||||
function lastMessageAt(t: StoredTicket): string {
|
||||
/** Last non-internal message — the inbox's "last activity" (visible messages only; internal notes never surface). */
|
||||
function lastVisibleMessage(t: StoredTicket): StoredMessage | undefined {
|
||||
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 {
|
||||
@@ -185,6 +203,8 @@ function toSummary(t: StoredTicket): TicketSummary {
|
||||
createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(),
|
||||
lastMessageAt: lastMessageAt(t),
|
||||
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 };
|
||||
},
|
||||
|
||||
// §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> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
|
||||
@@ -26,6 +26,20 @@ export const TICKETS_LIST_STALE_TIME = 30 * 1000;
|
||||
export const TICKET_THREAD_STALE_TIME = 15 * 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. */
|
||||
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 { ticketKeys } from '../keys';
|
||||
import { ticketsApi } from '../apis';
|
||||
import type { PostMessageResult, TicketDetail, TicketMessage } from '../types';
|
||||
import type { PostMessageResult, TicketDetail } from '../types';
|
||||
import { useTicketViewer } from './useTicketViewer';
|
||||
|
||||
interface PostMessageVars {
|
||||
@@ -10,53 +10,65 @@ interface PostMessageVars {
|
||||
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
|
||||
* immediately with a "sending" state. `onError` **rolls the thread back to the snapshot** (removing the
|
||||
* pending bubble) and rejects — the composer keeps the typed draft and offers retry (never retype). `onSuccess`
|
||||
* replaces the pending bubble **by `clientMessageId`** with the server message (so it never double-renders).
|
||||
* `onSettled` invalidates the thread + the inbox lists (last-activity/unread move). The composer clears the
|
||||
* draft **only** in its own `onSuccess`.
|
||||
* `onMutate` is idempotent on `clientMessageId`: a **fresh** send appends a pending bubble; a **retry** (the
|
||||
* same id already sitting in the cache with `sendStatus: 'failed'`) flips it back to `sending` in place
|
||||
* instead of appending a duplicate — so retry and first-send are the same call. `onError` does **not** roll
|
||||
* the thread back — it flips the bubble to `sendStatus: 'failed'` and leaves it in the thread with its typed
|
||||
* body, so the failed-bubble UI (retry / discard-and-retype) always has something to act on. `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) {
|
||||
const queryClient = useQueryClient();
|
||||
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 }),
|
||||
|
||||
onMutate: async ({ body, clientMessageId }) => {
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
await queryClient.cancelQueries({ queryKey: key });
|
||||
const previous = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (previous) {
|
||||
const pending: TicketMessage = {
|
||||
id: null,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
sendStatus: 'sending',
|
||||
};
|
||||
queryClient.setQueryData<TicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
|
||||
}
|
||||
return { previous };
|
||||
const current = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (!current) return;
|
||||
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,
|
||||
clientMessageId,
|
||||
ticketId,
|
||||
body,
|
||||
authorRole: role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isMine: true,
|
||||
sendStatus: 'sending' as const,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous);
|
||||
onError: (_err, { clientMessageId }) => {
|
||||
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 }) => {
|
||||
const key = ticketKeys.detail(ticketId);
|
||||
const current = queryClient.getQueryData<TicketDetail>(key);
|
||||
if (current) {
|
||||
queryClient.setQueryData<TicketDetail>(key, {
|
||||
@@ -71,8 +83,9 @@ export function usePostMessage(ticketId: number) {
|
||||
},
|
||||
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
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 { ticketsApi } from '../apis';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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) {
|
||||
const { userId } = useTicketViewer();
|
||||
@@ -18,5 +20,6 @@ export function useTicket(ticketId: number | undefined) {
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
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 { 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
|
||||
* 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
|
||||
* updates instantly.
|
||||
* updates instantly. Polls while mounted, same as `useTicket` (§3.2) — both observers share one cache entry.
|
||||
*/
|
||||
export function useTicketThread(ticketId: number | undefined) {
|
||||
const { userId } = useTicketViewer();
|
||||
@@ -19,6 +19,7 @@ export function useTicketThread(ticketId: number | undefined) {
|
||||
enabled: ticketId != null && ticketId > 0,
|
||||
staleTime: TICKET_THREAD_STALE_TIME,
|
||||
gcTime: TICKETS_GC_TIME,
|
||||
refetchInterval: TICKET_THREAD_REFETCH_INTERVAL,
|
||||
select: (detail): TicketMessage[] => detail.messages,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export { useTicket } from './hooks/useTicket';
|
||||
export { useTicketThread } from './hooks/useTicketThread';
|
||||
export { useOpenTicket } from './hooks/useOpenTicket';
|
||||
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.
|
||||
export { useAdminTickets } from './hooks/useAdminTickets';
|
||||
|
||||
@@ -20,6 +20,9 @@ export const ticketKeys = {
|
||||
details: () => [...ticketKeys.all, 'detail'] 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
|
||||
// caches above (and invalidating one never touches the other). Filters + page key the global queue.
|
||||
adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const,
|
||||
|
||||
@@ -40,9 +40,10 @@ export type TicketAuthorRole = 'customer' | 'nurse' | 'admin' | 'system';
|
||||
export type MessageSendStatus = 'sent' | 'sending' | 'failed';
|
||||
|
||||
/**
|
||||
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are **not**
|
||||
* on the wire summary (REQ-028) — they are optional and only the mock supplies them today; the inbox
|
||||
* renders the unread indicator / last-activity time only when present, else falls back to `createdAt`.
|
||||
* A ticket row for the "My Tickets" inbox (`TicketSummaryDto`). `lastMessageAt`/`unreadCount` are real
|
||||
* (REQ-028, delivered). `lastMessagePreview`/`lastAuthorRole` are **not** on the wire summary yet
|
||||
* (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 {
|
||||
id: number;
|
||||
@@ -53,10 +54,12 @@ export interface TicketSummary {
|
||||
bookingId: number | null;
|
||||
refundId: number | null;
|
||||
createdAt: string;
|
||||
/** REQ-028 gap — the wire summary has no last-activity timestamp; mock-only until delivered. */
|
||||
lastMessageAt?: string | null;
|
||||
/** REQ-028 gap — the wire summary has no unread count; mock-only until delivered. */
|
||||
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. */
|
||||
@@ -213,6 +216,12 @@ export interface TicketsApi {
|
||||
*/
|
||||
openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise<OpenTicketResult>;
|
||||
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
|
||||
* user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket`
|
||||
|
||||
@@ -41,3 +41,38 @@ export function formatShamsiDateTime(iso: string | Date, locale: string = 'fa'):
|
||||
export function formatShamsiMonthYear(iso: string | Date, locale: string = 'fa'): string {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ export type LinkToPage = {
|
||||
subtitle?: string; // Sub-title or secondary text to display
|
||||
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)
|
||||
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),
|
||||
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.
|
||||
|
||||
## 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) |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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 0–1 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 | 🟡 |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user