frontend phase 14

This commit is contained in:
hamid
2026-07-10 18:46:16 +03:30
parent 85488bc25b
commit bc51cf59b4
73 changed files with 3582 additions and 13 deletions
+13 -3
View File
@@ -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=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
│ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire
│ ├── 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.
+59
View File
@@ -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"
}
}
+59
View File
@@ -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} اعلان خوانده‌نشده"
}
}
@@ -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() {
<BookingDetailView bookingId={bookingId} viewerRole="customer" />
{bookingId > 0 && <CustomerBookingActions bookingId={bookingId} />}
{bookingId > 0 && <LeaveReviewCta bookingId={bookingId} />}
{bookingId > 0 && <BookingSupportEntry bookingId={bookingId} role="customer" />}
</Stack>
);
}
@@ -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 <NotificationCenter role="customer" />;
}
@@ -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 <TicketThreadScreen role="customer" ticketId={ticketId} />;
}
@@ -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 <TicketInboxScreen role="customer" />;
}
@@ -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 <NotificationCenter role="nurse" />;
}
@@ -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 <TicketThreadScreen role="nurse" ticketId={ticketId} />;
}
@@ -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 <TicketInboxScreen role="nurse" />;
}
@@ -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 (
<Stack sx={{ gap: 3 }}>
<BookingDetailView bookingId={bookingId} viewerRole="nurse" />
{bookingId > 0 && <BookingSupportEntry bookingId={bookingId} role="nurse" />}
{bookingId > 0 && <NurseVisitNotesPanel bookingId={bookingId} />}
</Stack>
);
@@ -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,
};
@@ -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<BookingSupportEntryProps> = ({ 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 (
<Stack sx={{ gap: 1.5, maxWidth: 640, mx: 'auto', width: '100%' }}>
{role === 'nurse' && confirmed ? (
<EmergencyBanner
contactName={care?.emergencyContactName}
contactPhone={care?.emergencyContactPhone}
onOpenTicket={() => setDialogOpen(true)}
/>
) : null}
<AppButton
variant="outlined"
color="primary"
startIcon="support"
onClick={() => setDialogOpen(true)}
sx={{ m: 0 }}
>
{t('open_from_booking')}
</AppButton>
<ContactSupportDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
role={role}
bookingId={bookingId}
defaultCategory="coordination"
/>
</Stack>
);
};
export default BookingSupportEntry;
@@ -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<string, Record<string, string>>;
return messages[namespace]?.[key] ?? key;
},
}));
import { ThemeProvider } from '../../theme';
import ContactSupportDialog from './ContactSupportDialog';
function renderDialog() {
render(
<ThemeProvider>
<ContactSupportDialog open onClose={() => {}} role="customer" defaultCategory="support" />
</ThemeProvider>,
);
}
describe('<ContactSupportDialog/> 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();
});
});
@@ -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<ContactSupportDialogProps> = ({
open,
onClose,
role,
bookingId,
defaultCategory = 'support',
}) => {
const t = useTranslations('tickets');
const locale = useLocale();
const router = useRouter();
const openTicket = useOpenTicket();
const [category, setCategory] = useState<TicketCategory>(defaultCategory);
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [created, setCreated] = useState<OpenTicketResult | null>(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 (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
{created ? (
<>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AppIcon icon="verified" size={22} color="var(--bal-success)" />
{t('created_title')}
</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 1 }}>
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('created_body')}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('ref_code_label')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 800, letterSpacing: 0.5, direction: 'ltr' }}>
{created.referenceCode}
</Typography>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<AppButton variant="text" color="primary" onClick={handleClose}>
{t('close')}
</AppButton>
<AppButton variant="contained" color="primary" onClick={goToThread}>
{t('view_thread')}
</AppButton>
</DialogActions>
</>
) : (
<>
<DialogTitle>{t('new_ticket_title')}</DialogTitle>
<DialogContent>
<Stack sx={{ gap: 2, pt: 1 }}>
<TextField
select
label={t('category_label')}
value={category}
onChange={(event) => setCategory(event.target.value as TicketCategory)}
fullWidth
size="small"
>
{OPENABLE_CATEGORIES.map((code) => (
<MenuItem key={code} value={code}>
{t(`category_${code}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('subject_label')}
value={subject}
onChange={(event) => setSubject(event.target.value)}
fullWidth
size="small"
/>
<TextField
label={t('message_label')}
value={body}
onChange={(event) => setBody(event.target.value)}
fullWidth
required
multiline
minRows={3}
size="small"
/>
{openTicket.isError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('open_failed')}
</Typography>
) : null}
</Stack>
</DialogContent>
<DialogActions>
<AppButton variant="text" color="primary" onClick={handleClose}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={submit}
disabled={openTicket.isPending || body.trim().length === 0}
>
{openTicket.isPending ? t('submitting') : t('submit')}
</AppButton>
</DialogActions>
</>
)}
</Dialog>
);
};
export default ContactSupportDialog;
@@ -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<string, unknown>) => {
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
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('<EmergencyBanner/> component', () => {
it('surfaces a tel: click-to-call when an emergency contact phone is provided', () => {
render(
<ThemeProvider>
<EmergencyBanner contactName="زهرا موسوی" contactPhone="09121234567" onOpenTicket={() => {}} />
</ThemeProvider>,
);
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(
<ThemeProvider>
<EmergencyBanner onOpenTicket={() => {}} />
</ThemeProvider>,
);
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(
<ThemeProvider>
<EmergencyBanner onOpenTicket={onOpenTicket} />
</ThemeProvider>,
);
fireEvent.click(screen.getByRole('button'));
expect(onOpenTicket).toHaveBeenCalledTimes(1);
});
});
@@ -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<EmergencyBannerProps> = ({ contactName, contactPhone, onOpenTicket }) => {
const t = useTranslations('tickets');
const phone = contactPhone?.trim() || null;
const name = contactName?.trim() || null;
return (
<Paper
elevation={0}
data-testid="emergency-banner"
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'var(--bal-error)',
borderInlineStart: '4px solid var(--bal-error)',
bgcolor: 'var(--bal-bg-paper)',
}}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="emergency" size={22} color="var(--bal-error)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-error)' }}>
{t('emergency_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('emergency_body')}
</Typography>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center', mt: 0.5 }}>
{phone ? (
<Button
component="a"
href={`tel:${phone}`}
variant="contained"
data-testid="emergency-call"
startIcon={<AppIcon icon="emergency" size={18} color="var(--bal-error-contrast)" />}
sx={{
bgcolor: 'var(--bal-error)',
color: 'var(--bal-error-contrast)',
fontWeight: 700,
'&:hover': { bgcolor: 'var(--bal-error)', filter: 'brightness(0.95)' },
}}
>
{name ? t('emergency_call', { name }) : t('emergency_call_generic')}
</Button>
) : null}
<Button
variant="text"
onClick={onOpenTicket}
startIcon={<AppIcon icon="support" size={18} color="var(--bal-primary)" />}
sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}
>
{t('emergency_open_ticket')}
</Button>
</Stack>
</Stack>
</Stack>
</Paper>
);
};
export default EmergencyBanner;
@@ -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<TicketMessage>) {
return render(
<ThemeProvider>
<MessageBubble
message={{ ...base, ...overrides }}
authorLabel="پشتیبانی"
timeLabel="۱۰:۰۰"
sendingLabel="در حال ارسال…"
/>
</ThemeProvider>,
);
}
describe('<MessageBubble/> 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();
});
});
@@ -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<MessageBubbleProps> = ({ message, authorLabel, timeLabel, sendingLabel }) => {
const isMine = message.isMine;
const sending = message.sendStatus === 'sending';
return (
<Box
data-testid="message-bubble"
data-mine={isMine ? 'true' : 'false'}
sx={{ display: 'flex', justifyContent: isMine ? 'flex-end' : 'flex-start', width: '100%' }}
>
<Stack
sx={{
maxWidth: '80%',
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: isMine ? 'var(--bal-primary)' : 'var(--bal-bg-paper)',
color: isMine ? 'var(--bal-primary-contrast)' : 'var(--bal-text-primary)',
border: isMine ? 'none' : '1px solid',
borderColor: isMine ? undefined : 'divider',
// Mine leans to the inline-end corner, theirs to the inline-start — a subtle "tail".
borderStartEndRadius: isMine ? 4 : undefined,
borderStartStartRadius: isMine ? undefined : 4,
opacity: sending ? 0.75 : 1,
}}
>
{!isMine ? (
<Typography variant="caption" sx={{ fontWeight: 700, color: 'var(--bal-primary)', mb: 0.25 }}>
{authorLabel}
</Typography>
) : null}
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{message.body}
</Typography>
<Typography
variant="caption"
sx={{
mt: 0.25,
alignSelf: 'flex-end',
opacity: isMine ? 0.8 : 1,
color: isMine ? 'inherit' : 'var(--bal-text-secondary)',
direction: 'ltr',
}}
>
{sending ? sendingLabel : timeLabel}
</Typography>
</Stack>
</Box>
);
};
export default MessageBubble;
@@ -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<MessageComposerProps> = ({ 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<HTMLDivElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
};
return (
<Stack sx={{ gap: 0.5 }}>
{postMessage.isError ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }} data-testid="composer-send-failed">
{t('send_failed')}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-end' }}>
<TextField
fullWidth
multiline
maxRows={4}
size="small"
placeholder={t('composer_placeholder')}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={onKeyDown}
disabled={disabled}
/>
<IconButton
onClick={submit}
disabled={disabled || sending || draft.trim().length === 0}
aria-label={t('send')}
sx={{
bgcolor: 'var(--bal-primary)',
color: 'var(--bal-primary-contrast)',
'&:hover': { bgcolor: 'var(--bal-primary-dark)' },
'&.Mui-disabled': { bgcolor: 'var(--bal-divider)', color: 'var(--bal-text-secondary)' },
}}
>
{sending ? (
<CircularProgress size={18} sx={{ color: 'var(--bal-primary-contrast)' }} />
) : (
<AppIcon icon="send" size={18} color="var(--bal-primary-contrast)" />
)}
</IconButton>
</Stack>
</Stack>
);
};
export default MessageComposer;
@@ -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 / errorretry states. Shared by the customer and nurse inbox pages (role decides
* the thread route + the ticket shell, not the components).
* @component TicketInboxScreen
*/
const TicketInboxScreen: FunctionComponent<TicketInboxScreenProps> = ({ role }) => {
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 (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{t('title')}
</Typography>
<AppButton
variant="contained"
color="primary"
startIcon="support"
onClick={() => setDialogOpen(true)}
sx={{ m: 0 }}
>
{t('contact_support')}
</AppButton>
</Stack>
<EmergencyBanner onOpenTicket={() => setDialogOpen(true)} />
{isLoading ? (
<Stack sx={{ gap: 1 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="rounded" height={96} />
))}
</Stack>
) : isError ? (
<Stack sx={{ gap: 1.5, alignItems: 'center', py: 4 }}>
<AppIcon icon="error" size={32} color="var(--bal-error)" />
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('error_body')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
{t('retry')}
</AppButton>
</Stack>
) : tickets.length === 0 ? (
<Stack sx={{ gap: 1, alignItems: 'center', py: 6 }}>
<AppIcon icon="support" size={36} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('empty_body')}
</Typography>
</Stack>
) : (
<Stack sx={{ gap: 1 }}>
{tickets.map((ticket) => (
<TicketListCard
key={ticket.id}
ticket={ticket}
categoryLabel={t(`category_${ticket.category}`)}
statusLabel={t(`status_${ticket.status}`)}
timeLabel={formatShamsiDateTime(ticket.lastMessageAt ?? ticket.createdAt, locale)}
linkedBookingLabel={ticket.bookingId != null ? t('linked_booking', { id: ticket.bookingId }) : null}
linkedRefundLabel={ticket.refundId != null ? t('linked_refund', { id: ticket.refundId }) : null}
onOpen={() => openThread(ticket.id)}
/>
))}
</Stack>
)}
<ContactSupportDialog open={dialogOpen} onClose={() => setDialogOpen(false)} role={role} defaultCategory="support" />
</Stack>
);
};
export default TicketInboxScreen;
@@ -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<TicketSummary>, extra: Partial<React.ComponentProps<typeof TicketListCard>> = {}) {
const onOpen = jest.fn();
render(
<ThemeProvider>
<TicketListCard
ticket={{ ...base, ...overrides }}
categoryLabel="هماهنگی"
statusLabel="باز"
timeLabel="۱۲:۰۰"
linkedBookingLabel={overrides.bookingId === null ? null : 'رزرو #۵۰۰۱'}
linkedRefundLabel={null}
onOpen={onOpen}
{...extra}
/>
</ThemeProvider>,
);
return onOpen;
}
describe('<TicketListCard/> 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);
});
});
@@ -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<TicketListCardProps> = ({
ticket,
categoryLabel,
statusLabel,
timeLabel,
linkedBookingLabel,
linkedRefundLabel,
onOpen,
}) => {
const hasUnread = (ticket.unreadCount ?? 0) > 0;
const subject = ticket.subject?.trim() || categoryLabel;
return (
<ButtonBase
onClick={onOpen}
data-testid="ticket-card"
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'var(--bal-bg-paper)',
}}
>
<Stack sx={{ gap: 0.75, width: '100%' }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon={ticketCategoryIcon(ticket.category)} size={18} color="var(--bal-primary)" />
<Typography variant="subtitle2" sx={{ fontWeight: hasUnread ? 800 : 600, flexGrow: 1 }}>
{subject}
</Typography>
{hasUnread ? (
<Box
data-testid="ticket-unread"
sx={{
minWidth: 20,
height: 20,
px: 0.5,
borderRadius: 10,
bgcolor: 'var(--bal-primary)',
color: 'var(--bal-primary-contrast)',
fontSize: 12,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{ticket.unreadCount}
</Box>
) : null}
<StatusChip status={ticketStatusKind(ticket.status)} label={statusLabel} />
</Stack>
<Typography
variant="caption"
sx={{ fontWeight: 700, letterSpacing: 0.5, color: 'var(--bal-primary)', direction: 'ltr', alignSelf: 'flex-start' }}
>
{ticket.referenceCode}
</Typography>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
{linkedBookingLabel ? (
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
{linkedBookingLabel}
</Typography>
) : null}
{linkedRefundLabel ? (
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
{linkedRefundLabel}
</Typography>
) : null}
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)', marginInlineStart: 'auto' }}>
{timeLabel}
</Typography>
</Stack>
</Stack>
</ButtonBase>
);
};
export default TicketListCard;
@@ -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<TicketMessageListProps> = ({ ticketId }) => {
const t = useTranslations('tickets');
const locale = useLocale();
const { data: messages, isLoading } = useTicketThread(ticketId);
if (isLoading) {
return (
<Stack sx={{ gap: 1 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="rounded" height={56} sx={{ width: '70%', alignSelf: i % 2 ? 'flex-end' : 'flex-start' }} />
))}
</Stack>
);
}
if (!messages || messages.length === 0) {
return (
<Stack sx={{ gap: 0.5, alignItems: 'center', py: 4 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('thread_empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('thread_empty_body')}
</Typography>
</Stack>
);
}
return (
<Stack sx={{ gap: 1 }}>
{messages.map((message) => (
<MessageBubble
key={message.clientMessageId ?? String(message.id)}
message={message}
authorLabel={t(authorLabelKey(message.authorRole))}
timeLabel={formatShamsiDateTime(message.createdAt, locale)}
sendingLabel={t('sending')}
/>
))}
</Stack>
);
};
export default TicketMessageList;
@@ -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<TicketThreadScreenProps> = ({ 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 (
<Stack sx={{ gap: 2, maxWidth: 720, mx: 'auto', width: '100%' }}>
<AppButton variant="text" color="primary" onClick={goBack} sx={{ m: 0, alignSelf: 'flex-start' }}>
{t('back_to_tickets')}
</AppButton>
{isLoading ? (
<Stack sx={{ gap: 1 }}>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={56} sx={{ width: '70%' }} />
<Skeleton variant="rounded" height={56} sx={{ width: '70%', alignSelf: 'flex-end' }} />
</Stack>
) : isError || !ticket ? (
<Stack sx={{ gap: 1.5, alignItems: 'center', py: 4 }}>
<AppIcon icon="error" size={32} color="var(--bal-error)" />
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('thread_error')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
{t('retry')}
</AppButton>
</Stack>
) : (
<>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1 }}>
<AppIcon icon={ticketCategoryIcon(ticket.category)} size={20} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
{ticket.subject?.trim() || t(`category_${ticket.category}`)}
</Typography>
<StatusChip status={ticketStatusKind(ticket.status)} label={t(`status_${ticket.status}`)} />
</Stack>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('ref_code_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 800, letterSpacing: 0.5, direction: 'ltr' }}>
{ticket.referenceCode}
</Typography>
{ticket.bookingId != null ? (
<Chip
size="small"
variant="outlined"
icon={<AppIcon icon="bookings" size={14} color="var(--bal-primary)" />}
label={t('linked_booking', { id: ticket.bookingId })}
onClick={() => goToBooking(ticket.bookingId as number)}
sx={{ marginInlineStart: 'auto' }}
/>
) : null}
</Stack>
</Stack>
</Paper>
<TicketMessageList ticketId={ticketId} />
<Box
sx={{
position: 'sticky',
bottom: 0,
bgcolor: 'var(--bal-bg-default)',
pt: 1,
pb: 0.5,
}}
>
{ticket.status === 'closed' ? (
<Typography
variant="body2"
sx={{ textAlign: 'center', color: 'var(--bal-text-secondary)', py: 1 }}
>
{t('closed_notice')}
</Typography>
) : (
// Key on ticketId so navigating thread→thread (the App Router reuses the [id] subtree)
// remounts the composer — its draft + in-flight/failed send state never cross tickets.
<MessageComposer key={ticketId} ticketId={ticketId} />
)}
</Box>
</>
)}
</Stack>
);
};
export default TicketThreadScreen;
@@ -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';
}
}
+19
View File
@@ -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';
@@ -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';
}
}
@@ -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<NotificationBellProps> = ({ role }) => {
const count = useUnreadCount();
const router = useRouter();
const locale = useLocale();
const t = useTranslations('notifications');
return (
<NotificationBellView
count={count}
label={t('bell_aria', { count })}
onClick={() => router.push(`/${locale}${notificationsPath(role)}`)}
/>
);
};
export default NotificationBell;
@@ -0,0 +1,28 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import NotificationBellView from './NotificationBellView';
describe('<NotificationBellView/> component', () => {
it('renders the unread count + aria label and fires onClick', () => {
const onClick = jest.fn();
render(
<ThemeProvider>
<NotificationBellView count={3} label="۳ اعلان خوانده‌نشده" onClick={onClick} />
</ThemeProvider>,
);
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(
<ThemeProvider>
<NotificationBellView count={0} label="اعلان‌ها" onClick={() => {}} />
</ThemeProvider>,
);
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
});
@@ -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<NotificationBellViewProps> = ({ count, label, onClick }) => {
return (
<IconButton onClick={onClick} aria-label={label} data-testid="notification-bell" color="inherit">
<Badge
badgeContent={count}
max={99}
overlap="circular"
sx={{ '& .MuiBadge-badge': { bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 700 } }}
>
<AppIcon icon="notifications" />
</Badge>
</IconButton>
);
};
export default NotificationBellView;
@@ -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 / errorretry states. Shared by the customer and
* nurse notification pages (role decides only the deep-link shell).
* @component NotificationCenter
*/
const NotificationCenter: FunctionComponent<NotificationCenterProps> = ({ 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 (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{t('title')}
</Typography>
{hasUnread ? (
<AppButton
variant="text"
color="primary"
onClick={() => markAll.mutate()}
disabled={markAll.isPending}
sx={{ m: 0 }}
>
{t('mark_all_read')}
</AppButton>
) : null}
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1 }}>
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} variant="rounded" height={72} />
))}
</Stack>
) : isError ? (
<Stack sx={{ gap: 1.5, alignItems: 'center', py: 4 }}>
<AppIcon icon="error" size={32} color="var(--bal-error)" />
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('error_body')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => refetch()}>
{t('retry')}
</AppButton>
</Stack>
) : items.length === 0 ? (
<Stack sx={{ gap: 1, alignItems: 'center', py: 6 }}>
<AppIcon icon="verified" size={36} color="var(--bal-success)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{t('empty_body')}
</Typography>
</Stack>
) : (
<Stack sx={{ gap: 1 }}>
{items.map((notification) => (
<NotificationRow
key={notification.id}
notification={notification}
timeLabel={formatShamsiDateTime(notification.createdAt, locale)}
onOpen={() => openNotification(notification)}
/>
))}
{total > items.length ? (
<AppButton
variant="text"
color="primary"
onClick={() => setLimit((current) => current + NOTIFICATIONS_PAGE_SIZE)}
disabled={isFetching}
sx={{ alignSelf: 'center' }}
>
{t('load_more')}
</AppButton>
) : null}
</Stack>
)}
</Stack>
);
};
export default NotificationCenter;
@@ -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<AppNotification>) {
const onOpen = jest.fn();
render(
<ThemeProvider>
<NotificationRow notification={{ ...base, ...overrides }} timeLabel="۱۰:۰۰" onOpen={onOpen} />
</ThemeProvider>,
);
return onOpen;
}
describe('<NotificationRow/> 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);
});
});
@@ -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<NotificationRowProps> = ({ notification, timeLabel, onOpen }) => {
const unread = !notification.isRead;
return (
<ButtonBase
onClick={onOpen}
data-testid="notification-row"
data-unread={unread ? 'true' : 'false'}
sx={{
display: 'block',
width: '100%',
textAlign: 'start',
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: unread ? 'var(--bal-primary-soft)' : 'var(--bal-bg-paper)',
}}
>
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start', width: '100%' }}>
<AppIcon icon={notificationIcon(notification.data.kind)} size={20} color="var(--bal-primary)" />
<Stack sx={{ gap: 0.25, flexGrow: 1, minWidth: 0 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
{unread ? (
<Box
data-testid="notification-unread-dot"
sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: 'var(--bal-primary)', flexShrink: 0 }}
/>
) : null}
<Typography variant="subtitle2" sx={{ fontWeight: unread ? 800 : 600 }}>
{notification.title}
</Typography>
</Stack>
{notification.body ? (
<Typography variant="body2" sx={{ color: 'var(--bal-text-secondary)' }}>
{notification.body}
</Typography>
) : null}
<Typography variant="caption" sx={{ color: 'var(--bal-text-secondary)' }}>
{timeLabel}
</Typography>
</Stack>
</Stack>
</ButtonBase>
);
};
export default NotificationRow;
@@ -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';
@@ -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';
}
}
+28
View File
@@ -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];
+26 -4
View File
@@ -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<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tShell = useTranslations('shell');
const onMobile = useIsMobile();
const router = useRouter();
const locale = useLocale();
const bottomNavItems: Array<LinkToPage> = useMemo(
() => [
@@ -36,7 +42,23 @@ const CustomerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
return (
<Stack sx={{ height: '100dvh' }}>
<Stack component="header">
<TopBar title={tShell('customer_app')} endNode={<DarkModeToggleButton />} />
<TopBar
title={tShell('customer_app')}
startNode={
<AppIconButton
icon="support"
color="inherit"
title={t('support')}
onClick={() => router.push(`/${locale}${ROUTES.SUPPORT_TICKETS}`)}
/>
}
endNode={
<Stack direction="row" sx={{ alignItems: 'center' }}>
<NotificationBell role="customer" />
<DarkModeToggleButton />
</Stack>
}
/>
</Stack>
<Box
+3
View File
@@ -1,6 +1,7 @@
'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { NotificationBell } from '@/components/notifications';
import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
@@ -26,6 +27,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ children }) => {
sidebarItems={sidebarItems}
title={tShell('nurse_app')}
variant="sidebarPersistentOnDesktop"
headerActions={<NotificationBell role="nurse" />}
>
{children}
</TopBarAndSideBarLayout>
+14 -5
View File
@@ -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<LinkToPage>;
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<Props> = ({ children, sidebarItems, title, variant }) => {
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant, headerActions }) => {
const [sidebarVisible, setSidebarVisible] = useState(false);
const onMobile = useIsMobile();
@@ -75,11 +77,18 @@ const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ 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 = (
<Stack direction="row" sx={{ alignItems: 'center' }}>
{headerActions}
<DarkModeToggleButton />
</Stack>
);
const { startNode, endNode } = sidebarProps?.anchor?.includes('left')
? { startNode: LogoButton, endNode: <DarkModeToggleButton /> }
: { startNode: <DarkModeToggleButton />, endNode: LogoButton };
? { startNode: LogoButton, endNode: headerControls }
: { startNode: headerControls, endNode: LogoButton };
return (
<Stack sx={stackStyles}>
+1 -1
View File
@@ -11,7 +11,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
{/*{process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}*/}
</QueryClientProvider>
);
}
@@ -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<Paginated<AppNotification>> => {
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<ApiEnvelope<Paginated<NotificationWire>>>(
`${API}/notifications/get_notifications?${query.toString()}`,
),
);
return { ...wire, items: wire.items.map(mapNotification) };
},
getUnreadCount: async (): Promise<number> => {
const wire = unwrap(await clientFetch<ApiEnvelope<{ count: number }>>(`${API}/notifications/get_unread_count`));
return wire.count;
},
markRead: async (notificationId: number): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/notifications/mark_notification_read`, {
method: 'POST',
body: JSON.stringify({ notificationId }),
});
},
markAllRead: async (): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${API}/notifications/mark_all_read`, { method: 'POST' });
},
};
@@ -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;
@@ -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<Paginated<AppNotification>> => {
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<number> => {
await sleep(MOCK_LATENCY_MS);
return notifications.filter((n) => !n.isRead).length;
},
markRead: async (notificationId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const n = notifications.find((x) => x.id === notificationId);
if (n) n.isRead = true;
},
markAllRead: async (): Promise<void> => {
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() });
}
@@ -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;
@@ -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();
});
});
@@ -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;
}
}
@@ -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<AppNotification> | 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<void, unknown, void, MarkAllContext>({
mutationFn: () => notificationsApi.markAllRead(),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: notificationKeys.all });
const prevCount = queryClient.getQueryData<number>(notificationKeys.unreadCount());
const prevLists = queryClient.getQueriesData<Paginated<AppNotification>>({ queryKey: notificationKeys.lists() });
queryClient.setQueriesData<Paginated<AppNotification>>({ queryKey: notificationKeys.lists() }, (data) =>
data ? { ...data, items: data.items.map((n) => ({ ...n, isRead: true })) } : data,
);
queryClient.setQueryData<number>(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() });
},
});
}
@@ -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<AppNotification> | 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<void, unknown, number, MarkReadContext>({
mutationFn: (notificationId) => notificationsApi.markRead(notificationId),
onMutate: async (notificationId) => {
await queryClient.cancelQueries({ queryKey: notificationKeys.all });
const prevCount = queryClient.getQueryData<number>(notificationKeys.unreadCount());
const prevLists = queryClient.getQueriesData<Paginated<AppNotification>>({ queryKey: notificationKeys.lists() });
const wasUnread = prevLists.some(([, data]) =>
data?.items.some((n) => n.id === notificationId && !n.isRead),
);
queryClient.setQueriesData<Paginated<AppNotification>>({ 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<number>(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() });
},
});
}
@@ -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,
});
}
@@ -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;
}
@@ -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';
+16
View File
@@ -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,
};
@@ -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' });
});
});
@@ -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<string, unknown> {
if (!dataJson) return {};
try {
const parsed: unknown = JSON.parse(dataJson);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** First key that resolves to a finite number (accepts a numeric string), else `null`. */
function num(obj: Record<string, unknown>, ...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;
}
@@ -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<Paginated<AppNotification>>;
/** Cheap index-backed count for the polling bell. */
getUnreadCount(): Promise<number>;
markRead(notificationId: number): Promise<void>;
markAllRead(): Promise<void>;
}
@@ -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<number, TicketAuthorRole>(
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<Paginated<TicketSummary>> => {
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<ApiEnvelope<Paginated<TicketSummaryWire>>>(`${API}/tickets?${query.toString()}`),
);
return { ...wire, items: wire.items.map(mapSummary) };
},
getTicket: async (ticketId: number, viewerUserId?: number): Promise<TicketDetail> => {
const wire = unwrap(await clientFetch<ApiEnvelope<TicketThreadWire>>(`${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<OpenTicketResult> =>
unwrap(
await clientFetch<ApiEnvelope<OpenTicketResult>>(`${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<PostMessageResult> =>
unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
method: 'POST',
body: JSON.stringify({ body: body.body }),
}),
),
};
+11
View File
@@ -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;
+291
View File
@@ -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 failureretry, 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<number, TicketAuthorRole>(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<Paginated<TicketSummary>> => {
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<TicketDetail> => {
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<OpenTicketResult> => {
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<PostMessageResult> => {
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 };
},
};
+47
View File
@@ -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,
};
@@ -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,
});
}
@@ -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<OpenTicketResult, unknown, OpenTicketRequest>({
mutationFn: (body) => ticketsApi.openTicket(body, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });
},
});
}
@@ -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<PostMessageResult, unknown, PostMessageVars, PostMessageContext>({
mutationFn: ({ body, clientMessageId }) => ticketsApi.postMessage(ticketId, { body, clientMessageId }),
onMutate: async ({ body, clientMessageId }) => {
const key = ticketKeys.detail(ticketId);
await queryClient.cancelQueries({ queryKey: key });
const previous = queryClient.getQueryData<TicketDetail>(key);
if (previous) {
const pending: TicketMessage = {
id: null,
clientMessageId,
ticketId,
body,
authorRole: role,
createdAt: new Date().toISOString(),
isMine: true,
sendStatus: 'sending',
};
queryClient.setQueryData<TicketDetail>(key, { ...previous, messages: [...previous.messages, pending] });
}
return { previous };
},
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<TicketDetail>(key);
if (current) {
queryClient.setQueryData<TicketDetail>(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() });
},
});
}
@@ -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,
});
}
@@ -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,
});
}
@@ -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 };
}
+9
View File
@@ -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';
+21
View File
@@ -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,
};
+160
View File
@@ -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
* nursecustomer 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.
* - **Ticketbooking/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<Paginated<TicketSummary>>;
/** The full thread (user view — internal messages stripped). `viewerUserId` drives `isMine`. */
getTicket(ticketId: number, viewerUserId?: number): Promise<TicketDetail>;
/**
* 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<OpenTicketResult>;
postMessage(ticketId: number, body: PostMessageRequest): Promise<PostMessageResult>;
}
@@ -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`
@@ -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
@@ -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 `<TicketInboxScreen role=…>`: 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` (`<NotificationCenter role=…>`):
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**`<NotificationBell role=…>` 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**`<BookingSupportEntry bookingId role>`
(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`.
@@ -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 | 🟡 |