From bc51cf59b4ca5441fd122eb7c78d20a381bb05b4 Mon Sep 17 00:00:00 2001 From: hamid Date: Fri, 10 Jul 2026 18:46:16 +0330 Subject: [PATCH] frontend phase 14 --- client/CLAUDE.md | 16 +- client/messages/en.json | 59 ++++ client/messages/fa.json | 59 ++++ .../(customer)/bookings/[id]/page.tsx | 2 + .../(customer)/notifications/page.tsx | 7 + .../(customer)/support/tickets/[id]/page.tsx | 11 + .../(customer)/support/tickets/page.tsx | 7 + .../nurse/notifications/page.tsx | 7 + .../nurse/support/tickets/[id]/page.tsx | 11 + .../nurse/support/tickets/page.tsx | 7 + .../nurse/visits/[id]/page.tsx | 2 + .../src/components/common/AppIcon/config.ts | 5 + .../messaging/BookingSupportEntry.tsx | 71 +++++ .../messaging/ContactSupportDialog.test.tsx | 54 ++++ .../messaging/ContactSupportDialog.tsx | 180 +++++++++++ .../messaging/EmergencyBanner.test.tsx | 47 +++ .../components/messaging/EmergencyBanner.tsx | 90 ++++++ .../messaging/MessageBubble.test.tsx | 49 +++ .../components/messaging/MessageBubble.tsx | 80 +++++ .../components/messaging/MessageComposer.tsx | 94 ++++++ .../messaging/TicketInboxScreen.tsx | 107 +++++++ .../messaging/TicketListCard.test.tsx | 69 +++++ .../components/messaging/TicketListCard.tsx | 118 +++++++ .../messaging/TicketMessageList.tsx | 66 ++++ .../messaging/TicketThreadScreen.tsx | 128 ++++++++ .../src/components/messaging/authorLabel.ts | 16 + client/src/components/messaging/index.ts | 19 ++ client/src/components/messaging/statusKind.ts | 22 ++ .../notifications/NotificationBell.tsx | 36 +++ .../NotificationBellView.test.tsx | 28 ++ .../notifications/NotificationBellView.tsx | 36 +++ .../notifications/NotificationCenter.tsx | 124 ++++++++ .../notifications/NotificationRow.test.tsx | 50 +++ .../notifications/NotificationRow.tsx | 73 +++++ client/src/components/notifications/index.ts | 13 + .../notifications/notificationIcon.ts | 20 ++ client/src/constants/routes.ts | 28 ++ client/src/layout/CustomerLayout.tsx | 30 +- client/src/layout/NurseLayout.tsx | 3 + client/src/layout/TopBarAndSideBarLayout.tsx | 19 +- client/src/lib/query/QueryProvider.tsx | 2 +- .../services/notifications/apis/clientApi.ts | 73 +++++ .../src/services/notifications/apis/index.ts | 13 + .../services/notifications/apis/mockApi.ts | 108 +++++++ .../src/services/notifications/constants.ts | 27 ++ .../services/notifications/deepLink.test.ts | 35 +++ client/src/services/notifications/deepLink.ts | 35 +++ .../notifications/hooks/useMarkAllRead.ts | 43 +++ .../hooks/useMarkNotificationRead.ts | 51 +++ .../notifications/hooks/useNotifications.ts | 22 ++ .../notifications/hooks/useUnreadCount.ts | 25 ++ client/src/services/notifications/index.ts | 10 + client/src/services/notifications/keys.ts | 16 + .../src/services/notifications/parse.test.ts | 28 ++ client/src/services/notifications/parse.ts | 68 ++++ client/src/services/notifications/types.ts | 56 ++++ client/src/services/tickets/apis/clientApi.ts | 153 +++++++++ client/src/services/tickets/apis/index.ts | 11 + client/src/services/tickets/apis/mockApi.ts | 291 ++++++++++++++++++ client/src/services/tickets/constants.ts | 47 +++ .../services/tickets/hooks/useMyTickets.ts | 22 ++ .../services/tickets/hooks/useOpenTicket.ts | 23 ++ .../services/tickets/hooks/usePostMessage.ts | 78 +++++ .../src/services/tickets/hooks/useTicket.ts | 22 ++ .../services/tickets/hooks/useTicketThread.ts | 24 ++ .../services/tickets/hooks/useTicketViewer.ts | 18 ++ client/src/services/tickets/index.ts | 9 + client/src/services/tickets/keys.ts | 21 ++ client/src/services/tickets/types.ts | 160 ++++++++++ dev/shared-working-context/frontend/STATUS.md | 39 +++ .../frontend/requests/for-backend.md | 32 ++ .../reports/frontend-phase-14-report.md | 168 ++++++++++ .../reports/mocks-registry.md | 2 + 73 files changed, 3582 insertions(+), 13 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/notifications/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/support/tickets/[id]/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/support/tickets/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/notifications/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/support/tickets/[id]/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/support/tickets/page.tsx create mode 100644 client/src/components/messaging/BookingSupportEntry.tsx create mode 100644 client/src/components/messaging/ContactSupportDialog.test.tsx create mode 100644 client/src/components/messaging/ContactSupportDialog.tsx create mode 100644 client/src/components/messaging/EmergencyBanner.test.tsx create mode 100644 client/src/components/messaging/EmergencyBanner.tsx create mode 100644 client/src/components/messaging/MessageBubble.test.tsx create mode 100644 client/src/components/messaging/MessageBubble.tsx create mode 100644 client/src/components/messaging/MessageComposer.tsx create mode 100644 client/src/components/messaging/TicketInboxScreen.tsx create mode 100644 client/src/components/messaging/TicketListCard.test.tsx create mode 100644 client/src/components/messaging/TicketListCard.tsx create mode 100644 client/src/components/messaging/TicketMessageList.tsx create mode 100644 client/src/components/messaging/TicketThreadScreen.tsx create mode 100644 client/src/components/messaging/authorLabel.ts create mode 100644 client/src/components/messaging/index.ts create mode 100644 client/src/components/messaging/statusKind.ts create mode 100644 client/src/components/notifications/NotificationBell.tsx create mode 100644 client/src/components/notifications/NotificationBellView.test.tsx create mode 100644 client/src/components/notifications/NotificationBellView.tsx create mode 100644 client/src/components/notifications/NotificationCenter.tsx create mode 100644 client/src/components/notifications/NotificationRow.test.tsx create mode 100644 client/src/components/notifications/NotificationRow.tsx create mode 100644 client/src/components/notifications/index.ts create mode 100644 client/src/components/notifications/notificationIcon.ts create mode 100644 client/src/services/notifications/apis/clientApi.ts create mode 100644 client/src/services/notifications/apis/index.ts create mode 100644 client/src/services/notifications/apis/mockApi.ts create mode 100644 client/src/services/notifications/constants.ts create mode 100644 client/src/services/notifications/deepLink.test.ts create mode 100644 client/src/services/notifications/deepLink.ts create mode 100644 client/src/services/notifications/hooks/useMarkAllRead.ts create mode 100644 client/src/services/notifications/hooks/useMarkNotificationRead.ts create mode 100644 client/src/services/notifications/hooks/useNotifications.ts create mode 100644 client/src/services/notifications/hooks/useUnreadCount.ts create mode 100644 client/src/services/notifications/index.ts create mode 100644 client/src/services/notifications/keys.ts create mode 100644 client/src/services/notifications/parse.test.ts create mode 100644 client/src/services/notifications/parse.ts create mode 100644 client/src/services/notifications/types.ts create mode 100644 client/src/services/tickets/apis/clientApi.ts create mode 100644 client/src/services/tickets/apis/index.ts create mode 100644 client/src/services/tickets/apis/mockApi.ts create mode 100644 client/src/services/tickets/constants.ts create mode 100644 client/src/services/tickets/hooks/useMyTickets.ts create mode 100644 client/src/services/tickets/hooks/useOpenTicket.ts create mode 100644 client/src/services/tickets/hooks/usePostMessage.ts create mode 100644 client/src/services/tickets/hooks/useTicket.ts create mode 100644 client/src/services/tickets/hooks/useTicketThread.ts create mode 100644 client/src/services/tickets/hooks/useTicketViewer.ts create mode 100644 client/src/services/tickets/index.ts create mode 100644 client/src/services/tickets/keys.ts create mode 100644 client/src/services/tickets/types.ts create mode 100644 dev/shared-working-context/reports/frontend-phase-14-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index cd4e89f..09a0d53 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -151,7 +151,9 @@ client/ │ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14): reused PatientHeader + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). Family-owned & patient-scoped — customer edits medications/routine/tasks (useUpdateCareRecord); سوابق = read-only nurse visit-note history (VisitNoteCard); access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) │ │ │ ├── wallet/ # /wallet — f11 D5 پیگیری اقساط (page.tsx = thin shell → WalletInstallments.tsx: provider-reported outstanding balance + due list + early-pay provider hand-off; self-contained for f12 nurse-earnings later) - │ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) + │ │ │ ├── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) + │ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging + │ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── page.tsx # /nurse (dashboard) @@ -168,7 +170,9 @@ client/ │ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only) │ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven) │ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords) - │ │ │ └── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links) + │ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links) + │ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx + │ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here │ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell │ │ ├── layout.tsx # 'use client' — wraps AdminLayout │ │ ├── page.tsx # /admin (overview) @@ -215,6 +219,8 @@ client/ │ ├── PatientHeader/ # f13 patient identity block (name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions (tested) │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) + │ ├── 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 │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown ├── i18n/ │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' @@ -273,6 +279,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 │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── keys.ts # React Query key factory (hierarchical) @@ -374,10 +382,12 @@ 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` - `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale -files): `notifications`, `admin`. Keep top-level keys as namespaces and both files in sync. +files): `admin` (f15). Keep top-level keys as namespaces and both files in sync. **Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files. diff --git a/client/messages/en.json b/client/messages/en.json index ea778ae..770a546 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -17,6 +17,7 @@ "overview": "Overview", "users": "Users", "notifications": "Notifications", + "support": "Support", "login": "Login", "logout": "Logout" }, @@ -1087,5 +1088,63 @@ "note_error": "Couldn't save the note. Please try again.", "note_required": "Please write a note.", "continuity_title": "Patient history" + }, + "tickets": { + "title": "My Tickets", + "contact_support": "Contact support", + "empty_title": "No conversations yet", + "empty_body": "Open a ticket to coordinate or get support.", + "error_body": "Couldn't load your tickets.", + "retry": "Retry", + "category_support": "Support", + "category_coordination": "Visit coordination", + "category_refund": "Refund", + "category_emergency": "Emergency", + "status_open": "Open", + "status_closed": "Closed", + "linked_booking": "Booking #{id}", + "linked_refund": "Refund #{id}", + "ref_code_label": "Reference code", + "new_ticket_title": "New ticket", + "category_label": "Category", + "subject_label": "Subject", + "message_label": "Message", + "submit": "Send", + "submitting": "Sending…", + "cancel": "Cancel", + "close": "Close", + "open_failed": "Couldn't open the ticket. Please try again.", + "created_title": "Ticket created", + "created_body": "Your ticket was created. You can follow the conversation.", + "view_thread": "View conversation", + "back_to_tickets": "Back to tickets", + "thread_error": "Couldn't load the conversation.", + "thread_empty_title": "No messages yet", + "thread_empty_body": "No messages yet — start coordinating.", + "closed_notice": "This conversation is closed.", + "sending": "Sending…", + "send": "Send message", + "send_failed": "Message not sent. Please try again.", + "composer_placeholder": "Write your message…", + "author_customer": "Family", + "author_nurse": "Nurse", + "author_support": "Support", + "author_system": "System", + "emergency_title": "Emergencies", + "emergency_body": "For emergencies, call the emergency contact first, then open a ticket.", + "emergency_call": "Call {name}", + "emergency_call_generic": "Emergency call", + "emergency_open_ticket": "Open a support ticket", + "open_from_booking": "Get support / Open ticket" + }, + "notifications": { + "title": "Notifications", + "empty_title": "You're all caught up", + "empty_body": "You have no unread notifications.", + "error_body": "Couldn't load your notifications.", + "retry": "Retry", + "mark_all_read": "Mark all read", + "load_more": "Load more", + "bell_aria": "{count, number} unread notifications" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 8e13d28..aeff2c9 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -17,6 +17,7 @@ "overview": "نمای کلی", "users": "کاربران", "notifications": "اعلان‌ها", + "support": "پشتیبانی", "login": "ورود", "logout": "خروج" }, @@ -1087,5 +1088,63 @@ "note_error": "ثبت یادداشت ممکن نشد. دوباره تلاش کنید.", "note_required": "متن یادداشت را وارد کنید.", "continuity_title": "سوابق بیمار" + }, + "tickets": { + "title": "تیکت‌های من", + "contact_support": "تماس با پشتیبانی", + "empty_title": "هنوز گفتگویی ندارید", + "empty_body": "برای هماهنگی یا پشتیبانی، یک تیکت جدید باز کنید.", + "error_body": "بارگذاری تیکت‌ها ممکن نشد.", + "retry": "تلاش مجدد", + "category_support": "پشتیبانی", + "category_coordination": "هماهنگی ویزیت", + "category_refund": "بازپرداخت", + "category_emergency": "اضطراری", + "status_open": "باز", + "status_closed": "بسته", + "linked_booking": "رزرو #{id}", + "linked_refund": "بازپرداخت #{id}", + "ref_code_label": "کد پیگیری", + "new_ticket_title": "تیکت جدید", + "category_label": "دسته", + "subject_label": "موضوع", + "message_label": "پیام", + "submit": "ارسال", + "submitting": "در حال ارسال…", + "cancel": "انصراف", + "close": "بستن", + "open_failed": "ثبت تیکت ممکن نشد. دوباره تلاش کنید.", + "created_title": "تیکت ثبت شد", + "created_body": "تیکت شما ایجاد شد. می‌توانید گفتگو را دنبال کنید.", + "view_thread": "مشاهده گفتگو", + "back_to_tickets": "بازگشت به تیکت‌ها", + "thread_error": "بارگذاری گفتگو ممکن نشد.", + "thread_empty_title": "هنوز پیامی نیست", + "thread_empty_body": "هنوز پیامی نیست، هماهنگی را شروع کنید.", + "closed_notice": "این گفتگو بسته شده است.", + "sending": "در حال ارسال…", + "send": "ارسال پیام", + "send_failed": "پیام ارسال نشد. دوباره تلاش کنید.", + "composer_placeholder": "پیام خود را بنویسید…", + "author_customer": "خانواده", + "author_nurse": "پرستار", + "author_support": "پشتیبانی", + "author_system": "سیستم", + "emergency_title": "موارد اضطراری", + "emergency_body": "در مواقع اضطراری ابتدا با شمارهٔ تماس اضطراری تماس بگیرید، سپس یک تیکت ثبت کنید.", + "emergency_call": "تماس با {name}", + "emergency_call_generic": "تماس اضطراری", + "emergency_open_ticket": "ثبت تیکت پشتیبانی", + "open_from_booking": "دریافت پشتیبانی / ثبت تیکت" + }, + "notifications": { + "title": "اعلان‌ها", + "empty_title": "به‌روز هستید", + "empty_body": "اعلان خوانده‌نشده‌ای ندارید.", + "error_body": "بارگذاری اعلان‌ها ممکن نشد.", + "retry": "تلاش مجدد", + "mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده", + "load_more": "نمایش بیشتر", + "bell_aria": "{count, number} اعلان خوانده‌نشده" } } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx index 52f043d..6bcb0cd 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx @@ -3,6 +3,7 @@ import { useParams, useRouter } from 'next/navigation'; import { useLocale, useTranslations } from 'next-intl'; import { Stack, Typography } from '@mui/material'; import { BookingDetailView } from '@/components/booking'; +import { BookingSupportEntry } from '@/components/messaging'; import RefundStatusCard from '@/components/RefundStatusCard'; import AppButton from '@/components/common/AppButton'; import { bookingCancelPath, bookingRefundStatusPath, bookingReviewPath } from '@/constants'; @@ -30,6 +31,7 @@ export default function CustomerBookingDetailPage() { {bookingId > 0 && } {bookingId > 0 && } + {bookingId > 0 && } ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/notifications/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/notifications/page.tsx new file mode 100644 index 0000000..9f82415 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/notifications/page.tsx @@ -0,0 +1,7 @@ +'use client'; +import { NotificationCenter } from '@/components/notifications'; + +/** /notifications — the customer notification center (f14). The bell deep-links here. */ +export default function CustomerNotificationsPage() { + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/support/tickets/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/support/tickets/[id]/page.tsx new file mode 100644 index 0000000..a4ff7d8 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/support/tickets/[id]/page.tsx @@ -0,0 +1,11 @@ +'use client'; +import { useParams } from 'next/navigation'; +import { TicketThreadScreen } from '@/components/messaging'; + +/** /support/tickets/[id] — the customer ticket thread (f14). */ +export default function CustomerTicketThreadPage() { + const params = useParams<{ id: string }>(); + const id = Number(params.id); + const ticketId = Number.isInteger(id) && id > 0 ? id : -1; + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/support/tickets/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/support/tickets/page.tsx new file mode 100644 index 0000000..3f8c6f0 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/support/tickets/page.tsx @@ -0,0 +1,7 @@ +'use client'; +import { TicketInboxScreen } from '@/components/messaging'; + +/** /support/tickets — the customer "My Tickets" inbox (f14). Shares the screen with the nurse shell. */ +export default function CustomerTicketsPage() { + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/notifications/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/notifications/page.tsx new file mode 100644 index 0000000..2f0fc2d --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/notifications/page.tsx @@ -0,0 +1,7 @@ +'use client'; +import { NotificationCenter } from '@/components/notifications'; + +/** /nurse/notifications — the nurse notification center (f14). The bell deep-links here. */ +export default function NurseNotificationsPage() { + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/support/tickets/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/support/tickets/[id]/page.tsx new file mode 100644 index 0000000..3f8f4ec --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/support/tickets/[id]/page.tsx @@ -0,0 +1,11 @@ +'use client'; +import { useParams } from 'next/navigation'; +import { TicketThreadScreen } from '@/components/messaging'; + +/** /nurse/support/tickets/[id] — the nurse ticket thread (f14). */ +export default function NurseTicketThreadPage() { + const params = useParams<{ id: string }>(); + const id = Number(params.id); + const ticketId = Number.isInteger(id) && id > 0 ? id : -1; + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/support/tickets/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/support/tickets/page.tsx new file mode 100644 index 0000000..4be1e0c --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/support/tickets/page.tsx @@ -0,0 +1,7 @@ +'use client'; +import { TicketInboxScreen } from '@/components/messaging'; + +/** /nurse/support/tickets — the nurse "My Tickets" inbox (f14). Same screen as the customer, nurse chrome. */ +export default function NurseTicketsPage() { + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx index b36b947..45e805e 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx @@ -2,6 +2,7 @@ import { useParams } from 'next/navigation'; import { Stack } from '@mui/material'; import { BookingDetailView } from '@/components/booking'; +import { BookingSupportEntry } from '@/components/messaging'; import NurseVisitNotesPanel from './NurseVisitNotesPanel'; /** @@ -19,6 +20,7 @@ export default function NurseBookingDetailPage() { return ( + {bookingId > 0 && } {bookingId > 0 && } ); diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 6470221..3f0bafd 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -80,6 +80,9 @@ import RoutineIcon from '@mui/icons-material/EventRepeatOutlined'; import TasksIcon from '@mui/icons-material/ChecklistOutlined'; import HistoryIcon from '@mui/icons-material/HistoryOutlined'; import FamilyIcon from '@mui/icons-material/FamilyRestroomOutlined'; +// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action +import SupportIcon from '@mui/icons-material/SupportAgentOutlined'; +import SendIcon from '@mui/icons-material/SendOutlined'; /** * List of all available Icon names @@ -167,4 +170,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - tasks: TasksIcon, history: HistoryIcon, family: FamilyIcon, + support: SupportIcon, + send: SendIcon, }; diff --git a/client/src/components/messaging/BookingSupportEntry.tsx b/client/src/components/messaging/BookingSupportEntry.tsx new file mode 100644 index 0000000..19ea44d --- /dev/null +++ b/client/src/components/messaging/BookingSupportEntry.tsx @@ -0,0 +1,71 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +import Stack from '@mui/material/Stack'; +import { useTranslations } from 'next-intl'; +import AppButton from '@/components/common/AppButton'; +import { useBookingDetail, useCareInstructions } from '@/services/bookings'; +import { isBookingConfirmedOrBeyond } from '@/services/bookings/types'; +import ContactSupportDialog from './ContactSupportDialog'; +import EmergencyBanner from './EmergencyBanner'; + +export interface BookingSupportEntryProps { + bookingId: number; + role: 'customer' | 'nurse'; +} + +/** + * The support / emergency entry that hangs off the f8 booking-detail screen (page-local glue, mounted below + * `BookingDetailView`). It **reuses the cached booking query** (same key/viewer as the view — no refetch) and, + * for the nurse on a **post-confirmation** booking, the cached care-instructions read to surface the emergency + * banner's `tel:` contact. It never fetches the booking again and never decrypts client-side. + * + * - **Nurse, confirmed+**: the emergency banner (playbook + `tel:` to the care emergency contact) **and** a + * "Get support / Open ticket" CTA. Pre-confirmation → nothing (the care read is gated off). + * - **Customer**: the "Get support / Open ticket" CTA only (the customer is not entitled to the clinical + * emergency contact — it stays in the nurse-gated care read). + * + * The CTA opens a ticket pre-linked to this booking (`coordination`); the mock's coordination idempotency + * **jumps to the existing coordination thread** rather than duplicating. + * @component BookingSupportEntry + */ +const BookingSupportEntry: FunctionComponent = ({ bookingId, role }) => { + const t = useTranslations('tickets'); + const [dialogOpen, setDialogOpen] = useState(false); + + const { data: booking } = useBookingDetail(bookingId, role); + const confirmed = !!booking && isBookingConfirmedOrBeyond(booking.status); + const careEnabled = role === 'nurse' && confirmed; + const { data: care } = useCareInstructions(bookingId, { enabled: careEnabled }); + + if (!booking) return null; + + return ( + + {role === 'nurse' && confirmed ? ( + setDialogOpen(true)} + /> + ) : null} + setDialogOpen(true)} + sx={{ m: 0 }} + > + {t('open_from_booking')} + + setDialogOpen(false)} + role={role} + bookingId={bookingId} + defaultCategory="coordination" + /> + + ); +}; + +export default BookingSupportEntry; diff --git a/client/src/components/messaging/ContactSupportDialog.test.tsx b/client/src/components/messaging/ContactSupportDialog.test.tsx new file mode 100644 index 0000000..9a08edb --- /dev/null +++ b/client/src/components/messaging/ContactSupportDialog.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +const mockMutate = jest.fn(); +const mockReset = jest.fn(); + +jest.mock('@/services/tickets', () => ({ + useOpenTicket: () => ({ mutate: mockMutate, isPending: false, isError: false, reset: mockReset }), +})); + +jest.mock('next/navigation', () => ({ + ...jest.requireActual('next/navigation'), + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock('next-intl', () => ({ + useLocale: () => 'fa', + useTranslations: (namespace: string) => (key: string) => { + const messages = jest.requireActual('../../../messages/fa.json') as Record>; + return messages[namespace]?.[key] ?? key; + }, +})); + +import { ThemeProvider } from '../../theme'; +import ContactSupportDialog from './ContactSupportDialog'; + +function renderDialog() { + render( + + {}} role="customer" defaultCategory="support" /> + , + ); +} + +describe(' component', () => { + beforeEach(() => { + mockMutate.mockClear(); + }); + + it('opens a ticket with the typed message on submit', () => { + renderDialog(); + fireEvent.change(screen.getByLabelText(/پیام/), { target: { value: 'سوال من درباره ویزیت' } }); + fireEvent.click(screen.getByRole('button', { name: 'ارسال' })); + expect(mockMutate).toHaveBeenCalledTimes(1); + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ category: 'support', body: 'سوال من درباره ویزیت' }), + expect.anything(), + ); + }); + + it('disables submit until a message is typed', () => { + renderDialog(); + expect(screen.getByRole('button', { name: 'ارسال' })).toBeDisabled(); + }); +}); diff --git a/client/src/components/messaging/ContactSupportDialog.tsx b/client/src/components/messaging/ContactSupportDialog.tsx new file mode 100644 index 0000000..3a794fc --- /dev/null +++ b/client/src/components/messaging/ContactSupportDialog.tsx @@ -0,0 +1,180 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import AppButton from '@/components/common/AppButton'; +import { AppIcon } from '@/components/common'; +import { ticketThreadPath } from '@/constants'; +import { useOpenTicket } from '@/services/tickets'; +import type { OpenTicketResult, TicketCategory } from '@/services/tickets/types'; + +export interface ContactSupportDialogProps { + open: boolean; + onClose: () => void; + /** Which shell to route the new thread into on success. */ + role: 'customer' | 'nurse'; + /** Pre-link the ticket to a booking (from the booking-detail entry point). */ + bookingId?: number | null; + /** Initial category — `coordination` from a booking, else `support`. */ + defaultCategory?: TicketCategory; +} + +/** Categories a user may open directly (refund is staff-linked; emergency uses its own flow). */ +const OPENABLE_CATEGORIES: TicketCategory[] = ['support', 'coordination']; + +/** + * The "Contact support" flow — open a new ticket (category select → subject/message → submit) and, on + * success, show the new **`referenceCode`** with a link to the thread. Used from the inbox and the + * booking-detail entry point (pre-linked to a `bookingId`; the mock's coordination idempotency **jumps to the + * existing coordination thread** rather than duplicating). Domain 4xx keep the draft (never cleared on + * failure). On success the inbox is invalidated, so the new ticket appears at the top without a manual refresh. + * @component ContactSupportDialog + */ +const ContactSupportDialog: FunctionComponent = ({ + open, + onClose, + role, + bookingId, + defaultCategory = 'support', +}) => { + const t = useTranslations('tickets'); + const locale = useLocale(); + const router = useRouter(); + const openTicket = useOpenTicket(); + + const [category, setCategory] = useState(defaultCategory); + const [subject, setSubject] = useState(''); + const [body, setBody] = useState(''); + const [created, setCreated] = useState(null); + + const reset = () => { + setSubject(''); + setBody(''); + setCreated(null); + setCategory(defaultCategory); + openTicket.reset(); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const submit = () => { + const trimmed = body.trim(); + if (!trimmed || openTicket.isPending) return; + openTicket.mutate( + { category, subject: subject.trim() || null, body: trimmed, bookingId: bookingId ?? null }, + { onSuccess: (result) => setCreated(result) }, + ); + }; + + const goToThread = () => { + if (!created) return; + router.push(`/${locale}${ticketThreadPath(role, created.ticketId)}`); + handleClose(); + }; + + return ( + + {created ? ( + <> + + + {t('created_title')} + + + + + {t('created_body')} + + + + {t('ref_code_label')} + + + {created.referenceCode} + + + + + + + {t('close')} + + + {t('view_thread')} + + + + ) : ( + <> + {t('new_ticket_title')} + + + setCategory(event.target.value as TicketCategory)} + fullWidth + size="small" + > + {OPENABLE_CATEGORIES.map((code) => ( + + {t(`category_${code}`)} + + ))} + + setSubject(event.target.value)} + fullWidth + size="small" + /> + setBody(event.target.value)} + fullWidth + required + multiline + minRows={3} + size="small" + /> + {openTicket.isError ? ( + + {t('open_failed')} + + ) : null} + + + + + {t('cancel')} + + + {openTicket.isPending ? t('submitting') : t('submit')} + + + + )} + + ); +}; + +export default ContactSupportDialog; diff --git a/client/src/components/messaging/EmergencyBanner.test.tsx b/client/src/components/messaging/EmergencyBanner.test.tsx new file mode 100644 index 0000000..d24d8e5 --- /dev/null +++ b/client/src/components/messaging/EmergencyBanner.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +// next-intl mocked to read the REAL fa message file so the emergency playbook copy is pinned as it ships. +jest.mock('next-intl', () => ({ + useTranslations: (namespace: string) => (key: string, params?: Record) => { + const messages = jest.requireActual('../../../messages/fa.json') as Record>; + let value = messages[namespace]?.[key] ?? key; + if (params) for (const [k, v] of Object.entries(params)) value = value.replace(`{${k}}`, String(v)); + return value; + }, +})); + +import { ThemeProvider } from '../../theme'; +import EmergencyBanner from './EmergencyBanner'; + +describe(' component', () => { + it('surfaces a tel: click-to-call when an emergency contact phone is provided', () => { + render( + + {}} /> + , + ); + expect(screen.getByTestId('emergency-banner')).toBeInTheDocument(); + expect(screen.getByTestId('emergency-call')).toHaveAttribute('href', 'tel:09121234567'); + }); + + it('degrades to the open-ticket path (no tel: link) when the contact cannot be loaded', () => { + render( + + {}} /> + , + ); + expect(screen.getByTestId('emergency-banner')).toBeInTheDocument(); + expect(screen.queryByTestId('emergency-call')).not.toBeInTheDocument(); + }); + + it('invokes onOpenTicket from the "open a ticket" action', () => { + const onOpenTicket = jest.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole('button')); + expect(onOpenTicket).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/messaging/EmergencyBanner.tsx b/client/src/components/messaging/EmergencyBanner.tsx new file mode 100644 index 0000000..4583de9 --- /dev/null +++ b/client/src/components/messaging/EmergencyBanner.tsx @@ -0,0 +1,90 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Button from '@mui/material/Button'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { useTranslations } from 'next-intl'; +import { AppIcon } from '@/components/common'; + +export interface EmergencyBannerProps { + /** The emergency-contact name (from the booking's post-confirmation care instructions), if loaded. */ + contactName?: string | null; + /** The emergency-contact phone — the **only** sanctioned click-to-call surface; omit if not available. */ + contactPhone?: string | null; + /** Opens the support-ticket flow ("…then open a ticket"). */ + onOpenTicket: () => void; +} + +/** + * The emergency **operational playbook** — not a real-time feature. "For emergencies, call the emergency + * contact, then open a ticket." When a `contactPhone` is available (the nurse's post-confirmation care read), + * it surfaces a `tel:` **click-to-call** — the platform's only sanctioned out-of-band surface; the platform + * **never** exposes a general phone number or a contact directory. When the contact can't be loaded (e.g. the + * customer/support-entry side, which is not entitled to the clinical read), the banner still renders with a + * path to open a support ticket. `tel:` only — no VoIP/calling seam (telephony is out-of-platform by design). + * The caller decides when to render it (post-confirmation only; nothing pre-confirmation). + * @component EmergencyBanner + */ +const EmergencyBanner: FunctionComponent = ({ contactName, contactPhone, onOpenTicket }) => { + const t = useTranslations('tickets'); + const phone = contactPhone?.trim() || null; + const name = contactName?.trim() || null; + + return ( + + + + + + {t('emergency_title')} + + + {t('emergency_body')} + + + + {phone ? ( + + ) : null} + + + + + + ); +}; + +export default EmergencyBanner; diff --git a/client/src/components/messaging/MessageBubble.test.tsx b/client/src/components/messaging/MessageBubble.test.tsx new file mode 100644 index 0000000..d8203f3 --- /dev/null +++ b/client/src/components/messaging/MessageBubble.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import MessageBubble from './MessageBubble'; +import type { TicketMessage } from '@/services/tickets/types'; + +const base: TicketMessage = { + id: 1, + ticketId: 10, + body: 'سلام، ساعت ویزیت را تغییر دهید', + authorRole: 'admin', + createdAt: '2026-07-01T10:00:00Z', + isMine: false, + sendStatus: 'sent', +}; + +function renderBubble(overrides: Partial) { + return render( + + + , + ); +} + +describe(' component', () => { + it("aligns others' messages to the start and shows the author label + time", () => { + renderBubble({ isMine: false }); + expect(screen.getByTestId('message-bubble')).toHaveAttribute('data-mine', 'false'); + expect(screen.getByText('پشتیبانی')).toBeInTheDocument(); + expect(screen.getByText('سلام، ساعت ویزیت را تغییر دهید')).toBeInTheDocument(); + expect(screen.getByText('۱۰:۰۰')).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'); + expect(screen.queryByText('پشتیبانی')).not.toBeInTheDocument(); + }); + + it('shows the sending label (not the time) while a message is pending', () => { + renderBubble({ isMine: true, id: null, clientMessageId: 'c1', sendStatus: 'sending' }); + expect(screen.getByText('در حال ارسال…')).toBeInTheDocument(); + expect(screen.queryByText('۱۰:۰۰')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/messaging/MessageBubble.tsx b/client/src/components/messaging/MessageBubble.tsx new file mode 100644 index 0000000..bd7f476 --- /dev/null +++ b/client/src/components/messaging/MessageBubble.tsx @@ -0,0 +1,80 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +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). */ + timeLabel: string; + /** "در حال ارسال…" — shown while an optimistic message is still sending (in place of the time). */ + sendingLabel: string; +} + +/** + * 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). + * @component MessageBubble + */ +const MessageBubble: FunctionComponent = ({ message, authorLabel, timeLabel, sendingLabel }) => { + const isMine = message.isMine; + const sending = message.sendStatus === 'sending'; + + return ( + + + {!isMine ? ( + + {authorLabel} + + ) : null} + + + {message.body} + + + + {sending ? sendingLabel : timeLabel} + + + + ); +}; + +export default MessageBubble; diff --git a/client/src/components/messaging/MessageComposer.tsx b/client/src/components/messaging/MessageComposer.tsx new file mode 100644 index 0000000..627b07c --- /dev/null +++ b/client/src/components/messaging/MessageComposer.tsx @@ -0,0 +1,94 @@ +'use client'; +import { FunctionComponent, KeyboardEvent, useState } 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 { useTranslations } from 'next-intl'; +import { AppIcon } from '@/components/common'; +import { usePostMessage } from '@/services/tickets'; + +export interface MessageComposerProps { + ticketId: number; + /** 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. + * @component MessageComposer + */ +const MessageComposer: FunctionComponent = ({ ticketId, 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 onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + submit(); + } + }; + + return ( + + {postMessage.isError ? ( + + {t('send_failed')} + + ) : null} + + setDraft(event.target.value)} + onKeyDown={onKeyDown} + disabled={disabled} + /> + + {sending ? ( + + ) : ( + + )} + + + + ); +}; + +export default MessageComposer; diff --git a/client/src/components/messaging/TicketInboxScreen.tsx b/client/src/components/messaging/TicketInboxScreen.tsx new file mode 100644 index 0000000..007e031 --- /dev/null +++ b/client/src/components/messaging/TicketInboxScreen.tsx @@ -0,0 +1,107 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +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 { useRouter } from 'next/navigation'; +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 ContactSupportDialog from './ContactSupportDialog'; +import EmergencyBanner from './EmergencyBanner'; +import TicketListCard from './TicketListCard'; + +export interface TicketInboxScreenProps { + role: 'customer' | 'nurse'; +} + +/** + * 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). + * @component TicketInboxScreen + */ +const TicketInboxScreen: FunctionComponent = ({ role }) => { + const t = useTranslations('tickets'); + const locale = useLocale(); + const router = useRouter(); + const [dialogOpen, setDialogOpen] = useState(false); + + const { data, isLoading, isError, refetch } = useMyTickets({}); + const tickets = data?.items ?? []; + + const openThread = (ticketId: number) => router.push(`/${locale}${ticketThreadPath(role, ticketId)}`); + + return ( + + + + {t('title')} + + setDialogOpen(true)} + sx={{ m: 0 }} + > + {t('contact_support')} + + + + setDialogOpen(true)} /> + + {isLoading ? ( + + {[0, 1, 2].map((i) => ( + + ))} + + ) : isError ? ( + + + + {t('error_body')} + + refetch()}> + {t('retry')} + + + ) : tickets.length === 0 ? ( + + + + {t('empty_title')} + + + {t('empty_body')} + + + ) : ( + + {tickets.map((ticket) => ( + openThread(ticket.id)} + /> + ))} + + )} + + setDialogOpen(false)} role={role} defaultCategory="support" /> + + ); +}; + +export default TicketInboxScreen; diff --git a/client/src/components/messaging/TicketListCard.test.tsx b/client/src/components/messaging/TicketListCard.test.tsx new file mode 100644 index 0000000..3d3141c --- /dev/null +++ b/client/src/components/messaging/TicketListCard.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import TicketListCard from './TicketListCard'; +import type { TicketSummary } from '@/services/tickets/types'; + +const base: TicketSummary = { + id: 12, + referenceCode: 'TKT-9F3K2A7Q', + subject: 'هماهنگی ویزیت', + status: 'open', + category: 'coordination', + bookingId: 5001, + refundId: null, + createdAt: '2026-07-01T10:00:00Z', + lastMessageAt: '2026-07-01T12:00:00Z', + unreadCount: 2, +}; + +function renderCard(overrides: Partial, extra: Partial> = {}) { + const onOpen = jest.fn(); + render( + + + , + ); + return onOpen; +} + +describe(' component', () => { + it('shows the reference code prominently', () => { + renderCard({}); + expect(screen.getByText('TKT-9F3K2A7Q')).toBeInTheDocument(); + }); + + it('shows the unread indicator with the count when there is unread activity', () => { + renderCard({ unreadCount: 2 }); + expect(screen.getByTestId('ticket-unread')).toHaveTextContent('2'); + }); + + it('hides the unread indicator when nothing is unread', () => { + renderCard({ unreadCount: 0 }); + expect(screen.queryByTestId('ticket-unread')).not.toBeInTheDocument(); + }); + + it('renders the linked-booking hint only when the ticket is booking-linked', () => { + renderCard({}); + expect(screen.getByText('رزرو #۵۰۰۱')).toBeInTheDocument(); + }); + + it('renders no linked hint for a pure support ticket', () => { + renderCard({ bookingId: null, refundId: null }); + expect(screen.queryByText('رزرو #۵۰۰۱')).not.toBeInTheDocument(); + }); + + it('calls onOpen when clicked', () => { + const onOpen = renderCard({}); + fireEvent.click(screen.getByTestId('ticket-card')); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/messaging/TicketListCard.tsx b/client/src/components/messaging/TicketListCard.tsx new file mode 100644 index 0000000..8c0af65 --- /dev/null +++ b/client/src/components/messaging/TicketListCard.tsx @@ -0,0 +1,118 @@ +'use client'; +import { FunctionComponent } 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 StatusChip from '@/components/StatusChip'; +import { AppIcon } from '@/components/common'; +import type { TicketSummary } from '@/services/tickets/types'; +import { ticketCategoryIcon, ticketStatusKind } from './statusKind'; + +export interface TicketListCardProps { + ticket: TicketSummary; + /** Translated category label (also the subject fallback when `subject` is null). */ + categoryLabel: string; + /** Translated status label. */ + statusLabel: string; + /** Pre-formatted last-activity time — the caller owns locale. */ + timeLabel: string; + /** e.g. "رزرو #۵۰۰۱" — rendered only when the ticket is booking-linked (null-safe). */ + linkedBookingLabel?: string | null; + /** e.g. "بازپرداخت #۹۰۰۱" — rendered only when refund-linked (null-safe). */ + linkedRefundLabel?: string | null; + onOpen: () => void; +} + +/** + * 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`. + * @component TicketListCard + */ +const TicketListCard: FunctionComponent = ({ + ticket, + categoryLabel, + statusLabel, + timeLabel, + linkedBookingLabel, + linkedRefundLabel, + onOpen, +}) => { + const hasUnread = (ticket.unreadCount ?? 0) > 0; + const subject = ticket.subject?.trim() || categoryLabel; + + return ( + + + + + + {subject} + + {hasUnread ? ( + + {ticket.unreadCount} + + ) : null} + + + + + {ticket.referenceCode} + + + + {linkedBookingLabel ? ( + + {linkedBookingLabel} + + ) : null} + {linkedRefundLabel ? ( + + {linkedRefundLabel} + + ) : null} + + {timeLabel} + + + + + ); +}; + +export default TicketListCard; diff --git a/client/src/components/messaging/TicketMessageList.tsx b/client/src/components/messaging/TicketMessageList.tsx new file mode 100644 index 0000000..1cc4d16 --- /dev/null +++ b/client/src/components/messaging/TicketMessageList.tsx @@ -0,0 +1,66 @@ +'use client'; +import { FunctionComponent } from 'react'; +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 { useTicketThread } from '@/services/tickets'; +import { formatShamsiDateTime } from '@/utils'; +import MessageBubble from './MessageBubble'; +import { authorLabelKey } from './authorLabel'; + +export interface TicketMessageListProps { + ticketId: number; +} + +/** + * 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). + * @component TicketMessageList + */ +const TicketMessageList: FunctionComponent = ({ ticketId }) => { + const t = useTranslations('tickets'); + const locale = useLocale(); + const { data: messages, isLoading } = useTicketThread(ticketId); + + if (isLoading) { + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ); + } + + if (!messages || messages.length === 0) { + return ( + + + {t('thread_empty_title')} + + + {t('thread_empty_body')} + + + ); + } + + return ( + + {messages.map((message) => ( + + ))} + + ); +}; + +export default TicketMessageList; diff --git a/client/src/components/messaging/TicketThreadScreen.tsx b/client/src/components/messaging/TicketThreadScreen.tsx new file mode 100644 index 0000000..2051fa3 --- /dev/null +++ b/client/src/components/messaging/TicketThreadScreen.tsx @@ -0,0 +1,128 @@ +'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'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import AppButton from '@/components/common/AppButton'; +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 { ticketCategoryIcon, ticketStatusKind } from './statusKind'; + +export interface TicketThreadScreenProps { + role: 'customer' | 'nurse'; + ticketId: number; +} + +/** + * A ticket thread — the role-aware bubble stream + a sticky composer. The header shows the **`referenceCode` + * prominently**, the category, the status chip, and (when present) a linked-booking chip. Closed tickets show + * a notice instead of the composer. **No internal-note affordance anywhere** (§5). Shared by the customer and + * nurse thread pages (role decides the back/booking route + composer entitlement, not the components). + * @component TicketThreadScreen + */ +const TicketThreadScreen: FunctionComponent = ({ role, ticketId }) => { + const t = useTranslations('tickets'); + const locale = useLocale(); + const router = useRouter(); + const { data: ticket, isLoading, isError, refetch } = useTicket(ticketId); + + const goBack = () => router.push(`/${locale}${ticketsBasePath(role)}`); + const goToBooking = (bookingId: number) => { + const path = role === 'nurse' ? nurseBookingDetailPath(bookingId) : `${ROUTES.BOOKINGS}/${bookingId}`; + router.push(`/${locale}${path}`); + }; + + return ( + + + {t('back_to_tickets')} + + + {isLoading ? ( + + + + + + ) : isError || !ticket ? ( + + + + {t('thread_error')} + + refetch()}> + {t('retry')} + + + ) : ( + <> + + + + + + {ticket.subject?.trim() || t(`category_${ticket.category}`)} + + + + + + {t('ref_code_label')} + + + {ticket.referenceCode} + + {ticket.bookingId != null ? ( + } + label={t('linked_booking', { id: ticket.bookingId })} + onClick={() => goToBooking(ticket.bookingId as number)} + sx={{ marginInlineStart: 'auto' }} + /> + ) : null} + + + + + + + + {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. + + )} + + + )} + + ); +}; + +export default TicketThreadScreen; diff --git a/client/src/components/messaging/authorLabel.ts b/client/src/components/messaging/authorLabel.ts new file mode 100644 index 0000000..69fb405 --- /dev/null +++ b/client/src/components/messaging/authorLabel.ts @@ -0,0 +1,16 @@ +import type { TicketAuthorRole } from '@/services/tickets/types'; + +/** The i18n key (in the `tickets` namespace) for a message author's display label. `admin` reads as "support". */ +export function authorLabelKey(role: TicketAuthorRole): string { + switch (role) { + case 'nurse': + return 'author_nurse'; + case 'admin': + return 'author_support'; + case 'customer': + return 'author_customer'; + case 'system': + default: + return 'author_system'; + } +} diff --git a/client/src/components/messaging/index.ts b/client/src/components/messaging/index.ts new file mode 100644 index 0000000..48d560a --- /dev/null +++ b/client/src/components/messaging/index.ts @@ -0,0 +1,19 @@ +/** + * Messaging (tickets) composites — import from `@/components/messaging` (a subfolder barrel, like + * `@/components/booking`). Screens (`TicketInboxScreen`/`TicketThreadScreen`) are shared by the customer and + * nurse route pages; the pure composites (`MessageBubble`/`TicketListCard`/`EmergencyBanner`) carry co-located + * tests. + */ +export { default as MessageBubble } from './MessageBubble'; +export type { MessageBubbleProps } from './MessageBubble'; +export { default as MessageComposer } from './MessageComposer'; +export { default as TicketMessageList } from './TicketMessageList'; +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 ContactSupportDialog } from './ContactSupportDialog'; +export type { ContactSupportDialogProps } from './ContactSupportDialog'; +export { default as TicketInboxScreen } from './TicketInboxScreen'; +export { default as TicketThreadScreen } from './TicketThreadScreen'; +export { default as BookingSupportEntry } from './BookingSupportEntry'; diff --git a/client/src/components/messaging/statusKind.ts b/client/src/components/messaging/statusKind.ts new file mode 100644 index 0000000..d6f152b --- /dev/null +++ b/client/src/components/messaging/statusKind.ts @@ -0,0 +1,22 @@ +import type { StatusKind } from '@/components/StatusChip'; +import type { TicketCategory, TicketStatus } from '@/services/tickets/types'; + +/** Ticket status → the semantic `StatusChip` kind. An open ticket reads as active/info, a closed one neutral. */ +export function ticketStatusKind(status: TicketStatus): StatusKind { + return status === 'open' ? 'info' : 'neutral'; +} + +/** The `AppIcon` name for a ticket category (inbox card + thread header). */ +export function ticketCategoryIcon(category: TicketCategory): string { + switch (category) { + case 'coordination': + return 'bookings'; + case 'refund': + return 'payment'; + case 'emergency': + return 'emergency'; + case 'support': + default: + return 'support'; + } +} diff --git a/client/src/components/notifications/NotificationBell.tsx b/client/src/components/notifications/NotificationBell.tsx new file mode 100644 index 0000000..cfcab03 --- /dev/null +++ b/client/src/components/notifications/NotificationBell.tsx @@ -0,0 +1,36 @@ +'use client'; +import { FunctionComponent } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { notificationsPath } from '@/constants'; +import { useUnreadCount } from '@/services/notifications'; +import NotificationBellView from './NotificationBellView'; + +export interface NotificationBellProps { + /** The shell the bell lives in — decides which notification center it opens. */ + role: 'customer' | 'nurse'; +} + +/** + * 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. + * @component NotificationBell + */ +const NotificationBell: FunctionComponent = ({ role }) => { + const count = useUnreadCount(); + const router = useRouter(); + const locale = useLocale(); + const t = useTranslations('notifications'); + + return ( + router.push(`/${locale}${notificationsPath(role)}`)} + /> + ); +}; + +export default NotificationBell; diff --git a/client/src/components/notifications/NotificationBellView.test.tsx b/client/src/components/notifications/NotificationBellView.test.tsx new file mode 100644 index 0000000..ccf3f44 --- /dev/null +++ b/client/src/components/notifications/NotificationBellView.test.tsx @@ -0,0 +1,28 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import NotificationBellView from './NotificationBellView'; + +describe(' component', () => { + it('renders the unread count + aria label and fires onClick', () => { + const onClick = jest.fn(); + render( + + + , + ); + const bell = screen.getByTestId('notification-bell'); + expect(bell).toHaveAttribute('aria-label', '۳ اعلان خوانده‌نشده'); + expect(screen.getByText('3')).toBeInTheDocument(); + fireEvent.click(bell); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('hides the badge when there are no unread notifications', () => { + render( + + {}} /> + , + ); + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/notifications/NotificationBellView.tsx b/client/src/components/notifications/NotificationBellView.tsx new file mode 100644 index 0000000..10a9e8f --- /dev/null +++ b/client/src/components/notifications/NotificationBellView.tsx @@ -0,0 +1,36 @@ +'use client'; +import { FunctionComponent } from 'react'; +import Badge from '@mui/material/Badge'; +import IconButton from '@mui/material/IconButton'; +import { AppIcon } from '@/components/common'; + +export interface NotificationBellViewProps { + /** Unread count for the badge (0 hides the badge). */ + count: number; + /** Accessible label (already translated, includes the count). */ + label: string; + onClick: () => void; +} + +/** + * 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). + * @component NotificationBellView + */ +const NotificationBellView: FunctionComponent = ({ count, label, onClick }) => { + return ( + + + + + + ); +}; + +export default NotificationBellView; diff --git a/client/src/components/notifications/NotificationCenter.tsx b/client/src/components/notifications/NotificationCenter.tsx new file mode 100644 index 0000000..7fb19f6 --- /dev/null +++ b/client/src/components/notifications/NotificationCenter.tsx @@ -0,0 +1,124 @@ +'use client'; +import { FunctionComponent, useState } from 'react'; +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 { useRouter } from 'next/navigation'; +import AppButton from '@/components/common/AppButton'; +import { AppIcon } from '@/components/common'; +import { NOTIFICATIONS_PAGE_SIZE } from '@/services/notifications/constants'; +import { + notificationDeepLink, + useMarkAllRead, + useMarkNotificationRead, + useNotifications, +} from '@/services/notifications'; +import type { AppNotification } from '@/services/notifications/types'; +import { formatShamsiDateTime } from '@/utils'; +import NotificationRow from './NotificationRow'; + +export interface NotificationCenterProps { + role: 'customer' | 'nurse'; +} + +/** + * 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). + * @component NotificationCenter + */ +const NotificationCenter: FunctionComponent = ({ role }) => { + const t = useTranslations('notifications'); + const locale = useLocale(); + const router = useRouter(); + const [limit, setLimit] = useState(NOTIFICATIONS_PAGE_SIZE); + + const { data, isLoading, isError, refetch, isFetching } = useNotifications(limit); + const markRead = useMarkNotificationRead(); + const markAll = useMarkAllRead(); + + const items = data?.items ?? []; + const total = data?.total ?? 0; + 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}`); + }; + + return ( + + + + {t('title')} + + {hasUnread ? ( + markAll.mutate()} + disabled={markAll.isPending} + sx={{ m: 0 }} + > + {t('mark_all_read')} + + ) : null} + + + {isLoading ? ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + ) : isError ? ( + + + + {t('error_body')} + + refetch()}> + {t('retry')} + + + ) : items.length === 0 ? ( + + + + {t('empty_title')} + + + {t('empty_body')} + + + ) : ( + + {items.map((notification) => ( + openNotification(notification)} + /> + ))} + {total > items.length ? ( + setLimit((current) => current + NOTIFICATIONS_PAGE_SIZE)} + disabled={isFetching} + sx={{ alignSelf: 'center' }} + > + {t('load_more')} + + ) : null} + + )} + + ); +}; + +export default NotificationCenter; diff --git a/client/src/components/notifications/NotificationRow.test.tsx b/client/src/components/notifications/NotificationRow.test.tsx new file mode 100644 index 0000000..1176f24 --- /dev/null +++ b/client/src/components/notifications/NotificationRow.test.tsx @@ -0,0 +1,50 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import NotificationRow from './NotificationRow'; +import type { AppNotification } from '@/services/notifications/types'; + +const base: AppNotification = { + id: 1, + type: 'booking_confirmed', + title: 'رزرو شما تایید شد', + body: 'ویزیت شما ثبت شد', + isRead: false, + createdAt: '2026-07-01T10:00:00Z', + data: { kind: 'booking', bookingId: 5001 }, +}; + +function renderRow(overrides: Partial) { + const onOpen = jest.fn(); + render( + + + , + ); + return onOpen; +} + +describe(' component', () => { + it('emphasises an unread notification with a dot', () => { + renderRow({ isRead: false }); + expect(screen.getByTestId('notification-row')).toHaveAttribute('data-unread', 'true'); + expect(screen.getByTestId('notification-unread-dot')).toBeInTheDocument(); + }); + + it('shows no unread dot once read', () => { + renderRow({ isRead: true }); + expect(screen.getByTestId('notification-row')).toHaveAttribute('data-unread', 'false'); + expect(screen.queryByTestId('notification-unread-dot')).not.toBeInTheDocument(); + }); + + it('renders the server-provided title and body', () => { + renderRow({}); + expect(screen.getByText('رزرو شما تایید شد')).toBeInTheDocument(); + expect(screen.getByText('ویزیت شما ثبت شد')).toBeInTheDocument(); + }); + + it('calls onOpen when clicked', () => { + const onOpen = renderRow({}); + fireEvent.click(screen.getByTestId('notification-row')); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/notifications/NotificationRow.tsx b/client/src/components/notifications/NotificationRow.tsx new file mode 100644 index 0000000..0596000 --- /dev/null +++ b/client/src/components/notifications/NotificationRow.tsx @@ -0,0 +1,73 @@ +'use client'; +import { FunctionComponent } 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'; + +export interface NotificationRowProps { + notification: AppNotification; + /** Pre-formatted Shamsi time — the caller owns locale. */ + timeLabel: string; + /** Marks the notification read (optimistic) and, when it deep-links, 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). + * @component NotificationRow + */ +const NotificationRow: FunctionComponent = ({ notification, timeLabel, onOpen }) => { + const unread = !notification.isRead; + return ( + + + + + + {unread ? ( + + ) : null} + + {notification.title} + + + {notification.body ? ( + + {notification.body} + + ) : null} + + {timeLabel} + + + + + ); +}; + +export default NotificationRow; diff --git a/client/src/components/notifications/index.ts b/client/src/components/notifications/index.ts new file mode 100644 index 0000000..d12282c --- /dev/null +++ b/client/src/components/notifications/index.ts @@ -0,0 +1,13 @@ +/** + * Notification composites — import from `@/components/notifications`. `NotificationBell` is the chrome + * container (subscribes to the polling count); `NotificationCenter` is the shared page body; the pure + * `NotificationBellView`/`NotificationRow` carry co-located tests. + */ +export { default as NotificationBell } from './NotificationBell'; +export type { NotificationBellProps } from './NotificationBell'; +export { default as NotificationBellView } from './NotificationBellView'; +export type { NotificationBellViewProps } from './NotificationBellView'; +export { default as NotificationRow } from './NotificationRow'; +export type { NotificationRowProps } from './NotificationRow'; +export { default as NotificationCenter } from './NotificationCenter'; +export type { NotificationCenterProps } from './NotificationCenter'; diff --git a/client/src/components/notifications/notificationIcon.ts b/client/src/components/notifications/notificationIcon.ts new file mode 100644 index 0000000..744ddd1 --- /dev/null +++ b/client/src/components/notifications/notificationIcon.ts @@ -0,0 +1,20 @@ +import type { NotificationData } from '@/services/notifications/types'; + +/** The `AppIcon` name for a notification, by its parsed deep-link class. */ +export function notificationIcon(kind: NotificationData['kind']): string { + switch (kind) { + case 'booking': + return 'bookings'; + case 'refund': + return 'payment'; + case 'payout': + return 'earnings'; + case 'ticket': + return 'support'; + case 'nurse_profile': + return 'star'; + case 'none': + default: + return 'info'; + } +} diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index a970b0c..5c77b0d 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -39,6 +39,10 @@ export const ROUTES = { ADDRESSES: '/addresses', WALLET: '/wallet', PROFILE: '/profile', + // Support / "My Tickets" (f14) — the only sanctioned post-booking channel; append `/{id}` for a thread. + SUPPORT_TICKETS: '/support/tickets', + // In-app notification center (f14) — the polled bell deep-links here and per-notification. + NOTIFICATIONS: '/notifications', // Nurse app NURSE: '/nurse', @@ -60,6 +64,10 @@ export const ROUTES = { NURSE_EARNINGS: '/nurse/earnings', // Payout history list base — append `/{payoutId}` for the payout/batch reconciliation detail. NURSE_EARNINGS_PAYOUTS: '/nurse/earnings/payouts', + // Nurse support / "My Tickets" (f14) — same screens as the customer, under the nurse shell. + NURSE_SUPPORT_TICKETS: '/nurse/support/tickets', + // Nurse in-app notification center (f14). + NURSE_NOTIFICATIONS: '/nurse/notifications', // Admin / backoffice console ADMIN: '/admin', @@ -95,5 +103,25 @@ export const bookingReviewPath = (bookingId: number | string): string => export const patientRecordPath = (patientId: number | string): string => `${ROUTES.PATIENTS}/${patientId}/record`; +/** A ticket thread (f14) in the **customer** shell. */ +export const customerTicketThreadPath = (ticketId: number | string): string => + `${ROUTES.SUPPORT_TICKETS}/${ticketId}`; + +/** A ticket thread (f14) in the **nurse** shell (same screen, nurse chrome). */ +export const nurseTicketThreadPath = (ticketId: number | string): string => + `${ROUTES.NURSE_SUPPORT_TICKETS}/${ticketId}`; + +/** The tickets inbox base for an actor (customer vs nurse shell). */ +export const ticketsBasePath = (role: 'customer' | 'nurse'): string => + role === 'nurse' ? ROUTES.NURSE_SUPPORT_TICKETS : ROUTES.SUPPORT_TICKETS; + +/** A ticket thread for an actor (customer vs nurse shell). */ +export const ticketThreadPath = (role: 'customer' | 'nurse', ticketId: number | string): string => + role === 'nurse' ? nurseTicketThreadPath(ticketId) : customerTicketThreadPath(ticketId); + +/** The notification center for an actor (customer vs nurse shell). */ +export const notificationsPath = (role: 'customer' | 'nurse'): string => + role === 'nurse' ? ROUTES.NURSE_NOTIFICATIONS : ROUTES.NOTIFICATIONS; + /** Paths (without locale prefix) that bypass auth in middleware. */ export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN]; diff --git a/client/src/layout/CustomerLayout.tsx b/client/src/layout/CustomerLayout.tsx index 531a864..1b121db 100644 --- a/client/src/layout/CustomerLayout.tsx +++ b/client/src/layout/CustomerLayout.tsx @@ -1,8 +1,11 @@ 'use client'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; import { Box, Stack } from '@mui/material'; -import { useTranslations } from 'next-intl'; +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; import { ErrorBoundary } from '@/components'; +import AppIconButton from '@/components/common/AppIconButton'; +import { NotificationBell } from '@/components/notifications'; import { CONTENT_MAX_WIDTH } from '@/components/config'; import { ROUTES } from '@/constants'; import { LinkToPage } from '@/utils'; @@ -13,14 +16,17 @@ import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config'; /** * Customer (family) app shell — the primary, mobile-first experience. - * A slim TopBar, a scrollable content column constrained to reading width, and the - * 5-tab BottomBar (Home/Bookings/Patients/Wallet/Profile) from the wireframe. + * A slim TopBar (a "Support / My Tickets" action at the start, the notification bell + dark-mode toggle at + * the end), a scrollable content column constrained to reading width, and the 5-tab BottomBar + * (Home/Bookings/Patients/Wallet/Profile) from the wireframe. * @layout CustomerLayout */ const CustomerLayout: FunctionComponent = ({ children }) => { const t = useTranslations('nav'); const tShell = useTranslations('shell'); const onMobile = useIsMobile(); + const router = useRouter(); + const locale = useLocale(); const bottomNavItems: Array = useMemo( () => [ @@ -36,7 +42,23 @@ const CustomerLayout: FunctionComponent = ({ children }) => { return ( - } /> + router.push(`/${locale}${ROUTES.SUPPORT_TICKETS}`)} + /> + } + endNode={ + + + + + } + /> = ({ children }) => { { title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' }, { title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' }, { title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings' }, + { title: t('support'), path: ROUTES.NURSE_SUPPORT_TICKETS, icon: 'support' }, ], [t] ); @@ -35,6 +37,7 @@ const NurseLayout: FunctionComponent = ({ children }) => { sidebarItems={sidebarItems} title={tShell('nurse_app')} variant="sidebarPersistentOnDesktop" + headerActions={} > {children} diff --git a/client/src/layout/TopBarAndSideBarLayout.tsx b/client/src/layout/TopBarAndSideBarLayout.tsx index 9b5a212..e2606e8 100644 --- a/client/src/layout/TopBarAndSideBarLayout.tsx +++ b/client/src/layout/TopBarAndSideBarLayout.tsx @@ -1,5 +1,5 @@ 'use client'; -import { FunctionComponent, useMemo, useState } from 'react'; +import { FunctionComponent, ReactNode, useMemo, useState } from 'react'; import { Stack, StackProps } from '@mui/material'; import { AppIconButton, ErrorBoundary } from '@/components'; import { LinkToPage } from '@/utils'; @@ -19,9 +19,11 @@ interface Props extends StackProps { sidebarItems: Array; title: string; variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent'; + /** Extra chrome actions (e.g. the notification bell) rendered next to the dark-mode toggle. */ + headerActions?: ReactNode; } -const TopBarAndSideBarLayout: FunctionComponent = ({ children, sidebarItems, title, variant }) => { +const TopBarAndSideBarLayout: FunctionComponent = ({ children, sidebarItems, title, variant, headerActions }) => { const [sidebarVisible, setSidebarVisible] = useState(false); const onMobile = useIsMobile(); @@ -75,11 +77,18 @@ const TopBarAndSideBarLayout: FunctionComponent = ({ children, sidebarIte /* * DarkModeToggleButton is a self-contained component that subscribes to * useColorScheme() on its own. This layout component never reads the color - * scheme and therefore never re-renders when the theme switches. + * scheme and therefore never re-renders when the theme switches. The optional + * headerActions (e.g. the notification bell) travel alongside it. */ + const headerControls = ( + + {headerActions} + + + ); const { startNode, endNode } = sidebarProps?.anchor?.includes('left') - ? { startNode: LogoButton, endNode: } - : { startNode: , endNode: LogoButton }; + ? { startNode: LogoButton, endNode: headerControls } + : { startNode: headerControls, endNode: LogoButton }; return ( diff --git a/client/src/lib/query/QueryProvider.tsx b/client/src/lib/query/QueryProvider.tsx index 9590bc4..add48bf 100644 --- a/client/src/lib/query/QueryProvider.tsx +++ b/client/src/lib/query/QueryProvider.tsx @@ -11,7 +11,7 @@ export function QueryProvider({ children }: { children: ReactNode }) { return ( {children} - {process.env.NODE_ENV === 'development' && } + {/*{process.env.NODE_ENV === 'development' && }*/} ); } diff --git a/client/src/services/notifications/apis/clientApi.ts b/client/src/services/notifications/apis/clientApi.ts new file mode 100644 index 0000000..14aab8a --- /dev/null +++ b/client/src/services/notifications/apis/clientApi.ts @@ -0,0 +1,73 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types'; +import { NOTIFICATIONS_PAGE_SIZE } from '../constants'; +import { parseNotificationData } from '../parse'; +import type { AppNotification, NotificationsApi } from '../types'; + +const API = '/api/v1'; + +/** Wire `NotificationDto` (camelCase; `dataJson` is a JSON string parsed into the typed union). */ +interface NotificationWire { + id: number; + type: string; + title: string; + body: string | null; + dataJson: string | null; + isRead: boolean; + readAt: string | null; + createdAt: string; +} + +function mapNotification(w: NotificationWire): AppNotification { + return { + id: w.id, + type: w.type, + title: w.title, + body: w.body, + isRead: w.isRead, + createdAt: w.createdAt, + data: parseNotificationData(w.type, w.dataJson), + }; +} + +/** + * Real HTTP implementation of the `NotificationsApi` seam (b1 contract, `notifications` controller). All four + * methods map published routes: + * - `listNotifications` → `GET notifications/get_notifications` (unread-first, paginated). + * - `getUnreadCount` → `GET notifications/get_unread_count` (`{ count }`). + * - `markRead` → `POST notifications/mark_notification_read` (`{ notificationId }`). + * - `markAllRead` → `POST notifications/mark_all_read`. + * + * NOT the primary implementation this phase (`USE_NOTIFICATIONS_MOCK = true`) — nothing dispatches + * notifications client-side yet (see `constants.ts`). Pagination follows the repo convention (`pageSize`, + * REQ-010). + */ +export const notificationsClientApi: NotificationsApi = { + listNotifications: async (params: PageParams): Promise> => { + const query = new URLSearchParams(); + query.set('page', String(params.page ?? 1)); + query.set('pageSize', String(params.pageSize ?? NOTIFICATIONS_PAGE_SIZE)); + const wire = unwrap( + await clientFetch>>( + `${API}/notifications/get_notifications?${query.toString()}`, + ), + ); + return { ...wire, items: wire.items.map(mapNotification) }; + }, + + getUnreadCount: async (): Promise => { + const wire = unwrap(await clientFetch>(`${API}/notifications/get_unread_count`)); + return wire.count; + }, + + markRead: async (notificationId: number): Promise => { + await clientFetch>(`${API}/notifications/mark_notification_read`, { + method: 'POST', + body: JSON.stringify({ notificationId }), + }); + }, + + markAllRead: async (): Promise => { + await clientFetch>(`${API}/notifications/mark_all_read`, { method: 'POST' }); + }, +}; diff --git a/client/src/services/notifications/apis/index.ts b/client/src/services/notifications/apis/index.ts new file mode 100644 index 0000000..02e8f02 --- /dev/null +++ b/client/src/services/notifications/apis/index.ts @@ -0,0 +1,13 @@ +import { USE_NOTIFICATIONS_MOCK } from '../constants'; +import type { NotificationsApi } from '../types'; +import { notificationsClientApi } from './clientApi'; +import { notificationsMockApi } from './mockApi'; + +/** + * The selected `NotificationsApi` implementation — the single seam the hooks import. Selection is by config + * (`USE_NOTIFICATIONS_MOCK`), never scattered `if (mock)` checks. Mock-primary this phase (nothing dispatches + * notifications client-side yet); the swap is this one line. + */ +export const notificationsApi: NotificationsApi = USE_NOTIFICATIONS_MOCK + ? notificationsMockApi + : notificationsClientApi; diff --git a/client/src/services/notifications/apis/mockApi.ts b/client/src/services/notifications/apis/mockApi.ts new file mode 100644 index 0000000..6298e78 --- /dev/null +++ b/client/src/services/notifications/apis/mockApi.ts @@ -0,0 +1,108 @@ +import { sleep } from '@/utils'; +import type { Paginated, PageParams } from '@/lib/api/types'; +import { parseNotificationData } from '../parse'; +import type { AppNotification, NotificationsApi } from '../types'; + +/** + * In-memory `NotificationsApi` — **the primary implementation this phase** (`USE_NOTIFICATIONS_MOCK = true`; + * nothing dispatches notifications client-side yet — see `constants.ts`). + * + * It seeds a realistic **unread-first** feed spanning every deep-link class so `notificationDeepLink` is + * exercisable end-to-end, keeps a `dataJson` string per row (snake_case, as the contract serves) that the + * list maps through the real `parseNotificationData`, and exposes a dev-only `__mockPushNotification` so a + * human can watch the bell badge increment within the poll interval (phase §7 step 4). Booking/ticket/nurse + * ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen. + */ + +const MOCK_LATENCY_MS = 200; + +interface StoredNotification { + id: number; + type: string; + title: string; + body: string | null; + dataJson: string | null; + isRead: boolean; + createdAt: string; +} + +/** ISO instant `mins` in the past — seeded timestamps (rendered Shamsi client-side). */ +function isoMinsAgo(mins: number): string { + return new Date(Date.now() - mins * 60_000).toISOString(); +} + +let nextNotificationId = 8100; + +const notifications: StoredNotification[] = [ + { id: 8001, type: 'ticket_message', title: 'پیام جدید در تیکت', body: 'پرستار به هماهنگی ویزیت شما پاسخ داد.', dataJson: '{"ticket_id":1201}', isRead: false, createdAt: isoMinsAgo(90) }, + { id: 8002, type: 'booking_confirmed', title: 'رزرو شما تایید شد', body: 'ویزیت شما ثبت و تایید شد.', dataJson: '{"booking_id":5001}', isRead: false, createdAt: isoMinsAgo(240) }, + { id: 8003, type: 'refund_processed', title: 'بازپرداخت ثبت شد', body: 'بازپرداخت شما در حال انجام است.', dataJson: '{"booking_id":5004}', isRead: false, createdAt: isoMinsAgo(600) }, + { id: 8004, type: 'payment_captured', title: 'پرداخت انجام شد', body: 'مبلغ ویزیت با موفقیت پرداخت شد.', dataJson: '{"booking_id":5001}', isRead: true, createdAt: isoMinsAgo(1_440) }, + { id: 8005, type: 'payout_paid', title: 'تسویه واریز شد', body: 'درآمد این هفته به حساب شما واریز شد.', dataJson: '{"payout_id":7001}', isRead: true, createdAt: isoMinsAgo(2_880) }, + { id: 8006, type: 'review_published', title: 'نظر شما منتشر شد', body: null, dataJson: '{"nurse_profile_id":1}', isRead: true, createdAt: isoMinsAgo(4_320) }, + // Unknown type + no payload → degrades to no deep-link (still a readable, informational row). + { id: 8007, type: 'system_message', title: 'به بالین‌یار خوش آمدید', body: 'سلامت خانواده شما، اولویت ماست.', dataJson: null, isRead: true, createdAt: isoMinsAgo(10_080) }, +]; + +function toAppNotification(n: StoredNotification): AppNotification { + return { + id: n.id, + type: n.type, + title: n.title, + body: n.body, + isRead: n.isRead, + createdAt: n.createdAt, + data: parseNotificationData(n.type, n.dataJson), + }; +} + +/** Unread first, then newest-first (server ordering). */ +function ordered(): StoredNotification[] { + return [...notifications].sort((a, b) => { + if (a.isRead !== b.isRead) return a.isRead ? 1 : -1; + return Date.parse(b.createdAt) - Date.parse(a.createdAt); + }); +} + +export const notificationsMockApi: NotificationsApi = { + listNotifications: async (params: PageParams): Promise> => { + await sleep(MOCK_LATENCY_MS); + const all = ordered(); + const page = Math.max(1, params.page ?? 1); + const pageSize = Math.max(1, params.pageSize ?? all.length); + const start = (page - 1) * pageSize; + return { items: all.slice(start, start + pageSize).map(toAppNotification), total: all.length, page, pageSize }; + }, + + getUnreadCount: async (): Promise => { + await sleep(MOCK_LATENCY_MS); + return notifications.filter((n) => !n.isRead).length; + }, + + markRead: async (notificationId: number): Promise => { + await sleep(MOCK_LATENCY_MS); + const n = notifications.find((x) => x.id === notificationId); + if (n) n.isRead = true; + }, + + markAllRead: async (): Promise => { + await sleep(MOCK_LATENCY_MS); + notifications.forEach((n) => { + n.isRead = true; + }); + }, +}; + +/** + * DEV-ONLY: simulate a fresh notification arriving so a human can watch the bell badge increment within the + * poll interval (phase §7 step 4) and open it → deep-link. Prepends an **unread** row. `dataJson` is a + * snake_case JSON string, exactly as the wire serves it. Call it from the console. Not wired into any screen. + */ +export function __mockPushNotification( + type: string, + title: string, + dataJson: string | null = null, + body: string | null = null, +): void { + notifications.unshift({ id: nextNotificationId++, type, title, body, dataJson, isRead: false, createdAt: new Date().toISOString() }); +} diff --git a/client/src/services/notifications/constants.ts b/client/src/services/notifications/constants.ts new file mode 100644 index 0000000..4f87a6d --- /dev/null +++ b/client/src/services/notifications/constants.ts @@ -0,0 +1,27 @@ +/** + * When true, the notifications domain is served by the in-memory mock (`apis/mockApi.ts`) behind the + * `NotificationsApi` seam. + * + * **Mock is primary this phase.** The b1 endpoints are live and the real `clientApi.ts` maps them 1:1, but a + * notification is only produced by other backend domains dispatching one (`INotificationDispatcher`) — none + * of which 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 (booking / payment / ticket / + * payout / review / refund) so `notificationDeepLink` is exercisable, and exposes a dev-only + * `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll + * interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change. + */ +export const USE_NOTIFICATIONS_MOCK = true; + +/** Notification-center page size (api-conventions `pageSize`). */ +export const NOTIFICATIONS_PAGE_SIZE = 20; + +/** + * **Poll politely.** Only the unread **count** revalidates on an interval (stale-while-revalidate): the + * cached count is served instantly, refetched every `UNREAD_COUNT_REFETCH_INTERVAL` and on window focus, and + * treated fresh for `UNREAD_COUNT_STALE_TIME` so we never hammer the endpoint. The **list is not polled** — + * only its own reads/mutations refetch it. + */ +export const UNREAD_COUNT_STALE_TIME = 45 * 1000; +export const UNREAD_COUNT_REFETCH_INTERVAL = 60 * 1000; +export const NOTIFICATIONS_LIST_STALE_TIME = 30 * 1000; +export const NOTIFICATIONS_GC_TIME = 5 * 60 * 1000; diff --git a/client/src/services/notifications/deepLink.test.ts b/client/src/services/notifications/deepLink.test.ts new file mode 100644 index 0000000..80fc86f --- /dev/null +++ b/client/src/services/notifications/deepLink.test.ts @@ -0,0 +1,35 @@ +import { notificationDeepLink } from './deepLink'; +import type { AppNotification, NotificationData } from './types'; + +function make(data: NotificationData): AppNotification { + return { id: 1, type: 'x', title: 't', body: null, isRead: false, createdAt: '2026-07-01T00:00:00Z', data }; +} + +describe('notificationDeepLink', () => { + it('routes a booking to the customer booking detail vs the nurse visit detail', () => { + expect(notificationDeepLink(make({ kind: 'booking', bookingId: 5001 }), 'customer')).toBe('/bookings/5001'); + expect(notificationDeepLink(make({ kind: 'booking', bookingId: 5001 }), 'nurse')).toBe('/nurse/visits/5001'); + }); + + it('routes a ticket to the right shell', () => { + expect(notificationDeepLink(make({ kind: 'ticket', ticketId: 12 }), 'customer')).toBe('/support/tickets/12'); + expect(notificationDeepLink(make({ kind: 'ticket', ticketId: 12 }), 'nurse')).toBe('/nurse/support/tickets/12'); + }); + + it('routes a refund to the customer refund status', () => { + expect(notificationDeepLink(make({ kind: 'refund', bookingId: 5004 }), 'customer')).toBe('/bookings/5004/refund_status'); + }); + + it('routes a payout to the nurse payout detail', () => { + expect(notificationDeepLink(make({ kind: 'payout', payoutId: 7001 }), 'nurse')).toBe('/nurse/earnings/payouts/7001'); + }); + + it('routes a review to the public nurse profile for a customer, nowhere for a nurse', () => { + expect(notificationDeepLink(make({ kind: 'nurse_profile', nurseProfileId: 1 }), 'customer')).toBe('/search/nurse/1'); + expect(notificationDeepLink(make({ kind: 'nurse_profile', nurseProfileId: 1 }), 'nurse')).toBeNull(); + }); + + it('returns null (non-navigable, never broken) when there is nothing to open', () => { + expect(notificationDeepLink(make({ kind: 'none' }), 'customer')).toBeNull(); + }); +}); diff --git a/client/src/services/notifications/deepLink.ts b/client/src/services/notifications/deepLink.ts new file mode 100644 index 0000000..b412fc1 --- /dev/null +++ b/client/src/services/notifications/deepLink.ts @@ -0,0 +1,35 @@ +import { + ROUTES, + bookingRefundStatusPath, + nurseBookingDetailPath, + nursePayoutDetailPath, + ticketThreadPath, +} from '@/constants'; +import type { AppNotification } from './types'; + +/** + * Map a notification's parsed `data` to an **app-relative** in-app route (no locale prefix — the caller adds + * it), role-aware so the same notification lands on the right shell (a booking → the customer's + * `/bookings/{id}` vs the nurse's `/nurse/visits/{id}`; a ticket → the right `support/tickets/{id}`). + * Centralised so the bell and the center deep-link identically. Returns `null` when there is nothing to open + * (`kind: 'none'`, or a target that doesn't apply to this role) — the row is then non-navigable, never broken. + */ +export function notificationDeepLink(notification: AppNotification, role: 'customer' | 'nurse'): string | null { + const { data } = notification; + switch (data.kind) { + case 'booking': + return role === 'nurse' ? nurseBookingDetailPath(data.bookingId) : `${ROUTES.BOOKINGS}/${data.bookingId}`; + case 'refund': + // A refund view is a customer surface; a nurse just reaches the booking. + return role === 'nurse' ? nurseBookingDetailPath(data.bookingId) : bookingRefundStatusPath(data.bookingId); + case 'payout': + return nursePayoutDetailPath(data.payoutId); + case 'ticket': + return ticketThreadPath(role, data.ticketId); + case 'nurse_profile': + return role === 'customer' ? `${ROUTES.SEARCH_NURSE}/${data.nurseProfileId}` : null; + case 'none': + default: + return null; + } +} diff --git a/client/src/services/notifications/hooks/useMarkAllRead.ts b/client/src/services/notifications/hooks/useMarkAllRead.ts new file mode 100644 index 0000000..f74ea9b --- /dev/null +++ b/client/src/services/notifications/hooks/useMarkAllRead.ts @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query'; +import type { Paginated } from '@/lib/api/types'; +import { notificationKeys } from '../keys'; +import { notificationsApi } from '../apis'; +import type { AppNotification } from '../types'; + +interface MarkAllContext { + prevCount?: number; + prevLists: Array<[QueryKey, Paginated | undefined]>; +} + +/** + * "Mark all read" — **optimistic**: flip every cached list item to `isRead` and zero the cached unread count + * so the badge clears instantly, roll both back on error, and invalidate on settle. + */ +export function useMarkAllRead() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => notificationsApi.markAllRead(), + + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: notificationKeys.all }); + const prevCount = queryClient.getQueryData(notificationKeys.unreadCount()); + const prevLists = queryClient.getQueriesData>({ queryKey: notificationKeys.lists() }); + + queryClient.setQueriesData>({ queryKey: notificationKeys.lists() }, (data) => + data ? { ...data, items: data.items.map((n) => ({ ...n, isRead: true })) } : data, + ); + queryClient.setQueryData(notificationKeys.unreadCount(), 0); + return { prevCount, prevLists }; + }, + + onError: (_err, _vars, context) => { + if (context?.prevCount !== undefined) queryClient.setQueryData(notificationKeys.unreadCount(), context.prevCount); + context?.prevLists.forEach(([key, data]) => queryClient.setQueryData(key, data)); + }, + + onSettled: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); + queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() }); + }, + }); +} diff --git a/client/src/services/notifications/hooks/useMarkNotificationRead.ts b/client/src/services/notifications/hooks/useMarkNotificationRead.ts new file mode 100644 index 0000000..0dc8c33 --- /dev/null +++ b/client/src/services/notifications/hooks/useMarkNotificationRead.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query'; +import type { Paginated } from '@/lib/api/types'; +import { notificationKeys } from '../keys'; +import { notificationsApi } from '../apis'; +import type { AppNotification } from '../types'; + +interface MarkReadContext { + prevCount?: number; + prevLists: Array<[QueryKey, Paginated | undefined]>; +} + +/** + * Mark one notification read — **optimistic** (phase §3.4): flip `isRead` across every cached list page and + * decrement the cached unread count at once (so the row de-emphasises and the bell badge drops instantly), + * roll both back on error, and invalidate on settle (no full refetch on the happy path). The count only + * decrements when the notification was actually unread, so re-opening a read one never underflows. + */ +export function useMarkNotificationRead() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (notificationId) => notificationsApi.markRead(notificationId), + + onMutate: async (notificationId) => { + await queryClient.cancelQueries({ queryKey: notificationKeys.all }); + const prevCount = queryClient.getQueryData(notificationKeys.unreadCount()); + const prevLists = queryClient.getQueriesData>({ queryKey: notificationKeys.lists() }); + + const wasUnread = prevLists.some(([, data]) => + data?.items.some((n) => n.id === notificationId && !n.isRead), + ); + + queryClient.setQueriesData>({ queryKey: notificationKeys.lists() }, (data) => + data ? { ...data, items: data.items.map((n) => (n.id === notificationId ? { ...n, isRead: true } : n)) } : data, + ); + if (wasUnread && typeof prevCount === 'number') { + queryClient.setQueryData(notificationKeys.unreadCount(), Math.max(0, prevCount - 1)); + } + return { prevCount, prevLists }; + }, + + onError: (_err, _id, context) => { + if (context?.prevCount !== undefined) queryClient.setQueryData(notificationKeys.unreadCount(), context.prevCount); + context?.prevLists.forEach(([key, data]) => queryClient.setQueryData(key, data)); + }, + + onSettled: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.lists() }); + queryClient.invalidateQueries({ queryKey: notificationKeys.unreadCount() }); + }, + }); +} diff --git a/client/src/services/notifications/hooks/useNotifications.ts b/client/src/services/notifications/hooks/useNotifications.ts new file mode 100644 index 0000000..ce8a80a --- /dev/null +++ b/client/src/services/notifications/hooks/useNotifications.ts @@ -0,0 +1,22 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { notificationsApi } from '../apis'; +import { notificationKeys } from '../keys'; +import { NOTIFICATIONS_GC_TIME, NOTIFICATIONS_LIST_STALE_TIME, NOTIFICATIONS_PAGE_SIZE } from '../constants'; + +/** + * The notification center list — **unread-first** (server ordering). `limit` grows for "load more" (page 1, + * a larger page) so the visible list accumulates without a fragile page-by-page merge (unread-first ordering + * shifts as rows are read). The limit keys the cache, so growing it caches independently and `keepPreviousData` + * 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) { + const params = { page: 1, pageSize: limit }; + return useQuery({ + queryKey: notificationKeys.list(params), + queryFn: () => notificationsApi.listNotifications(params), + placeholderData: keepPreviousData, + staleTime: NOTIFICATIONS_LIST_STALE_TIME, + gcTime: NOTIFICATIONS_GC_TIME, + }); +} diff --git a/client/src/services/notifications/hooks/useUnreadCount.ts b/client/src/services/notifications/hooks/useUnreadCount.ts new file mode 100644 index 0000000..d11a2bf --- /dev/null +++ b/client/src/services/notifications/hooks/useUnreadCount.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import { notificationsApi } from '../apis'; +import { notificationKeys } from '../keys'; +import { UNREAD_COUNT_REFETCH_INTERVAL, UNREAD_COUNT_STALE_TIME } from '../constants'; + +/** + * The **polled** unread count that drives the bell badge — stale-while-revalidate: the cached count is served + * instantly, treated fresh for `UNREAD_COUNT_STALE_TIME`, refetched every `UNREAD_COUNT_REFETCH_INTERVAL` and + * on window focus (background revalidation), so we never hammer the endpoint (phase §5). Gated on + * authentication so the bell never polls for a signed-out user. Returns the raw count (0 while loading) so + * only the bell — not the shell — re-renders on a change. + */ +export function useUnreadCount(): number { + const isAuthenticated = useIsAuthenticated(); + const { data } = useQuery({ + queryKey: notificationKeys.unreadCount(), + queryFn: () => notificationsApi.getUnreadCount(), + enabled: isAuthenticated, + staleTime: UNREAD_COUNT_STALE_TIME, + refetchInterval: UNREAD_COUNT_REFETCH_INTERVAL, + refetchOnWindowFocus: true, + }); + return data ?? 0; +} diff --git a/client/src/services/notifications/index.ts b/client/src/services/notifications/index.ts new file mode 100644 index 0000000..52ef562 --- /dev/null +++ b/client/src/services/notifications/index.ts @@ -0,0 +1,10 @@ +/** + * Notifications domain barrel — re-exports **hooks only** (per the `services/{domain}` convention) plus the + * shared `notificationDeepLink` util (the bell and the center deep-link identically through it). Import + * types/keys/apis directly from their files when needed. + */ +export { useNotifications } from './hooks/useNotifications'; +export { useUnreadCount } from './hooks/useUnreadCount'; +export { useMarkNotificationRead } from './hooks/useMarkNotificationRead'; +export { useMarkAllRead } from './hooks/useMarkAllRead'; +export { notificationDeepLink } from './deepLink'; diff --git a/client/src/services/notifications/keys.ts b/client/src/services/notifications/keys.ts new file mode 100644 index 0000000..cec13b5 --- /dev/null +++ b/client/src/services/notifications/keys.ts @@ -0,0 +1,16 @@ +import type { PageParams } from '@/lib/api/types'; + +/** + * React Query key factory for the notifications domain. The **page keys the list** (unread-first, paginated); + * `unreadCount` is its own tiny key so the polling bell reads the count **without** touching the list cache + * (the count re-fetches on an interval; the list does not). Mark-read mutations `setQueryData` both keys + * optimistically, then invalidate on settle. + */ +export const notificationKeys = { + all: ['notifications'] as const, + + lists: () => [...notificationKeys.all, 'list'] as const, + list: (params: PageParams) => [...notificationKeys.lists(), params] as const, + + unreadCount: () => [...notificationKeys.all, 'unread_count'] as const, +}; diff --git a/client/src/services/notifications/parse.test.ts b/client/src/services/notifications/parse.test.ts new file mode 100644 index 0000000..e3a6b91 --- /dev/null +++ b/client/src/services/notifications/parse.test.ts @@ -0,0 +1,28 @@ +import { parseNotificationData } from './parse'; + +describe('parseNotificationData', () => { + it('parses a snake_case booking payload', () => { + expect(parseNotificationData('booking_confirmed', '{"booking_id":5001}')).toEqual({ kind: 'booking', bookingId: 5001 }); + }); + + it('also accepts a camelCase payload', () => { + expect(parseNotificationData('ticket_message', '{"ticketId":12}')).toEqual({ kind: 'ticket', ticketId: 12 }); + }); + + it('maps a refund notification to the booking id', () => { + expect(parseNotificationData('refund_processed', '{"booking_id":5004}')).toEqual({ kind: 'refund', bookingId: 5004 }); + }); + + it('degrades to none for malformed JSON (never throws / trusts a blob)', () => { + expect(parseNotificationData('booking_confirmed', 'not-json')).toEqual({ kind: 'none' }); + }); + + it('degrades to none for an unknown type', () => { + expect(parseNotificationData('mystery_event', '{"booking_id":1}')).toEqual({ kind: 'none' }); + }); + + it('degrades to none when the expected id is missing', () => { + expect(parseNotificationData('payout_paid', '{}')).toEqual({ kind: 'none' }); + expect(parseNotificationData('booking_confirmed', null)).toEqual({ kind: 'none' }); + }); +}); diff --git a/client/src/services/notifications/parse.ts b/client/src/services/notifications/parse.ts new file mode 100644 index 0000000..c418a32 --- /dev/null +++ b/client/src/services/notifications/parse.ts @@ -0,0 +1,68 @@ +import type { NotificationData } from './types'; + +/** + * Parse a notification's wire `data_json` string into the typed `NotificationData` union, keyed off its + * `type` code. `data_json` is a **typed, versioned contract** (the contract example uses snake_case keys, + * e.g. `{"booking_id": 1}`), but we read both snake_case and camelCase defensively and **never trust an + * arbitrary blob**: a malformed JSON string, an unknown `type`, or a missing/invalid id all degrade to + * `{ kind: 'none' }` (no deep-link) rather than throwing. + * + * The many `type` codes collapse to a few deep-link classes so `notificationDeepLink` stays small. + */ +export function parseNotificationData(type: string, dataJson: string | null | undefined): NotificationData { + const raw = safeParse(dataJson); + + const bookingId = num(raw, 'bookingId', 'booking_id'); + const payoutId = num(raw, 'payoutId', 'payout_id', 'batchId', 'batch_id'); + const ticketId = num(raw, 'ticketId', 'ticket_id'); + const nurseProfileId = num(raw, 'nurseProfileId', 'nurse_profile_id', 'nurseId', 'nurse_id'); + + switch (type) { + case 'booking_confirmed': + case 'booking_reminder': + case 'session_reminder': + case 'payment_captured': + case 'booking_cancelled': + return bookingId != null ? { kind: 'booking', bookingId } : { kind: 'none' }; + + case 'refund_processed': + case 'refund_completed': + // A refund notification deep-links to the booking's refund status (that's how the customer reaches it). + return bookingId != null ? { kind: 'refund', bookingId } : { kind: 'none' }; + + case 'payout_paid': + case 'payout_failed': + return payoutId != null ? { kind: 'payout', payoutId } : { kind: 'none' }; + + case 'ticket_message': + case 'ticket_opened': + case 'ticket_closed': + return ticketId != null ? { kind: 'ticket', ticketId } : { kind: 'none' }; + + case 'review_published': + return nurseProfileId != null ? { kind: 'nurse_profile', nurseProfileId } : { kind: 'none' }; + + default: + return { kind: 'none' }; + } +} + +function safeParse(dataJson: string | null | undefined): Record { + if (!dataJson) return {}; + try { + const parsed: unknown = JSON.parse(dataJson); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** First key that resolves to a finite number (accepts a numeric string), else `null`. */ +function num(obj: Record, ...keys: string[]): number | null { + for (const key of keys) { + const v = obj[key]; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v); + } + return null; +} diff --git a/client/src/services/notifications/types.ts b/client/src/services/notifications/types.ts new file mode 100644 index 0000000..dc0f5b1 --- /dev/null +++ b/client/src/services/notifications/types.ts @@ -0,0 +1,56 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; + +/** + * Notifications domain — the app's **pull** mechanism (b1). In-app only (no push at launch), **polled**, with + * a typed `data_json` payload that tells the front-end where to deep-link. 90-day read retention is a server + * concern. Shapes derive from the b1 contract (`config-reference.md` → `NotificationDto`; swagger + * `NotificationDto`/`UnreadCountResult`/`MarkNotificationReadCommand`). + * + * **Load-bearing rules (phase §5):** + * - **`data_json` is a typed contract** — parse it into the discriminated `NotificationData` union and + * deep-link off that; never `eval`/trust an arbitrary blob, and degrade gracefully (no deep-link) for an + * unknown `type` or a missing id. + * - **Poll the count politely** — only `useUnreadCount` polls (stale-while-revalidate); the list is not + * polled on an interval. + * - **Tenancy** — every read is scoped to the signed-in caller server-side; never fetch by a raw id. + */ + +/** + * The parsed deep-link target of a notification, discriminated by `kind`. The many notification `type` codes + * collapse to a few route classes here (a booking, a refund view, a payout, a ticket, a nurse profile), or + * `none` when there's nothing to open. Parsed from `data_json` by `parseNotificationData`. + */ +export type NotificationData = + | { kind: 'booking'; bookingId: number } + | { kind: 'refund'; bookingId: number } + | { kind: 'payout'; payoutId: number } + | { kind: 'ticket'; ticketId: number } + | { kind: 'nurse_profile'; nurseProfileId: number } + | { kind: 'none' }; + +/** A client notification (`NotificationDto` + the parsed `data`). `type`/`title`/`body` are server-rendered. */ +export interface AppNotification { + id: number; + /** Open string code (e.g. `booking_confirmed`) — the server owns the copy; we deep-link off `data`. */ + type: string; + title: string; + body: string | null; + isRead: boolean; + /** UTC ISO-8601 — Shamsi display is the client's job. */ + createdAt: string; + /** Parsed from the wire `dataJson` string into the typed union (never the raw blob). */ + data: NotificationData; +} + +/** + * The notifications API seam — the real HTTP client and the in-memory mock both implement this; selection is + * by config (`USE_NOTIFICATIONS_MOCK`). Reads are tenant-scoped server-side. + */ +export interface NotificationsApi { + /** Paged, **unread-first then newest-first** (server ordering). */ + listNotifications(params: PageParams): Promise>; + /** Cheap index-backed count for the polling bell. */ + getUnreadCount(): Promise; + markRead(notificationId: number): Promise; + markAllRead(): Promise; +} diff --git a/client/src/services/tickets/apis/clientApi.ts b/client/src/services/tickets/apis/clientApi.ts new file mode 100644 index 0000000..a749a68 --- /dev/null +++ b/client/src/services/tickets/apis/clientApi.ts @@ -0,0 +1,153 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import { TICKETS_PAGE_SIZE } from '../constants'; +import type { + OpenTicketRequest, + OpenTicketResult, + PostMessageRequest, + PostMessageResult, + TicketAuthorRole, + TicketDetail, + TicketListParams, + TicketMessage, + TicketSummary, + TicketsApi, +} from '../types'; + +const API = '/api/v1'; + +/** Wire `TicketSummaryDto` (camelCase, per api-conventions). */ +interface TicketSummaryWire { + id: number; + referenceCode: string; + subject: string | null; + status: string; + category: string; + bookingId: number | null; + refundId: number | null; + createdAt: string; +} + +/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */ +interface TicketMessageWire { + id: number; + senderId: number; + body: string; + isInternal: boolean; + sentAt: string; +} + +/** Wire `TicketThreadDto` (user view). */ +interface TicketThreadWire { + id: number; + referenceCode: string; + subject: string | null; + status: string; + category: string; + bookingId: number | null; + refundId: number | null; + openedById: number; + closedAt: string | null; + participants: Array<{ userId: number; roleOnTicket: string | null }>; + messages: TicketMessageWire[]; +} + +function mapSummary(w: TicketSummaryWire): TicketSummary { + return { + id: w.id, + referenceCode: w.referenceCode, + subject: w.subject, + status: w.status as TicketSummary['status'], + category: w.category as TicketSummary['category'], + bookingId: w.bookingId, + refundId: w.refundId, + createdAt: w.createdAt, + }; +} + +function mapThread(w: TicketThreadWire, viewerUserId?: number): TicketDetail { + const roleBySender = new Map( + w.participants.map((p) => [p.userId, (p.roleOnTicket ?? 'system') as TicketAuthorRole]), + ); + const messages: TicketMessage[] = w.messages + // Defensive: the user view is server-stripped of internal notes, but never render one if it leaks — + // that is a backend defect to file, not to surface (phase §5, contract "Critical rules"). + .filter((m) => !m.isInternal) + .map((m) => ({ + id: m.id, + ticketId: w.id, + body: m.body, + authorRole: roleBySender.get(m.senderId) ?? 'system', + createdAt: m.sentAt, + isMine: viewerUserId != null && m.senderId === viewerUserId, + sendStatus: 'sent' as const, + })); + return { + id: w.id, + referenceCode: w.referenceCode, + subject: w.subject, + status: w.status as TicketDetail['status'], + category: w.category as TicketDetail['category'], + bookingId: w.bookingId, + refundId: w.refundId, + openedById: w.openedById, + closedAt: w.closedAt, + participants: w.participants.map((p) => ({ + userId: p.userId, + roleOnTicket: (p.roleOnTicket ?? 'system') as TicketAuthorRole, + })), + messages, + }; +} + +/** + * Real HTTP implementation of the `TicketsApi` seam (b15 contract). All four methods map published routes: + * - `listMyTickets` → `GET /tickets` (own, paginated; `Status`/`ReferenceCode`/`Page`/`PageSize`). + * - `getTicket` → `GET /tickets/{id}` (user view — internal messages already stripped server-side). + * - `openTicket` → `POST /tickets`. + * - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`). + * + * NOT the primary implementation this phase (`USE_TICKETS_MOCK = true`) — see `constants.ts`. The wire + * summary has no `unreadCount`/`lastMessageAt` (REQ-028), so those stay undefined here (the inbox degrades). + * `clientMessageId` is client-only (optimistic reconcile) — not sent (the server has no field for it yet). + */ +export const ticketsClientApi: TicketsApi = { + listMyTickets: async (params: TicketListParams): Promise> => { + const query = new URLSearchParams(); + if (params.status) query.set('Status', params.status); + query.set('Page', String(params.page ?? 1)); + query.set('PageSize', String(params.pageSize ?? TICKETS_PAGE_SIZE)); + const wire = unwrap( + await clientFetch>>(`${API}/tickets?${query.toString()}`), + ); + return { ...wire, items: wire.items.map(mapSummary) }; + }, + + getTicket: async (ticketId: number, viewerUserId?: number): Promise => { + const wire = unwrap(await clientFetch>(`${API}/tickets/${ticketId}`)); + return mapThread(wire, viewerUserId); + }, + + // The server infers the opener from auth — `viewerUserId` is only for the mock's attribution. + openTicket: async (body: OpenTicketRequest, _viewerUserId?: number): Promise => + unwrap( + await clientFetch>(`${API}/tickets`, { + method: 'POST', + body: JSON.stringify({ + category: body.category, + subject: body.subject ?? null, + body: body.body, + bookingId: body.bookingId ?? null, + refundId: body.refundId ?? null, + }), + }), + ), + + postMessage: async (ticketId: number, body: PostMessageRequest): Promise => + unwrap( + await clientFetch>(`${API}/tickets/${ticketId}/messages`, { + method: 'POST', + body: JSON.stringify({ body: body.body }), + }), + ), +}; diff --git a/client/src/services/tickets/apis/index.ts b/client/src/services/tickets/apis/index.ts new file mode 100644 index 0000000..42e9b22 --- /dev/null +++ b/client/src/services/tickets/apis/index.ts @@ -0,0 +1,11 @@ +import { USE_TICKETS_MOCK } from '../constants'; +import type { TicketsApi } from '../types'; +import { ticketsClientApi } from './clientApi'; +import { ticketsMockApi } from './mockApi'; + +/** + * The selected `TicketsApi` implementation — the single seam the hooks import. Selection is by config + * (`USE_TICKETS_MOCK`), never scattered `if (mock)` checks. Mock-primary this phase (linked bookings are + * mock-primary + REQ-028 summary gaps); the swap is this one line. + */ +export const ticketsApi: TicketsApi = USE_TICKETS_MOCK ? ticketsMockApi : ticketsClientApi; diff --git a/client/src/services/tickets/apis/mockApi.ts b/client/src/services/tickets/apis/mockApi.ts new file mode 100644 index 0000000..148b0b2 --- /dev/null +++ b/client/src/services/tickets/apis/mockApi.ts @@ -0,0 +1,291 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import type { Paginated } from '@/lib/api/types'; +import { MOCK_SEND_FAIL_SENTINEL, MOCK_VIEWER_USER_ID } from '../constants'; +import type { + OpenTicketRequest, + OpenTicketResult, + PostMessageRequest, + PostMessageResult, + TicketAuthorRole, + TicketCategory, + TicketDetail, + TicketListParams, + TicketMessage, + TicketParticipant, + TicketStatus, + TicketSummary, + TicketsApi, +} from '../types'; + +/** + * In-memory `TicketsApi` — **the primary implementation this phase** (`USE_TICKETS_MOCK = true`; see + * `constants.ts` for why: the linked bookings are themselves mock-primary, and the wire summary lacks + * `unreadCount`/`lastMessageAt`, REQ-028). + * + * It is engineered to demonstrate the whole ticket surface end-to-end: + * - **No internal-note leak.** A ticket stores an admin **internal** note that the user view (`getTicket`) + * **drops** — mimicking the server's `is_internal` stripping — so the phase §7 step-3 test (the user + * thread shows none of it, no styling, no affordance) is demonstrable against the mock. + * - **Booking-linked coordination.** A `coordination` ticket is seeded for booking 5001 (the f8 confirmed + * seed); `openTicket` is idempotent for `coordination + bookingId` so "Get support" from that booking + * **jumps to the existing thread** instead of spawning duplicates. + * - **Optimistic send.** `postMessage` appends as the current viewer (tracked from the last `getTicket`); + * posting the dev sentinel `MOCK_SEND_FAIL_SENTINEL` throws a `500` so the failure→retry, draft-preserved + * path is exercisable; posting to a **closed** ticket is a `403` (contract). + * - **Unread indicator.** Each ticket tracks an `unread` count the inbox renders; opening the thread + * (`getTicket`) clears it — mirroring the notification "mark read on open" feel. + */ + +const MOCK_LATENCY_MS = 250; + +/** A stored message. `internal` messages exist in the store but are **never** returned by the user view. */ +interface StoredMessage { + id: number; + senderId: number; + body: string; + internal: boolean; + sentAt: string; +} + +interface StoredTicket { + id: number; + referenceCode: string; + subject: string | null; + status: TicketStatus; + category: TicketCategory; + bookingId: number | null; + refundId: number | null; + openedById: number; + closedAt: string | null; + participants: TicketParticipant[]; + messages: StoredMessage[]; + /** Unread-for-the-viewer count the inbox renders; cleared when the thread is opened. */ + unread: number; +} + +const CUSTOMER = MOCK_VIEWER_USER_ID.customer; +const NURSE = MOCK_VIEWER_USER_ID.nurse; +const ADMIN = MOCK_VIEWER_USER_ID.admin; + +const CUSTOMER_PARTICIPANT: TicketParticipant = { userId: CUSTOMER, roleOnTicket: 'customer' }; +const NURSE_PARTICIPANT: TicketParticipant = { userId: NURSE, roleOnTicket: 'nurse' }; +const ADMIN_PARTICIPANT: TicketParticipant = { userId: ADMIN, roleOnTicket: 'admin' }; + +/** The participant record for a viewer id (so an opener is attributed to the right actor). */ +function participantFor(userId: number): TicketParticipant { + if (userId === NURSE) return NURSE_PARTICIPANT; + if (userId === ADMIN) return ADMIN_PARTICIPANT; + return CUSTOMER_PARTICIPANT; +} + +/** ISO instant `mins` in the past — seeded message timestamps (rendered Shamsi client-side). */ +function isoMinsAgo(mins: number): string { + return new Date(Date.now() - mins * 60_000).toISOString(); +} + +let nextTicketId = 1301; +let nextMessageId = 50_000; + +/** + * The viewer of the most recent `getTicket` — so `postMessage` (which the seam gives no viewer) appends the + * message as the right sender, making it render as "mine" on the next fetch in whichever app is open. + */ +let lastViewerUserId = CUSTOMER; + +const tickets: StoredTicket[] = [ + { + id: 1201, + referenceCode: 'TKT-9F3K2A7Q', + subject: 'هماهنگی ویزیت', + status: 'open', + category: 'coordination', + bookingId: 5001, + refundId: null, + openedById: ADMIN, + closedAt: null, + participants: [CUSTOMER_PARTICIPANT, NURSE_PARTICIPANT, ADMIN_PARTICIPANT], + unread: 1, + messages: [ + { id: 40_001, senderId: ADMIN, body: 'این گفتگو برای هماهنگی ویزیت شما ایجاد شد. در صورت نیاز اینجا پیام بگذارید.', internal: false, sentAt: isoMinsAgo(600) }, + { id: 40_002, senderId: CUSTOMER, body: 'سلام، لطفاً ساعت ویزیت را به عصر منتقل کنید.', internal: false, sentAt: isoMinsAgo(540) }, + // Internal admin note — stored, but the user view NEVER returns it (server-strip mimic; §5 no-leak). + { id: 40_003, senderId: ADMIN, body: 'INTERNAL: customer requested reschedule, confirm nurse availability before replying.', internal: true, sentAt: isoMinsAgo(520) }, + { id: 40_004, senderId: NURSE, body: 'سلام، بله امکان‌پذیر است. ساعت ۵ عصر هماهنگ شد.', internal: false, sentAt: isoMinsAgo(120) }, + ], + }, + { + id: 1202, + referenceCode: 'TKT-4B7X1M2P', + subject: 'سوال درباره سرویس', + status: 'open', + category: 'support', + bookingId: null, + refundId: null, + openedById: CUSTOMER, + closedAt: null, + participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT], + unread: 0, + messages: [ + { id: 40_010, senderId: CUSTOMER, body: 'آیا امکان انتخاب پرستار خانم برای ویزیت بعدی هست؟', internal: false, sentAt: isoMinsAgo(2_880) }, + { id: 40_011, senderId: ADMIN, body: 'بله، هنگام جست‌وجو می‌توانید جنسیت مراقب را انتخاب کنید.', internal: false, sentAt: isoMinsAgo(2_820) }, + ], + }, + { + id: 1203, + referenceCode: 'TKT-7C2D9E1F', + subject: 'پیگیری بازپرداخت', + status: 'closed', + category: 'refund', + bookingId: 5004, + refundId: 9001, + openedById: CUSTOMER, + closedAt: isoMinsAgo(4_000), + participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT], + unread: 0, + messages: [ + { id: 40_020, senderId: CUSTOMER, body: 'بازپرداخت من چه زمانی انجام می‌شود؟', internal: false, sentAt: isoMinsAgo(5_760) }, + { id: 40_021, senderId: ADMIN, body: 'بازپرداخت شما ثبت و به کارت شما واریز شد. این گفتگو بسته می‌شود.', internal: false, sentAt: isoMinsAgo(4_010) }, + ], + }, +]; + +function findTicket(id: number): StoredTicket { + const t = tickets.find((x) => x.id === id); + if (!t) throw new ApiError(404, 'Ticket not found', 'ticket_not_found'); + return t; +} + +/** Last non-internal message time — the inbox's "last activity" (REQ-028 stand-in for `lastMessageAt`). */ +function lastMessageAt(t: StoredTicket): string { + const visible = t.messages.filter((m) => !m.internal); + return visible.length ? visible[visible.length - 1].sentAt : t.messages[0]?.sentAt ?? new Date().toISOString(); +} + +function toSummary(t: StoredTicket): TicketSummary { + return { + id: t.id, + referenceCode: t.referenceCode, + subject: t.subject, + status: t.status, + category: t.category, + bookingId: t.bookingId, + refundId: t.refundId, + createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(), + lastMessageAt: lastMessageAt(t), + unreadCount: t.unread, + }; +} + +function toDetail(t: StoredTicket, viewerUserId: number): TicketDetail { + const roleBySender = new Map(t.participants.map((p) => [p.userId, p.roleOnTicket])); + const messages: TicketMessage[] = t.messages + // The user view NEVER contains an internal message (server-strip mimic; phase §5). + .filter((m) => !m.internal) + .map((m) => ({ + id: m.id, + ticketId: t.id, + body: m.body, + authorRole: roleBySender.get(m.senderId) ?? 'system', + createdAt: m.sentAt, + isMine: m.senderId === viewerUserId, + sendStatus: 'sent' as const, + })); + return { + id: t.id, + referenceCode: t.referenceCode, + subject: t.subject, + status: t.status, + category: t.category, + bookingId: t.bookingId, + refundId: t.refundId, + openedById: t.openedById, + closedAt: t.closedAt, + participants: t.participants, + messages, + }; +} + +/** `TKT-XXXXXXXX` — a stable, unique-looking reference (base36 of the id, not a real random code). */ +function makeReferenceCode(id: number): string { + return `TKT-${(id * 2_654_435_761 % 0xffffffff).toString(36).toUpperCase().padStart(8, '0').slice(-8)}`; +} + +export const ticketsMockApi: TicketsApi = { + listMyTickets: async (params: TicketListParams): Promise> => { + await sleep(MOCK_LATENCY_MS); + // Own-scoping is server-enforced; the mock returns every seeded ticket (a demo world), newest-activity + // first, optionally narrowed by status. + let all = [...tickets]; + if (params.status) all = all.filter((t) => t.status === params.status); + all.sort((a, b) => Date.parse(lastMessageAt(b)) - Date.parse(lastMessageAt(a))); + const page = Math.max(1, params.page ?? 1); + const pageSize = Math.max(1, params.pageSize ?? all.length); + const start = (page - 1) * pageSize; + return { + items: all.slice(start, start + pageSize).map(toSummary), + total: all.length, + page, + pageSize, + }; + }, + + getTicket: async (ticketId: number, viewerUserId?: number): Promise => { + await sleep(MOCK_LATENCY_MS); + const t = findTicket(ticketId); + lastViewerUserId = viewerUserId ?? CUSTOMER; + t.unread = 0; // opening the thread clears its unread indicator + return toDetail(t, lastViewerUserId); + }, + + openTicket: async (body: OpenTicketRequest, viewerUserId?: number): Promise => { + await sleep(MOCK_LATENCY_MS); + // Idempotent coordination: "Get support" from a booking jumps to its existing coordination thread. + if (body.category === 'coordination' && body.bookingId != null) { + const existing = tickets.find((t) => t.category === 'coordination' && t.bookingId === body.bookingId); + if (existing) { + return { ticketId: existing.id, referenceCode: existing.referenceCode, status: existing.status, category: existing.category }; + } + } + // The opener is the caller (nurse or customer) — attribute the opening message + participant to them so + // it renders as "mine" in whichever app opened it. Falls back to the last-viewed thread's viewer. + const opener = viewerUserId ?? lastViewerUserId; + const id = nextTicketId++; + const participants: TicketParticipant[] = [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT]; + if (body.bookingId != null && !participants.some((p) => p.userId === NURSE)) participants.push(NURSE_PARTICIPANT); + const openerParticipant = participantFor(opener); + if (!participants.some((p) => p.userId === openerParticipant.userId)) participants.push(openerParticipant); + const now = new Date().toISOString(); + const ticket: StoredTicket = { + id, + referenceCode: makeReferenceCode(id), + subject: body.subject?.trim() || null, + status: 'open', + category: body.category, + bookingId: body.bookingId ?? null, + refundId: body.refundId ?? null, + openedById: opener, + closedAt: null, + participants, + unread: 0, + messages: [{ id: nextMessageId++, senderId: opener, body: body.body, internal: false, sentAt: now }], + }; + tickets.unshift(ticket); // new ticket lands at the top of the inbox (phase §7 step 1) + return { ticketId: id, referenceCode: ticket.referenceCode, status: 'open', category: ticket.category }; + }, + + postMessage: async (ticketId: number, body: PostMessageRequest): Promise => { + await sleep(MOCK_LATENCY_MS); + const t = findTicket(ticketId); + // Dev-only failure trigger for the optimistic-send rollback/retry test (phase §7 step 2). + if (body.body.trim() === MOCK_SEND_FAIL_SENTINEL) { + throw new ApiError(500, 'Simulated send failure', 'mock_send_failed'); + } + // Contract: a non-staff caller posting to a closed ticket → 403. + if (t.status === 'closed') throw new ApiError(403, 'Ticket is closed', 'ticket_closed'); + const id = nextMessageId++; + const sentAt = new Date().toISOString(); + t.messages.push({ id, senderId: lastViewerUserId, body: body.body, internal: false, sentAt }); + return { messageId: id, ticketId, sentAt }; + }, +}; diff --git a/client/src/services/tickets/constants.ts b/client/src/services/tickets/constants.ts new file mode 100644 index 0000000..e6e9718 --- /dev/null +++ b/client/src/services/tickets/constants.ts @@ -0,0 +1,47 @@ +/** + * When true, the tickets domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `TicketsApi` + * seam. + * + * **Mock is primary this phase.** b15 serves the ticket endpoints (open / list / thread / message), and the + * real `clientApi.ts` maps them 1:1 — but the ticket screens depend on **inputs that are themselves + * mock-primary** (the f8 bookings a coordination ticket links to only exist client-side under the bookings + * mock), and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**). So the mock is the primary + * source: it seeds realistic tickets/threads **with no internal messages** (mimicking the server's user-view + * `is_internal` stripping — it even stores an internal admin note that the user view drops, so the + * no-leak test is demonstrable), supports the optimistic append, and makes a booking-linked coordination + * ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam + * selection in `apis/index.ts`). + */ +export const USE_TICKETS_MOCK = true; + +/** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */ +export const TICKETS_PAGE_SIZE = 20; + +/** + * The inbox list moves at human speed — a moderate `staleTime` avoids refetching on every revisit but a + * mutation (open ticket / post message) invalidates it so a new ticket / new activity shows without a manual + * refresh. The **thread is short-lived** (an active conversation) so it revalidates more eagerly. + */ +export const TICKETS_LIST_STALE_TIME = 30 * 1000; +export const TICKET_THREAD_STALE_TIME = 15 * 1000; +export const TICKETS_GC_TIME = 5 * 60 * 1000; + +/** + * DEV-ONLY trigger for the optimistic-send **failure** path (phase §7 step 2): posting this exact message + * body makes the mock throw a `500` so a human can watch the bubble roll back, the draft stay in the + * composer, and the retry succeed. Never a product string. + */ +export const MOCK_SEND_FAIL_SENTINEL = '/fail'; + +/** + * Stable per-role "me" ids for the **mock** world (the real path uses the authenticated `/me` id). The mock + * seeds the customer's / nurse's / support's messages with these sender ids; `useTicket` passes + * `currentUser?.id ?? MOCK_VIEWER_USER_ID[actorRole]` so a message renders as **mine** in whichever app is + * viewing (customer app → the customer's bubbles are mine; nurse app → the nurse's are). The fallback only + * fires under mock auth (no `/me` id); on the real path the authenticated id wins and this is dead weight. + */ +export const MOCK_VIEWER_USER_ID: Record<'customer' | 'nurse' | 'admin', number> = { + customer: 7001, + nurse: 7015, + admin: 7003, +}; diff --git a/client/src/services/tickets/hooks/useMyTickets.ts b/client/src/services/tickets/hooks/useMyTickets.ts new file mode 100644 index 0000000..5012909 --- /dev/null +++ b/client/src/services/tickets/hooks/useMyTickets.ts @@ -0,0 +1,22 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { ticketsApi } from '../apis'; +import { ticketKeys } from '../keys'; +import { TICKETS_GC_TIME, TICKETS_LIST_STALE_TIME, TICKETS_PAGE_SIZE } from '../constants'; +import type { TicketListParams } from '../types'; + +/** + * The "My Tickets" inbox — the caller's own tickets, newest-activity first, optionally narrowed by status. + * The **filter object keys the cache**, so paging/filtering caches independently and revisiting the inbox + * serves from cache; `keepPreviousData` avoids a flash on a status change. Opening a ticket or posting a + * message invalidates `tickets.lists()`, so new activity shows without a manual refresh. + */ +export function useMyTickets(params: TicketListParams = {}) { + const listParams: TicketListParams = { page: params.page ?? 1, pageSize: params.pageSize ?? TICKETS_PAGE_SIZE, status: params.status }; + return useQuery({ + queryKey: ticketKeys.list(listParams), + queryFn: () => ticketsApi.listMyTickets(listParams), + placeholderData: keepPreviousData, + staleTime: TICKETS_LIST_STALE_TIME, + gcTime: TICKETS_GC_TIME, + }); +} diff --git a/client/src/services/tickets/hooks/useOpenTicket.ts b/client/src/services/tickets/hooks/useOpenTicket.ts new file mode 100644 index 0000000..7d2f483 --- /dev/null +++ b/client/src/services/tickets/hooks/useOpenTicket.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ticketsApi } from '../apis'; +import { ticketKeys } from '../keys'; +import type { OpenTicketRequest, OpenTicketResult } from '../types'; +import { useTicketViewer } from './useTicketViewer'; + +/** + * Open a ticket (support / coordination from a booking / …). We pass the viewer id so the mock attributes the + * opening message to the right actor (a nurse-opened ticket's first message renders as the nurse's, not the + * customer's). On success we invalidate the inbox lists so the new ticket appears at the top without a manual + * refresh (phase §7 step 1). Domain 4xx (e.g. a `403` on a disallowed booking link) surface to the caller's + * `onError`; the dialog keeps the draft. + */ +export function useOpenTicket() { + const queryClient = useQueryClient(); + const { userId } = useTicketViewer(); + return useMutation({ + mutationFn: (body) => ticketsApi.openTicket(body, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); + }, + }); +} diff --git a/client/src/services/tickets/hooks/usePostMessage.ts b/client/src/services/tickets/hooks/usePostMessage.ts new file mode 100644 index 0000000..6287bb0 --- /dev/null +++ b/client/src/services/tickets/hooks/usePostMessage.ts @@ -0,0 +1,78 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ticketKeys } from '../keys'; +import { ticketsApi } from '../apis'; +import type { PostMessageResult, TicketDetail, TicketMessage } from '../types'; +import { useTicketViewer } from './useTicketViewer'; + +interface PostMessageVars { + body: string; + /** Client-generated id; the reconcile key so the optimistic bubble is never double-rendered (§3.5). */ + clientMessageId: string; +} + +interface PostMessageContext { + previous?: TicketDetail; +} + +/** + * The optimistic message send — the interaction that must feel instant (phase §3.5). + * + * `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`. + */ +export function usePostMessage(ticketId: number) { + const queryClient = useQueryClient(); + const { role } = useTicketViewer(); + + 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 }; + }, + + onError: (_err, _vars, context) => { + if (context?.previous) queryClient.setQueryData(ticketKeys.detail(ticketId), context.previous); + }, + + onSuccess: (result, { clientMessageId }) => { + const key = ticketKeys.detail(ticketId); + const current = queryClient.getQueryData(key); + if (current) { + queryClient.setQueryData(key, { + ...current, + messages: current.messages.map((m) => + m.clientMessageId === clientMessageId + ? { ...m, id: result.messageId, createdAt: result.sentAt, sendStatus: 'sent' } + : m, + ), + }); + } + }, + + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) }); + queryClient.invalidateQueries({ queryKey: ticketKeys.lists() }); + }, + }); +} diff --git a/client/src/services/tickets/hooks/useTicket.ts b/client/src/services/tickets/hooks/useTicket.ts new file mode 100644 index 0000000..6dd48d3 --- /dev/null +++ b/client/src/services/tickets/hooks/useTicket.ts @@ -0,0 +1,22 @@ +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 { 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. + */ +export function useTicket(ticketId: number | undefined) { + const { userId } = useTicketViewer(); + return useQuery({ + queryKey: ticketKeys.detail(ticketId ?? -1), + queryFn: () => ticketsApi.getTicket(ticketId as number, userId), + enabled: ticketId != null && ticketId > 0, + staleTime: TICKET_THREAD_STALE_TIME, + gcTime: TICKETS_GC_TIME, + }); +} diff --git a/client/src/services/tickets/hooks/useTicketThread.ts b/client/src/services/tickets/hooks/useTicketThread.ts new file mode 100644 index 0000000..0d75510 --- /dev/null +++ b/client/src/services/tickets/hooks/useTicketThread.ts @@ -0,0 +1,24 @@ +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 type { TicketMessage } from '../types'; +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. + */ +export function useTicketThread(ticketId: number | undefined) { + const { userId } = useTicketViewer(); + return useQuery({ + queryKey: ticketKeys.detail(ticketId ?? -1), + queryFn: () => ticketsApi.getTicket(ticketId as number, userId), + enabled: ticketId != null && ticketId > 0, + staleTime: TICKET_THREAD_STALE_TIME, + gcTime: TICKETS_GC_TIME, + select: (detail): TicketMessage[] => detail.messages, + }); +} diff --git a/client/src/services/tickets/hooks/useTicketViewer.ts b/client/src/services/tickets/hooks/useTicketViewer.ts new file mode 100644 index 0000000..521d0b9 --- /dev/null +++ b/client/src/services/tickets/hooks/useTicketViewer.ts @@ -0,0 +1,18 @@ +import { useAuth } from '@/context/auth'; +import { useActorRole } from '@/hooks'; +import { MOCK_VIEWER_USER_ID } from '../constants'; +import type { TicketAuthorRole } from '../types'; + +/** + * The current viewer's `{ userId, role }` for the tickets domain. `userId` drives `isMine` (whose bubble is + * whose); `role` labels an optimistic bubble's author. On the real path the authenticated `/me` id wins; + * under mock auth (no id yet) it falls back to the per-role mock "me" so bubbles still mirror correctly in + * whichever app is open (customer vs nurse). Not exported from the barrel — an internal helper the domain + * hooks share. + */ +export function useTicketViewer(): { userId: number; role: TicketAuthorRole } { + const [auth] = useAuth(); + const role = useActorRole(); + const userId = auth.currentUser?.id ?? MOCK_VIEWER_USER_ID[role] ?? MOCK_VIEWER_USER_ID.customer; + return { userId, role }; +} diff --git a/client/src/services/tickets/index.ts b/client/src/services/tickets/index.ts new file mode 100644 index 0000000..3efd553 --- /dev/null +++ b/client/src/services/tickets/index.ts @@ -0,0 +1,9 @@ +/** + * Tickets domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import + * types/keys/apis directly from their files when needed. + */ +export { useMyTickets } from './hooks/useMyTickets'; +export { useTicket } from './hooks/useTicket'; +export { useTicketThread } from './hooks/useTicketThread'; +export { useOpenTicket } from './hooks/useOpenTicket'; +export { usePostMessage } from './hooks/usePostMessage'; diff --git a/client/src/services/tickets/keys.ts b/client/src/services/tickets/keys.ts new file mode 100644 index 0000000..90534cc --- /dev/null +++ b/client/src/services/tickets/keys.ts @@ -0,0 +1,21 @@ +import type { TicketListParams } from './types'; + +/** + * React Query key factory for the tickets domain (hierarchical, per the `services/{domain}` pattern). + * + * The **filter object keys the list** so paging/filtering caches independently. There is a single + * **`detail(id)`** for a ticket: the b15 contract returns the whole thread (header + participants + + * messages) in one `GET /tickets/{id}` call — there is **no server pagination of messages** — so the + * thread is not a separate cache entry; `useTicketThread` is a `select` over `detail(id)` (mirroring the f8 + * `useBookingSessions = select over detail` precedent). `usePostMessage` optimistically mutates `detail(id)` + * and invalidates `lists()`/`detail(id)` on settle. + */ +export const ticketKeys = { + all: ['tickets'] as const, + + lists: () => [...ticketKeys.all, 'list'] as const, + list: (params: TicketListParams) => [...ticketKeys.lists(), params] as const, + + details: () => [...ticketKeys.all, 'detail'] as const, + detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const, +}; diff --git a/client/src/services/tickets/types.ts b/client/src/services/tickets/types.ts new file mode 100644 index 0000000..3c82e51 --- /dev/null +++ b/client/src/services/tickets/types.ts @@ -0,0 +1,160 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; + +/** + * Tickets domain — the **only sanctioned post-booking channel** (b15). There is no live chat and no direct + * nurse↔customer messaging by design: every conversation is a ticket that admin can read in full + * (anti-disintermediation + patient safety — see `product/business/12-messaging-and-emergencies.md`). A + * booking-coordination ticket is auto-created on confirmation; users also open support/refund tickets. + * + * Shapes derive from the b15 contract (`dev/contracts/domains/messaging-notifications-admin.md` + + * `dev/contracts/openapi/swagger.v1.json` → `TicketSummaryDto`/`TicketThreadDto`/…), mapped to a client + * model that carries the display state the wire omits. + * + * **Load-bearing rules (contract §"Critical rules" + phase §5):** + * - **`is_internal` NEVER reaches the user app.** The server strips internal admin messages from the user + * view (`GET /tickets/{id}`). We do **not** model an `isInternal` field here, never render internal + * styling, and never expose an internal-note affordance — and the real client mapper drops any message + * that arrives flagged internal (a defensive guard; such a leak is a backend defect to file, not render). + * - **`referenceCode` is stable + unique** (`"TKT-9F3K2A7Q"`) — quoted to support, shown prominently. + * - **Ticket↔booking/refund links are optional** — `bookingId`/`refundId` are both nullable. + * - **No phone numbers, ever.** The only sanctioned out-of-band surface is the post-confirmation + * emergency `tel:` (from the f8 care-instructions read), never a contact directory. + * + * Enums cross the wire as stable string codes — mirrored here as string-literal unions. + */ + +/** `ticket.status` (contract enum). */ +export type TicketStatus = 'open' | 'closed'; + +/** `ticket.category` (contract enum). A booking-coordination ticket is auto-created on confirmation. */ +export type TicketCategory = 'coordination' | 'support' | 'refund' | 'emergency'; + +/** + * The display role of a message author (from `ticket_participants.role_on_ticket`). `admin` renders as + * "support" in the user app — it is a **display label, never an auth source**. `system` is the fallback + * when a sender isn't in the participant list. + */ +export type TicketAuthorRole = 'customer' | 'nurse' | 'admin' | 'system'; + +/** Per-message optimistic send state — `sent` for any server-confirmed message. */ +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`. + */ +export interface TicketSummary { + id: number; + referenceCode: string; + subject: string | null; + status: TicketStatus; + category: TicketCategory; + 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; +} + +/** A participant on a ticket (`TicketParticipantDto`) — used to derive a message's author role. */ +export interface TicketParticipant { + userId: number; + roleOnTicket: TicketAuthorRole; +} + +/** + * A single message in a thread (client model). The wire `TicketMessageDto` carries only `senderId` (no + * author name, REQ-028) — we derive `authorRole` from the participant list and `isMine` from the viewer, + * and never show a raw name (privacy: the platform never turns a thread into a contact directory). + */ +export interface TicketMessage { + /** Server message id; `null` while an optimistic message is still pending. */ + id: number | null; + /** Client-generated id for an optimistic message; the reconcile key so we never double-render (§3.5). */ + clientMessageId?: string; + ticketId: number; + body: string; + authorRole: TicketAuthorRole; + /** UTC ISO-8601 — Shamsi display is the client's job. */ + createdAt: string; + isMine: boolean; + sendStatus: MessageSendStatus; +} + +/** + * The full thread (`TicketThreadDto`, user view). The contract returns the whole `messages[]` in one call + * (no server pagination), so this is the single cached detail; `useTicketThread` is a `select` over it. + */ +export interface TicketDetail { + id: number; + referenceCode: string; + subject: string | null; + status: TicketStatus; + category: TicketCategory; + bookingId: number | null; + refundId: number | null; + openedById: number; + closedAt: string | null; + participants: TicketParticipant[]; + messages: TicketMessage[]; +} + +/** List filters for `GET /tickets` (own, paginated). `status` optionally narrows the inbox. */ +export interface TicketListParams extends PageParams { + status?: TicketStatus; +} + +/** `OpenTicketCommand`. `bookingId`/`refundId` optional; a booking link requires the caller be a party. */ +export interface OpenTicketRequest { + category: TicketCategory; + subject?: string | null; + body: string; + bookingId?: number | null; + refundId?: number | null; +} + +/** `OpenTicketResult` — the new ticket's stable `referenceCode` (shown on the confirmation). */ +export interface OpenTicketResult { + ticketId: number; + referenceCode: string; + status: TicketStatus; + category: TicketCategory; +} + +/** + * `PostMessageCommand` body. `clientMessageId` is client-generated for optimistic reconciliation; the + * server has no field for it today (REQ-028 — an idempotency key), so the real client does not send it — it + * lives only in the cache to reconcile the pending bubble by identity. + */ +export interface PostMessageRequest { + body: string; + clientMessageId: string; +} + +/** `PostMessageResult`. */ +export interface PostMessageResult { + messageId: number; + ticketId: number; + sentAt: string; +} + +/** + * The tickets API seam — the real HTTP client and the in-memory mock both implement this; selection is by + * config (`USE_TICKETS_MOCK`), never scattered `if (mock)` checks. `getTicket` takes the viewer's user id + * so the real mapper can compute `isMine`; the mock is a self-contained world (its own "me"). + */ +export interface TicketsApi { + listMyTickets(params: TicketListParams): Promise>; + /** The full thread (user view — internal messages stripped). `viewerUserId` drives `isMine`. */ + getTicket(ticketId: number, viewerUserId?: number): Promise; + /** + * Open a ticket. `viewerUserId` is the opener's id — the real server infers the sender from auth, but the + * mock needs it to attribute the opening message to the right actor (customer vs nurse) and add them as a + * participant, so an optimistic/rendered opener bubble is correctly "mine". + */ + openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise; + postMessage(ticketId: number, body: PostMessageRequest): Promise; +} diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index bb9f570..8b70a27 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,6 +12,45 @@ for awareness. - **Requests filed:** frontend/requests/for-backend.md (yes/no) --> +## frontend-phase-14-b15 — Messaging (tickets) & notifications — 2026-07-10 +- **Shipped:** the social/communication layer for the customer **and** nurse apps (role decides chrome, not + the components; admin lens DEFERRED to f15). Two new domains — **`services/tickets`** (`useMyTickets` / + `useTicket` = the whole thread in one `detail(id)`, no message pagination / `useTicketThread` = select over + detail / `useOpenTicket` / `usePostMessage` **optimistic, draft-preserving**, reconcile by `clientMessageId`) + and **`services/notifications`** (`useNotifications` unread-first / `useUnreadCount` the **polled** bell — + count-only, stale-while-revalidate / `useMarkNotificationRead` + `useMarkAllRead` optimistic `setQueryData`). + Screens shared by both shells: **My Tickets inbox** (`/support/tickets`, `/nurse/support/tickets`) with + prominent `referenceCode` + unread indicator + null-safe linked-entity hint + Contact-support dialog (shows the + new `referenceCode`); **thread** (`/…/tickets/[id]`) role-aware bubbles + sticky composer; **notification + center** (`/notifications`, `/nurse/notifications`) unread-first, mark-read-on-open + mark-all, deep-links via + `notificationDeepLink`. **Notification bell** in the customer TopBar + the nurse shell (subscribes to the poll so + only it re-renders). **Emergency banner** + **"Get support / Open ticket"** on the f8 booking detail + (`BookingSupportEntry`, reuses the cached booking + nurse-gated care query — no refetch); a support icon in the + customer TopBar + a Support item in the nurse sidebar. New shared/tested composites: `MessageBubble`, + `TicketListCard`, `EmergencyBanner`, `NotificationRow`, `NotificationBellView`, `ContactSupportDialog` (+ + screens); `notificationDeepLink`/`parseNotificationData` unit-tested; `support`/`send` icons; `tickets` + + `notifications` i18n namespaces + `nav.support` (both locales). +- **Consumes:** dev/contracts/domains/messaging-notifications-admin.md (b15 tickets) + config-reference.md (b1 + notifications) + openapi/swagger.v1.json. Real & mapped 1:1 by the client APIs: `POST/GET /tickets`, + `GET /tickets/{id}`, `POST /tickets/{id}/messages`; `GET notifications/get_notifications`, + `get_unread_count`, `POST mark_notification_read` / `mark_all_read`. +- **Critical rules honoured:** `is_internal` is **never** modelled in the user-app types — both API mappers DROP + any internal message (server-strip mimic), no internal affordance anywhere; the emergency surface is a + **post-confirmation `tel:` playbook only** (nurse-gated care contact, no VoIP seam, never a general phone); + the unread **count** polls politely (60s interval + 45s staleTime + refetch-on-focus, auth-gated) and the list + is never polled; `data_json` is parsed into a typed union and degrades to no-deep-link. +- **Mocked client-side:** `services/tickets` (`USE_TICKETS_MOCK`) — b15 is live and `ticketsClientApi` maps it + 1:1, but the linked bookings are mock-primary and the summary lacks `unreadCount`/`lastMessageAt` (REQ-028); + `services/notifications` (`USE_NOTIFICATIONS_MOCK`) — b1 is live and mapped 1:1, but nothing dispatches + notifications client-side yet. Both default `true`; swap is one flag. See mocks-registry. +- **Gate:** npm run check green · npm run test:ci green (66 suites / 289 tests, +32) · production build compiles + + type-checks clean (a **pre-existing** "Missing .env variable" prerender guard fails on `/en/addresses` + + `/en/nurse/verification/identity` only — unrelated to this phase; sibling pages under the same modified shells + prerender fine). 6-dimension adversarial review with per-finding verification. +- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-028: `unreadCount`+`lastMessageAt` on the + ticket summary, a message author label / masked-name confirmation, a by-booking user ticket lookup, and an + optimistic `clientMessageId` idempotency field). + ## frontend-phase-13-b14 — Reviews & patient care records — 2026-07-10 - **Shipped:** the last feature-domain phase. Two new domains — **`services/reviews`** (`useNurseReviews` infinite published-only aggregate+list / `useReviewEligibility` / `useMyReviewForBooking` / `useCreateReview` diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index b7b1e00..141d7db 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -456,3 +456,35 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking). - **Status:** open + +## REQ-028 — Ticket inbox enrichment (unread + last-activity), message author name, by-booking lookup, optimistic idempotency — filed by frontend-phase-14-b15 — 2026-07-10 +- **Need:** Four additions the f14 messaging UI renders that the b15 `TicketSummaryDto`/`TicketThreadDto`/message + routes don't yet expose (the two ticket screens work today because `services/tickets` is **mock-primary** — + `USE_TICKETS_MOCK = true`; the real `ticketsClientApi` maps every live b15 route 1:1 and leaves these blank): + 1. **`unreadCount` + `lastMessageAt` on `TicketSummaryDto`.** The inbox card shows an **unread indicator** and + sorts/labels by **last activity**, but the wire summary carries only `createdAt` (no unread, no last-message + time). Today the client falls back to `createdAt` and hides the unread badge on the real path; the mock + supplies both. Proposed: `TicketSummaryDto { …, lastMessageAt: string (UTC), unreadCount: int }` (unread = + messages after the caller's `last_read_at`, internal excluded). + 2. **A message author display label on `TicketMessageDto`** (or on `TicketParticipantDto`). The thread bubble + labels each message by author, but the DTO carries only `senderId` — no name. The client derives the label + from the participant **role** (`author_customer`/`author_nurse`/`author_support`/`author_system`) and never + shows a raw identity (privacy — the thread is deliberately not a contact directory). Please **confirm this + role-label approach is intended**, or add a **server-masked** display name if a name is wanted. (No phone / + no real name is the safe default.) + 3. **A user-facing by-booking ticket lookup/filter.** `GET /tickets` (the user list) filters only by + `Status`/`ReferenceCode` — there's no `bookingId` filter (only the **admin** list has one). The + "Get support" action on a booking is specced to **jump to the existing coordination ticket** if one exists; + without a by-booking read the client can't find it. The mock approximates this via idempotent + `openTicket(category:'coordination', bookingId)` (returns the existing thread). Proposed: add a `bookingId` + query param to `GET /tickets`, or `GET /tickets/by_booking/{bookingId}?category=coordination`. + 4. **An idempotency / `clientMessageId` field on `POST /tickets/{id}/messages`.** The optimistic send generates a + `clientMessageId` to reconcile the pending bubble; the server has no field for it, so a retried send (network + flap) could create a duplicate server-side. The client does **not** send it today (reconciliation is + client-only). Proposed: accept an optional `clientMessageId`/`idempotencyKey` on the post-message body and + dedupe on it, echoing it back on `PostMessageResult`. +- **Why:** the f14 inbox (unread + last-activity), the thread (author labels + jump-to-coordination), and the + optimistic composer render these; the domain is mock-primary precisely because (1)/(3) aren't served and the + linked bookings are themselves mock-primary. When they land the swap is a single `USE_TICKETS_MOCK = false` flip + (no hook/component change) — `ticketsClientApi` already maps the live routes. +- **Status:** open diff --git a/dev/shared-working-context/reports/frontend-phase-14-report.md b/dev/shared-working-context/reports/frontend-phase-14-report.md new file mode 100644 index 0000000..2956a7e --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-14-report.md @@ -0,0 +1,168 @@ +# Frontend Phase 14 — Messaging (tickets) & notifications — report + +**Track:** frontend · **Depends on:** frontend-phase-8-b9 (booking detail) · **Consumes:** b15 tickets +([messaging-notifications-admin.md](../../contracts/domains/messaging-notifications-admin.md)) + b1 notifications +([config-reference.md](../../contracts/domains/config-reference.md)) · **Unlocks:** frontend-phase-15-b15 (admin +& partner consoles — reuses these services with the admin lens). Date: 2026-07-10. + +The mission: give families and nurses the **only** sanctioned way to talk after a booking — the admin-readable +**ticket** system — plus the in-app **notification center** + polled **bell**, and the **emergency playbook +banner** on booking/support entry. No live chat by design; structured, auditable, anti-disintermediation. + +--- + +## 1. What was built + +### Two new domain services (copied the `auth`/`reviews` skeleton exactly) +- **`services/tickets`** — `types.ts` (client model, deliberately **no `isInternal`**), `keys.ts` (one + `detail(id)` = the whole thread; there is no message pagination in the contract — a `select`-based + `useTicketThread` derives the messages, mirroring f8 `useBookingSessions = select over detail`), `constants.ts` + (`USE_TICKETS_MOCK`, stale times, `MOCK_SEND_FAIL_SENTINEL`, per-role `MOCK_VIEWER_USER_ID`), `apis/` + (`clientApi` maps b15 1:1 + **drops any internal message defensively**, `mockApi` PRIMARY, selecting `index`), + hooks `useMyTickets` / `useTicket` / `useTicketThread` / `useOpenTicket` / `usePostMessage` (+ internal + `useTicketViewer`), barrel. +- **`services/notifications`** — `types.ts` (`AppNotification` + discriminated `NotificationData` union), + `parse.ts` (`parseNotificationData` — snake/camel-tolerant, degrades to `{kind:'none'}`), `deepLink.ts` + (`notificationDeepLink(n, role)` — role-aware, `null` when nothing to open), `keys.ts`, `constants.ts` + (`USE_NOTIFICATIONS_MOCK`, poll interval/staleTime), `apis/` (`clientApi` maps b1 1:1, `mockApi` PRIMARY), + hooks `useNotifications` / `useUnreadCount` (**the poller**) / `useMarkNotificationRead` / `useMarkAllRead`, + barrel (+ re-exports `notificationDeepLink`). + +### Screens (shared by the customer **and** nurse apps — role decides chrome, not components) +- **My Tickets inbox** — `/support/tickets` (customer) + `/nurse/support/tickets` (nurse), both thin wrappers + over ``: prominent `referenceCode`, status chip (reused `StatusChip`), unread + indicator, null-safe linked-entity hint, relative Shamsi time; empty/loading-skeleton/error→retry; a + **Contact support** dialog (category → subject → message → submit → shows the new `referenceCode`). +- **Ticket thread** — `/…/tickets/[id]`: role-aware bubbles (mine vs theirs, **RTL-mirrored**), `referenceCode` + in the header, linked-booking chip, sticky **optimistic** composer; thread skeleton / empty / error states; + **never any internal-note content or affordance**. +- **Notification center** — `/notifications` + `/nurse/notifications` (``): + unread-first list, each row **marks read on open** (optimistic) and **deep-links via `notificationDeepLink`**; + **Mark all read**; empty ("به‌روز هستید") / loading / error→retry; a growing-limit "load more". +- **Notification bell** — `` in the customer TopBar (+ a support icon) and the nurse + shell (via a new `headerActions` slot on `TopBarAndSideBarLayout`; a Support item added to the nurse sidebar). + It subscribes to the polling count so **only the bell re-renders** on a change, never the shell. +- **Emergency banner + support entry on the f8 booking detail** — `` + (page-local glue, mounted below `BookingDetailView` on both the customer `/bookings/[id]` and nurse + `/nurse/visits/[id]` pages). It **reuses the cached booking query** (same key/viewer — no refetch) and, for + the **nurse on a post-confirmation booking**, the cached care-instructions read to surface the emergency + banner's `tel:` contact. Pre-confirmation → nothing. Customer → the support CTA only (no clinical phone). + +### Shared composites (co-located `*.test.tsx`) +`MessageBubble` (mine/theirs, RTL, no internal styling), `TicketListCard` (prominent ref code + unread + null-safe +link), `EmergencyBanner` (post-confirmation `tel:` playbook, no VoIP seam), `NotificationRow` (unread emphasis + +server title/body), `NotificationBellView` (pure badge), `ContactSupportDialog`. Plus unit tests for +`notificationDeepLink` and `parseNotificationData`. **+32 test cases; the full suite is 66 suites / 289 tests.** + +### i18n + icons + routes +`tickets` + `notifications` namespaces added to **both** `fa.json` and `en.json` (in sync, RTL-first) + `nav.support`; +`support` + `send` icons; route constants + role-aware path helpers (`ticketThreadPath`, `notificationsPath`, …). + +--- + +## 2. Critical rules honoured (phase §5) + +- **`is_internal` never reaches the user app.** No `isInternal` field in the user-app types, no internal styling, + no internal-note affordance anywhere. **Both** API mappers (`ticketsClientApi.mapThread` and the mock's + `toDetail`) **drop** any message flagged internal — the mock even stores an internal admin note it never + returns, so the no-leak behaviour is demonstrable. +- **No out-of-band channel except the post-confirmation emergency `tel:`** — drawn from the nurse-gated f8 care + read; the customer never sees a phone; no VoIP/calling seam; no SLA timers. +- **`referenceCode` shown prominently** in the inbox card + thread header. +- **Polite polling** — only `useUnreadCount` polls (60s `refetchInterval` + 45s `staleTime` + refetch-on-focus, + auth-gated); the notification **list is never polled**. +- **Optimistic send is draft-preserving** — `onMutate` appends a pending bubble; `onError` rolls back to the + snapshot; the composer keeps the draft (cleared **only** on server confirm) and retries; reconcile by + `clientMessageId` (no double-render); submit disabled while sending. The composer is **keyed on `ticketId`** + so a draft / in-flight send never crosses a thread→thread navigation. +- **`data_json` is a typed contract** — parsed into the discriminated union, deep-linked off that, degrades to + no-deep-link for an unknown type / missing id, never trusts a blob. +- **Tenancy / null links** — reads are server-scoped; ticket↔booking/refund chips render only when present. + +--- + +## 3. How to test (what a human can verify) — the phase §7 steps + +Run `npm run dev` (mocks are primary — `USE_TICKETS_MOCK`/`USE_NOTIFICATIONS_MOCK` default `true`): + +1. **Open a ticket from a booking.** Confirmed booking detail → **"Get support / Open ticket"** → a coordination + ticket opens (booking 5001 already has one → it **jumps to the existing thread**, else creates) and lands in + **My Tickets** with its `referenceCode`. *Expected:* it appears at the top of the inbox with no manual refresh. +2. **Post a message (optimistic).** Open a thread, type, send → the bubble appears **immediately** ("sending") then + resolves to "sent". Send the dev sentinel body **`/fail`** → the bubble rolls back, **the text stays in the + composer**, and the "send failed" hint shows; edit + resend succeeds. *Expected:* no duplicate bubble, no lost draft. +3. **No internal notes leak.** Ticket 1201 (coordination, booking 5001) has a **seeded internal admin note** — the + user thread shows **none of it**, no styling, no affordance. +4. **Notification bell.** From the console call `window`-reachable dev helper (or import) `__mockPushNotification('ticket_message','پیام جدید','{"ticket_id":1201}')` → the **bell badge increments** within ~60s. Open the center, + open a notification → it **marks read**, the **badge decrements**, and it **deep-links** (booking → `/bookings/{id}`, + ticket → `/support/tickets/{id}`, etc.). **Mark all read** clears the badge. *Expected:* the count is served from + cache instantly and revalidates in the background; the endpoint isn't hit more often than the interval. +5. **Emergency banner.** On a **confirmed** booking in the **nurse** app, the banner shows with a `tel:` link + (booking 5001/5002 have a seeded contact) + the playbook copy; on an **unconfirmed** booking it is **absent**. + The support entry (inbox) shows the playbook without a specific phone. +6. **RTL + locales.** Switch `fa`/`en`: bubbles mirror, the badge sits correctly, every string is translated. + `npm run check` + `npm run test:ci` pass. + +--- + +## 4. Mocks behind the two seams (swap = one flag) + +Both domains are **mock-primary** — recorded in [mocks-registry.md](./mocks-registry.md): + +- **`services/tickets` (`USE_TICKETS_MOCK = true`)** — `ticketsClientApi` maps every live b15 route 1:1 + (`POST/GET /tickets`, `GET /tickets/{id}`, `POST /tickets/{id}/messages`) and defensively drops any leaked + internal message. The mock is primary because the linked bookings are themselves mock-primary and the wire + summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**). It seeds 3 tickets (incl. the no-leak internal note + + a booking-linked coordination ticket with idempotent open), a clears-on-open unread count, an optimistic + append attributed to the current viewer (`openTicket` now takes the viewer id; `postMessage` tracks the + last-viewed thread's viewer), a closed-ticket `403`, and the `/fail` failure trigger. +- **`services/notifications` (`USE_NOTIFICATIONS_MOCK = true`)** — `notificationsClientApi` maps every live b1 + route 1:1. The mock is primary because nothing dispatches notifications client-side yet. It seeds an + unread-first feed spanning every deep-link class (each mapped through the **real** `parseNotificationData`) and + exposes `__mockPushNotification` for the bell-increment demo. + +Flip either flag to `false` (one line in `apis/index.ts` selection via `constants.ts`) — no hook/component change. + +--- + +## 5. Contract consumed + gaps filed + +Consumed: `messaging-notifications-admin.md` (b15) + `config-reference.md` (b1) + `openapi/swagger.v1.json` +(`TicketSummaryDto`/`TicketThreadDto`/`TicketMessageDto`/`OpenTicketCommand`/`OpenTicketResult`/`PostMessageCommand`/ +`PostMessageResult`; `NotificationDto`/`UnreadCountResult`/`MarkNotificationReadCommand`). + +Gap filed — **REQ-028** in [for-backend.md](../frontend/requests/for-backend.md): (1) `unreadCount` + `lastMessageAt` +on `TicketSummaryDto`; (2) a message author label / confirm masked-by-design (the client uses the participant +**role** label, never a raw name); (3) a **user-facing by-booking ticket lookup** (only the admin list filters by +`bookingId`) so "Get support" can jump to the existing coordination thread on the real path; (4) an optional +`clientMessageId`/idempotency field on `POST …/messages`. Business-rule drift check: the contract does **not** +expose `is_internal` to users (the user `GET /tickets/{id}` is server-stripped) — no drift; the client models it +accordingly. + +--- + +## 6. Review + gate + +- **6-dimension adversarial review** (leak-and-tenancy, optimistic-send, notifications-cache-poll, + conventions-rtl-i18n, contract-fidelity, reuse-bugs) with per-finding adversarial verification. **1 finding + survived verification** — the mock `openTicket` mis-attributing a nurse-opened ticket's first message to the + customer (module-global `lastViewerUserId` defaulted to the customer). **Fixed** by threading the viewer id + through the `openTicket` seam (real path ignores it; server infers the sender) and adding the opener as a + participant. A second flagged item (composer state crossing a thread→thread navigation) did **not** survive + verification, but the composer was **keyed on `ticketId`** anyway as a correct-by-construction safeguard. +- **Gate:** `npm run check` green; `npm run test:ci` green (66 suites / 289 tests, +32); production build + **compiles + type-checks clean**. A pre-existing "Missing .env variable" prerender guard fails SSG on + `/en/addresses` + `/en/nurse/verification/identity` only — unrelated to this phase (sibling pages under the + same modified shells prerender fine); not introduced here. + +--- + +## 7. Follow-ups for frontend-phase-15-b15 (the admin lens) + +- The admin **global ticket queue** (`GET /admin/tickets` + `GET /admin/tickets/{id}` with internal messages) and + the **internal-note composer** layer on top of `services/tickets` — reuse the domain, add an admin-only + `isInternal` toggle **only in the admin view** (never in the user-app types). +- The **support-alert worklist** (`support_alerts/*`) + **partner-center** console + **audit viewer** are b15/b1 + admin surfaces (DEFERRED here). +- When REQ-028 lands, flip `USE_TICKETS_MOCK = false`; when upstream domains dispatch real notifications, flip + `USE_NOTIFICATIONS_MOCK = false`. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 57d8321..73004ba 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -80,3 +80,5 @@ 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 | 🟡 | | `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 | 🟡 | +| `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 | 🟡 | +| `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 | 🟡 |