diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 47cc4e4..2b95ea2 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 ``/`` 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 diff --git a/client/messages/en.json b/client/messages/en.json index 5dfcb7d..fee0f22 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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", diff --git a/client/messages/fa.json b/client/messages/fa.json index 6e21720..16d42ba 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "پیشخان مدیریت", diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index ab18d7b..0df5e13 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -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(['back', 'chevron_start', 'forward']); +export const DIRECTIONAL_ICONS = new Set(['back', 'chevron_start', 'forward', 'send']); diff --git a/client/src/components/messaging/EmergencyPlaybookRow.test.tsx b/client/src/components/messaging/EmergencyPlaybookRow.test.tsx new file mode 100644 index 0000000..3ee39db --- /dev/null +++ b/client/src/components/messaging/EmergencyPlaybookRow.test.tsx @@ -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>; + return messages[namespace]?.[key] ?? key; + }, +})); + +import { ThemeProvider } from '../../theme'; +import EmergencyPlaybookRow from './EmergencyPlaybookRow'; + +describe(' component', () => { + it('starts collapsed with the playbook body hidden', () => { + render( + + {}} /> + , + ); + expect(screen.getByTestId('emergency-playbook-toggle')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('expands to reveal the playbook body + open-ticket action on tap', () => { + render( + + {}} /> + , + ); + 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( + + + , + ); + fireEvent.click(screen.getByTestId('emergency-playbook-toggle')); + fireEvent.click(screen.getByRole('button', { name: /پشتیبانی/ })); + expect(onOpenTicket).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/messaging/EmergencyPlaybookRow.tsx b/client/src/components/messaging/EmergencyPlaybookRow.tsx new file mode 100644 index 0000000..5cc7831 --- /dev/null +++ b/client/src/components/messaging/EmergencyPlaybookRow.tsx @@ -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 = ({ onOpenTicket }) => { + const t = useTranslations('tickets'); + const [expanded, setExpanded] = useState(false); + + return ( + + 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' }} + > + + + {t('emergency_row_title')} + + + + + + + {t('emergency_row_body')} + + + {t('emergency_open_ticket')} + + + + + ); +}; + +export default EmergencyPlaybookRow; diff --git a/client/src/components/messaging/MessageBubble.test.tsx b/client/src/components/messaging/MessageBubble.test.tsx index d8203f3..5dbf22a 100644 --- a/client/src/components/messaging/MessageBubble.test.tsx +++ b/client/src/components/messaging/MessageBubble.test.tsx @@ -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) { +function renderBubble(overrides: Partial, extra: { showAuthorLabel?: boolean; onRetry?: () => void; onDiscard?: () => void } = {}) { return render( , ); @@ -35,6 +41,11 @@ describe(' 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(' 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); + }); }); diff --git a/client/src/components/messaging/MessageBubble.tsx b/client/src/components/messaging/MessageBubble.tsx index bd7f476..c246f51 100644 --- a/client/src/components/messaging/MessageBubble.tsx +++ b/client/src/components/messaging/MessageBubble.tsx @@ -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 = ({ message, authorLabel, timeLabel, sendingLabel }) => { +const MessageBubble: FunctionComponent = ({ + 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 ( = ({ 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 ? ( {authorLabel} @@ -60,18 +89,43 @@ const MessageBubble: FunctionComponent = ({ message, authorL {message.body} - - {sending ? sendingLabel : timeLabel} - + {failed ? ( + + + {failedLabel} + + + + + + + ) : ( + + {sending ? sendingLabel : timeLabel} + + )} ); diff --git a/client/src/components/messaging/MessageComposer.tsx b/client/src/components/messaging/MessageComposer.tsx index 627b07c..2bd0b40 100644 --- a/client/src/components/messaging/MessageComposer.tsx +++ b/client/src/components/messaging/MessageComposer.tsx @@ -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 = ({ ticketId, disabled }) => { +const MessageComposer: FunctionComponent = ({ 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) => { + 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 ( - - {postMessage.isError ? ( - - {t('send_failed')} - + + {TICKETS_ATTACHMENTS_ENABLED ? ( + ) : null} - - setDraft(event.target.value)} - onKeyDown={onKeyDown} - disabled={disabled} - /> - - {sending ? ( - - ) : ( - - )} - - + onChange(event.target.value)} + onKeyDown={onKeyDown} + disabled={disabled} + /> + + {sending ? ( + + ) : ( + + )} + ); }; diff --git a/client/src/components/messaging/TicketConversationPanel.tsx b/client/src/components/messaging/TicketConversationPanel.tsx new file mode 100644 index 0000000..07e37bf --- /dev/null +++ b/client/src/components/messaging/TicketConversationPanel.tsx @@ -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 = ({ 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 ( + <> + + + + {closed ? ( + + {t('closed_notice')} + + ) : ( + + )} + + + ); +}; + +export default TicketConversationPanel; diff --git a/client/src/components/messaging/TicketInboxScreen.tsx b/client/src/components/messaging/TicketInboxScreen.tsx index 0e1949f..13d40d2 100644 --- a/client/src/components/messaging/TicketInboxScreen.tsx +++ b/client/src/components/messaging/TicketInboxScreen.tsx @@ -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 = [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 = ({ role }) => { @@ -31,14 +40,22 @@ const TicketInboxScreen: FunctionComponent = ({ role }) const locale = useLocale(); const router = useRouter(); const [dialogOpen, setDialogOpen] = useState(false); + const [status, setStatus] = useState(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 ( - + {t('title')} @@ -53,7 +70,19 @@ const TicketInboxScreen: FunctionComponent = ({ role }) - setDialogOpen(true)} /> + setDialogOpen(true)} /> + + + {STATUS_FILTERS.map((filter) => ( + selectStatus(filter)} + color={status === filter ? 'primary' : 'default'} + variant={status === filter ? 'filled' : 'outlined'} + /> + ))} + {isLoading ? ( @@ -89,12 +118,30 @@ const TicketInboxScreen: FunctionComponent = ({ 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 ? ( + setLimit((current) => current + TICKETS_PAGE_SIZE)} + disabled={isFetching} + sx={{ alignSelf: 'center' }} + > + {t('load_more')} + + ) : null} )} diff --git a/client/src/components/messaging/TicketListCard.test.tsx b/client/src/components/messaging/TicketListCard.test.tsx index 3d3141c..d0f3e78 100644 --- a/client/src/components/messaging/TicketListCard.test.tsx +++ b/client/src/components/messaging/TicketListCard.test.tsx @@ -66,4 +66,27 @@ describe(' 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( + + {}} + /> + , + ); + expect(container.querySelector('[data-testid="ticket-card"]')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/messaging/TicketListCard.tsx b/client/src/components/messaging/TicketListCard.tsx index 74f2013..07b5f95 100644 --- a/client/src/components/messaging/TicketListCard.tsx +++ b/client/src/components/messaging/TicketListCard.tsx @@ -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 = ({ @@ -38,6 +47,7 @@ const TicketListCard: FunctionComponent = ({ categoryLabel, statusLabel, timeLabel, + previewLabel, linkedBookingLabel, linkedRefundLabel, onOpen, @@ -58,6 +68,7 @@ const TicketListCard: FunctionComponent = ({ border: '1px solid', borderColor: 'divider', bgcolor: 'var(--bal-bg-paper)', + '&:focus-visible': { outline: '2px solid var(--bal-focus-ring)', outlineOffset: 2 }, }} > @@ -96,6 +107,21 @@ const TicketListCard: FunctionComponent = ({ {ticket.referenceCode} + {previewLabel ? ( + + {previewLabel} + + ) : null} + {linkedBookingLabel ? ( diff --git a/client/src/components/messaging/TicketMessageList.tsx b/client/src/components/messaging/TicketMessageList.tsx index 1cc4d16..b61616e 100644 --- a/client/src/components/messaging/TicketMessageList.tsx +++ b/client/src/components/messaging/TicketMessageList.tsx @@ -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 = ({ ticketId }) => { +const TicketMessageList: FunctionComponent = ({ 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 ( @@ -48,18 +104,79 @@ const TicketMessageList: FunctionComponent = ({ ticketId ); } + const blocks = buildBlocks(messages, locale, t('day_today'), t('day_yesterday')); + return ( - - {messages.map((message) => ( - - ))} - + + + {blocks.map((block) => { + if (block.kind === 'separator') { + return ( + + {block.label} + + ); + } + if (block.kind === 'system') { + return ( + + {block.message.body} + + ); + } + return ( + + {block.messages.map((message, index) => ( + onRetry(message)} + onDiscard={() => onDiscard(message)} + /> + ))} + + ); + })} + + + + {showNewMessagePill ? ( + + + {t('new_message_pill')} + + + ) : null} + ); }; diff --git a/client/src/components/messaging/TicketThreadScreen.tsx b/client/src/components/messaging/TicketThreadScreen.tsx index 16b8831..bf617c2 100644 --- a/client/src/components/messaging/TicketThreadScreen.tsx +++ b/client/src/components/messaging/TicketThreadScreen.tsx @@ -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 = ({ role, - - - - {ticket.status === 'closed' ? ( - - {t('closed_notice')} - - ) : ( - // 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. - - )} - + {/* 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. */} + )} diff --git a/client/src/components/messaging/clientMessageId.ts b/client/src/components/messaging/clientMessageId.ts new file mode 100644 index 0000000..6ed6d61 --- /dev/null +++ b/client/src/components/messaging/clientMessageId.ts @@ -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)}`; +} diff --git a/client/src/components/messaging/index.ts b/client/src/components/messaging/index.ts index 48d560a..fbd144c 100644 --- a/client/src/components/messaging/index.ts +++ b/client/src/components/messaging/index.ts @@ -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'; diff --git a/client/src/components/messaging/useThreadScroll.ts b/client/src/components/messaging/useThreadScroll.ts new file mode 100644 index 0000000..17de571 --- /dev/null +++ b/client/src/components/messaging/useThreadScroll.ts @@ -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(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 }; +} diff --git a/client/src/components/notifications/NotificationBell.tsx b/client/src/components/notifications/NotificationBell.tsx index dfc3059..07237d0 100644 --- a/client/src/components/notifications/NotificationBell.tsx +++ b/client/src/components/notifications/NotificationBell.tsx @@ -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 = ({ role }) => { @@ -23,13 +27,33 @@ const NotificationBell: FunctionComponent = ({ 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(null); + const [popoverOpen, setPopoverOpen] = useState(false); + + const usesPopover = role === 'nurse' && isDesktop; + + const onClick = () => { + if (usesPopover) { + setPopoverOpen(true); + return; + } + router.push(`/${locale}${notificationsPath(role)}`); + }; return ( - router.push(`/${locale}${notificationsPath(role)}`)} - /> + <> + + {usesPopover ? ( + setPopoverOpen(false)} + role="nurse" + /> + ) : null} + ); }; diff --git a/client/src/components/notifications/NotificationBellPopover.tsx b/client/src/components/notifications/NotificationBellPopover.tsx new file mode 100644 index 0000000..dcf6dc9 --- /dev/null +++ b/client/src/components/notifications/NotificationBellPopover.tsx @@ -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 = ({ 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 ( + + + + + {t('title')} + + {hasUnread ? ( + markAll.mutate()} disabled={markAll.isPending}> + {t('mark_all_read')} + + ) : null} + + + {isLoading ? ( + + {[0, 1].map((i) => ( + + ))} + + ) : items.length === 0 ? ( + + {t('empty_title')} + + ) : ( + + {items.map((notification) => { + const target = notificationDeepLink(notification, role); + return ( + openNotification(notification)} + /> + ); + })} + + )} + + + + {t('view_all')} + + + + ); +}; + +export default NotificationBellPopover; diff --git a/client/src/components/notifications/NotificationBellView.tsx b/client/src/components/notifications/NotificationBellView.tsx index 10a9e8f..cad4f91 100644 --- a/client/src/components/notifications/NotificationBellView.tsx +++ b/client/src/components/notifications/NotificationBellView.tsx @@ -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 = ({ count, label, onClick }) => { +const NotificationBellView = forwardRef(({ count, label, onClick }, ref) => { return ( - + = ({ co ); -}; +}); + +NotificationBellView.displayName = 'NotificationBellView'; export default NotificationBellView; diff --git a/client/src/components/notifications/NotificationCenter.tsx b/client/src/components/notifications/NotificationCenter.tsx index b6afd54..6c43445 100644 --- a/client/src/components/notifications/NotificationCenter.tsx +++ b/client/src/components/notifications/NotificationCenter.tsx @@ -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(); + 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 = ({ role }) => { @@ -42,6 +88,7 @@ const NotificationCenter: FunctionComponent = ({ 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 = ({ role } ) : ( - - {items.map((notification) => ( - openNotification(notification)} - /> + + {groups.map((group) => ( + + + {group.label} + + + {group.items.map((notification) => { + const target = notificationDeepLink(notification, role); + return ( + openNotification(notification)} + /> + ); + })} + + ))} {total > items.length ? ( ) { +function renderRow(overrides: Partial, navigable = true) { const onOpen = jest.fn(); render( - + , ); return onOpen; @@ -47,4 +47,17 @@ describe(' 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'); + }); }); diff --git a/client/src/components/notifications/NotificationRow.tsx b/client/src/components/notifications/NotificationRow.tsx index 0596000..4a7a29a 100644 --- a/client/src/components/notifications/NotificationRow.tsx +++ b/client/src/components/notifications/NotificationRow.tsx @@ -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 = ({ notification, timeLabel, onOpen }) => { +const NotificationRow: FunctionComponent = ({ notification, timeLabel, navigable, onOpen }) => { const unread = !notification.isRead; + const tint = notificationTint(notification.data.kind); + + const content = ( + + + + + + + {unread ? ( + + ) : null} + + {notification.title} + + + {notification.body ? ( + + {notification.body} + + ) : null} + + {timeLabel} + + + {navigable ? : null} + + ); + + if (navigable) { + return ( + + {content} + + ); + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onOpen(); + } + }; + return ( - = ({ 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 }, }} > - - - - - {unread ? ( - - ) : null} - - {notification.title} - - - {notification.body ? ( - - {notification.body} - - ) : null} - - {timeLabel} - - - - + {content} + ); }; diff --git a/client/src/components/notifications/index.ts b/client/src/components/notifications/index.ts index d12282c..7fe36c9 100644 --- a/client/src/components/notifications/index.ts +++ b/client/src/components/notifications/index.ts @@ -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'; diff --git a/client/src/components/notifications/notificationIcon.ts b/client/src/components/notifications/notificationIcon.ts index 744ddd1..8fd6bf0 100644 --- a/client/src/components/notifications/notificationIcon.ts +++ b/client/src/components/notifications/notificationIcon.ts @@ -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)' }; + } +} diff --git a/client/src/layout/AdminLayout.tsx b/client/src/layout/AdminLayout.tsx index fe23231..385dd67 100644 --- a/client/src/layout/AdminLayout.tsx +++ b/client/src/layout/AdminLayout.tsx @@ -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 = ({ children }) => { @@ -50,7 +52,6 @@ const AdminLayout: FunctionComponent = ({ children }) => { return ( } identity={ me && primaryRoleCode ? ( }> = const router = useRouter(); const title = useRouteTitle(); const onRootTab = isCustomerRootTab(pathname); + const supportUnreadTotal = useSupportUnreadTotal(); return ( router.push(ROUTES.SUPPORT_TICKETS)} - /> + + router.push(ROUTES.SUPPORT_TICKETS)} + /> + ) : ( router.back()} /> ) diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index ab5fa98..2545dd8 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -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 = ({ 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 = ({ 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 => [ diff --git a/client/src/layout/components/SideBarNavItem.tsx b/client/src/layout/components/SideBarNavItem.tsx index 4afe049..b2806f1 100644 --- a/client/src/layout/components/SideBarNavItem.tsx +++ b/client/src/layout/components/SideBarNavItem.tsx @@ -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 = ({ icon, path, selected = false, subtitle, title, onClick }) => { +const SideBarNavItem: FunctionComponent = ({ + icon, + path, + selected = false, + subtitle, + title, + onClick, + badgeCount, +}) => { + const iconNode = icon && ; return ( - {icon && } + + {badgeCount ? ( + + {iconNode} + + ) : ( + iconNode + )} + ); diff --git a/client/src/layout/components/SideBarNavList.tsx b/client/src/layout/components/SideBarNavList.tsx index 194af11..2611da7 100644 --- a/client/src/layout/components/SideBarNavList.tsx +++ b/client/src/layout/components/SideBarNavList.tsx @@ -42,6 +42,7 @@ const SideBarNavList: FunctionComponent = ({ items, showIcons, onClick }) subtitle={item.subtitle} selected={item.path === activePath} onClick={onClick} + badgeCount={item.badgeCount} /> ); diff --git a/client/src/services/notifications/hooks/useNotifications.ts b/client/src/services/notifications/hooks/useNotifications.ts index ce8a80a..4388264 100644 --- a/client/src/services/notifications/hooks/useNotifications.ts +++ b/client/src/services/notifications/hooks/useNotifications.ts @@ -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, }); } diff --git a/client/src/services/tickets/apis/clientApi.ts b/client/src/services/tickets/apis/clientApi.ts index 9723d59..91254ec 100644 --- a/client/src/services/tickets/apis/clientApi.ts +++ b/client/src/services/tickets/apis/clientApi.ts @@ -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 => null, + // ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ── listAdminTickets: async ( filters: AdminTicketFilters, diff --git a/client/src/services/tickets/apis/mockApi.ts b/client/src/services/tickets/apis/mockApi.ts index 58cf2d4..662ceb4 100644 --- a/client/src/services/tickets/apis/mockApi.ts +++ b/client/src/services/tickets/apis/mockApi.ts @@ -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 => { + await sleep(MOCK_LATENCY_MS); + return tickets.reduce((sum, t) => sum + t.unread, 0); + }, + postMessage: async (ticketId: number, body: PostMessageRequest): Promise => { await sleep(MOCK_LATENCY_MS); const t = findTicket(ticketId); diff --git a/client/src/services/tickets/constants.ts b/client/src/services/tickets/constants.ts index ff42d95..6a06d9d 100644 --- a/client/src/services/tickets/constants.ts +++ b/client/src/services/tickets/constants.ts @@ -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; diff --git a/client/src/services/tickets/hooks/useDiscardFailedMessage.ts b/client/src/services/tickets/hooks/useDiscardFailedMessage.ts new file mode 100644 index 0000000..2e29522 --- /dev/null +++ b/client/src/services/tickets/hooks/useDiscardFailedMessage.ts @@ -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(key, (current) => + current + ? { ...current, messages: current.messages.filter((m) => m.clientMessageId !== clientMessageId) } + : current, + ); + }; +} diff --git a/client/src/services/tickets/hooks/usePostMessage.ts b/client/src/services/tickets/hooks/usePostMessage.ts index 6287bb0..e09e758 100644 --- a/client/src/services/tickets/hooks/usePostMessage.ts +++ b/client/src/services/tickets/hooks/usePostMessage.ts @@ -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({ + return useMutation({ 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(key); - if (previous) { - const pending: TicketMessage = { - id: null, - clientMessageId, - ticketId, - body, - authorRole: role, - createdAt: new Date().toISOString(), - isMine: true, - sendStatus: 'sending', - }; - queryClient.setQueryData(key, { ...previous, messages: [...previous.messages, pending] }); - } - return { previous }; + const current = queryClient.getQueryData(key); + if (!current) return; + const alreadyPending = current.messages.some((m) => m.clientMessageId === clientMessageId); + queryClient.setQueryData(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(key); + if (!current) return; + queryClient.setQueryData(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(key); if (current) { queryClient.setQueryData(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() }); }, }); } diff --git a/client/src/services/tickets/hooks/useSupportUnreadTotal.ts b/client/src/services/tickets/hooks/useSupportUnreadTotal.ts new file mode 100644 index 0000000..4fc046d --- /dev/null +++ b/client/src/services/tickets/hooks/useSupportUnreadTotal.ts @@ -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; +} diff --git a/client/src/services/tickets/hooks/useTicket.ts b/client/src/services/tickets/hooks/useTicket.ts index 6dd48d3..fc448e0 100644 --- a/client/src/services/tickets/hooks/useTicket.ts +++ b/client/src/services/tickets/hooks/useTicket.ts @@ -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, }); } diff --git a/client/src/services/tickets/hooks/useTicketThread.ts b/client/src/services/tickets/hooks/useTicketThread.ts index 0d75510..176f9c5 100644 --- a/client/src/services/tickets/hooks/useTicketThread.ts +++ b/client/src/services/tickets/hooks/useTicketThread.ts @@ -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, }); } diff --git a/client/src/services/tickets/index.ts b/client/src/services/tickets/index.ts index de1c652..e4e77b1 100644 --- a/client/src/services/tickets/index.ts +++ b/client/src/services/tickets/index.ts @@ -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'; diff --git a/client/src/services/tickets/keys.ts b/client/src/services/tickets/keys.ts index dc1054b..f11fa81 100644 --- a/client/src/services/tickets/keys.ts +++ b/client/src/services/tickets/keys.ts @@ -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, diff --git a/client/src/services/tickets/types.ts b/client/src/services/tickets/types.ts index eff63de..0aff3e5 100644 --- a/client/src/services/tickets/types.ts +++ b/client/src/services/tickets/types.ts @@ -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; postMessage(ticketId: number, body: PostMessageRequest): Promise; + /** + * 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; /* 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` diff --git a/client/src/utils/date.ts b/client/src/utils/date.ts index 0f6d913..3d14ec3 100644 --- a/client/src/utils/date.ts +++ b/client/src/utils/date.ts @@ -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); +} diff --git a/client/src/utils/type.ts b/client/src/utils/type.ts index 556d33f..b98693b 100644 --- a/client/src/utils/type.ts +++ b/client/src/utils/type.ts @@ -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) }; diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 0d628e5..0962e1b 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 330177b..bfee3eb 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 `` 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 | 🟡 | diff --git a/dev/shared-working-context/reports/ui-phase-10-report.md b/dev/shared-working-context/reports/ui-phase-10-report.md new file mode 100644 index 0000000..acbfc61 --- /dev/null +++ b/dev/shared-working-context/reports/ui-phase-10-report.md @@ -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 `` 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.