# Frontend Phase 14 — Messaging (tickets) & notifications > **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** that pulls them back in > and deep-links them to the right place. There is no live chat by design: communication is structured, > auditable, and anti-disintermediation. This phase ships the customer/nurse "My Tickets" inbox + thread, > the notification bell with a polled unread count, and the prominent **emergency playbook banner** on > booking/support entry. It must never leak an internal admin note into a user's view. > > **Track:** frontend · **Depends on:** [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) (booking > detail — the ticket/support entry points hang off it) · backend **b15** contract > ([messaging-notifications-admin](../../contracts/domains/messaging-notifications-admin.md)) + the > **b1** notifications endpoints · **Unlocks:** [`frontend-phase-15-b15`](./frontend-phase-15-b15.md) > (admin & partner consoles — reuses the same ticket/notification services with the admin lens) · > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits We are at the social/communication layer of the customer & nurse apps. Bookings, sessions, EVV, payments, refunds, and reviews already exist; what is missing is the **channel** that ties them together for humans. Balinyaar deliberately has **no direct nurse↔customer messaging and no live chat** — all post-booking communication runs through **tickets** that admin can read in full (anti-disintermediation and patient-safety; see [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md)). A booking-coordination ticket is auto-created on confirmation; users also open support/refund tickets. In parallel, the **notification center** is the app's pull mechanism — in-app only (no push at launch), **polled**, with a typed `data_json` payload that tells the front-end where to deep-link. This phase builds **two new domain services** (`services/tickets`, `services/notifications`) and the screens on top of them, for the **customer and nurse apps only**. The admin lens over the very same data — the global ticket queue with the internal-note composer, the support-alert worklist — is **(DEFERRED to [`frontend-phase-15-b15`](./frontend-phase-15-b15.md))**; build the services so f15 reuses them without rewrite. **What already exists (do not rebuild) — confirmed from prior phases:** - The app shells, role-aware nav, the **5-tab customer bottom nav** and the nurse shell, the `services/{domain}` + TanStack Query caching pattern, the contracts→types pattern, the money/format utils, and the shared composites (status chip, stepper, cards) — [`frontend-phase-0`](./frontend-phase-0.md). - `AuthContext` with roles, the OTP login/role router — [`frontend-phase-1-b2`](./frontend-phase-1-b2.md). - The **booking detail & sessions** screen, the status timeline, and the nurse EVV check-in/out — these are where the **"Get support / Open ticket"** entry point and the nurse **emergency banner** attach — [`frontend-phase-8-b9`](./frontend-phase-8-b9.md). Reuse its booking-detail layout and booking query; **do not** rebuild booking fetching here. - Reviews & patient-record screens (the prior phase) — [`frontend-phase-13-b14`](./frontend-phase-13-b14.md); unrelated to this phase except that both consume the b14/b15 contract bundle. - `clientFetch`/`serverFetch` + `ApiError`, the toast bridge (already toasts 401/403/5xx — do **not** re-toast those in your hooks), the cookie manager, `APP_THEME_LTR/RTL`, `tokens.css`. > **Backend readiness note.** The contract you consume, > [`messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md), is > produced by **backend-phase-b15** (tickets) and the notification endpoints by **b1**. If a shape you > need is absent or wrong when you start, **do not guess and do not block** — append a request to > [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) > and mock behind the `services/tickets` / `services/notifications` seam meanwhile (operating-rules §6). > Record every mock in your report so it swaps cleanly. ## 2. Required reading (do this first) **Operating rules & checklists** - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you work and the tick-list. - [`../_shared/definition-of-done.md`](../_shared/definition-of-done.md) — the bar this phase adds to (§6). **Product / business truth (read before designing any screen)** - [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md) — **the core rules**: no live chat / no direct channel, ticket-only, `is_internal` admin notes, the emergency playbook ("call the surfaced emergency contact, then open a ticket"), why the family's phone is never exposed beyond the controlled emergency surface. - [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md) — in-app, polled notifications (no push), 90-day retention, deep-link via the typed payload; the admin tooling spine is **(DEFERRED to f15)**. - [`product/data-model/09-messaging.md`](../../../product/data-model/09-messaging.md) — `tickets` / `ticket_participants` / `ticket_messages`, `is_internal`, `reference_code`, optional booking/refund links. - [`product/data-model/11-notifications.md`](../../../product/data-model/11-notifications.md) — `notifications` (`data_json` typed payload, polled, 90-day read retention); `support_alerts` are internal-only (not in this phase's user app). **Contracts & types (the source of truth for shapes — do not guess)** - [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md) — the b15 ticket endpoints + the b1 notification endpoints, request/response shapes, enums, status codes, the user-vs-admin filtering note for `is_internal`. - [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) — the envelope, enums-as-codes, UTC timestamps (render Shamsi), pagination. **Code to mirror (existing patterns — copy, don't invent)** - `client/src/services/auth/*` (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`) — the exact shape every new domain service copies. - The booking-detail screen + its booking query from [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) — where the support/emergency entry points mount. - [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC boundary, layouts, i18n, theme, fetch services, anti-patterns. **Design** - **Invoke the `frontend-designer` skill** before building any screen. All visual work (the inbox list, the thread bubbles, the bell + badge, the emergency banner, message-send states) goes through it — brand palette, tokens, typography, the `App*` library, RTL rules. Do not hand-roll colours or spacing. ## 3. Scope — build this Two new domain services + the customer/nurse screens that consume them. Customer **and** nurse apps share these screens (role decides chrome, not the components). The admin global queue is **(DEFERRED to [`frontend-phase-15-b15`](./frontend-phase-15-b15.md))**. ### 3.1 `services/tickets` (new domain seam) Copy the `auth` service skeleton exactly. - **`types.ts`** — derive from the b15 contract: `Ticket` (`id`, `referenceCode`, `subject`/`category`, `status` enum e.g. `open|pending|closed`, optional `bookingId`/`refundId`, `lastMessageAt`, `unreadCount`), `TicketMessage` (`id`, `ticketId`, `body`, `authorRole` e.g. `customer|nurse|admin`, `authorName`, `createdAt`, `isMine`). **Do not model an `isInternal` field in the user-app types** — the server strips internal messages from the user view; modelling it invites a leak (§5). - **`keys.ts`** — query-key factory: `tickets.list(params)`, `tickets.detail(id)`, `tickets.thread(id, page)`. Deliberate `staleTime` (thread is short-lived; list moderate). - **`apis/clientApi.ts`** wrapping `clientFetch` — `listMyTickets(params)`, `getTicket(id)`, `getThread(id, page)`, `openTicket(body)`, `postMessage(ticketId, body, clientMessageId)`. - **`hooks/` (one hook per file):** `useMyTickets`, `useTicket`, `useTicketThread`, `useOpenTicket`, `usePostMessage`. `usePostMessage` is **optimistic** (§3.5). Mutations **invalidate** `tickets.list`/`tickets.thread` on settle so cached data isn't refetched needlessly. - **`index.ts`** barrel. ### 3.2 `services/notifications` (new domain seam) Copy the same skeleton. - **`types.ts`** — from the b1 contract: `AppNotification` (`id`, `type` enum, `title`/`body`, `isRead`, `createdAt`, `dataJson`), and a **discriminated-union `NotificationData`** typed off `type` (e.g. `booking_confirmed → { bookingId }`, `payment_captured → { bookingId }`, `payout_paid → { batchId }`, `review_published → { reviewId }`, `ticket_message → { ticketId }`). `data_json` is a *typed contract* — parse it into the union; never trust an arbitrary blob (§5). - **`keys.ts`** — `notifications.list(params)`, `notifications.unreadCount()`. - **`apis/clientApi.ts`** — `listNotifications(params)` (paged, unread-first), `getUnreadCount()`, `markRead(id)`, `markAllRead()`. - **`hooks/`:** `useNotifications`, `useUnreadCount` (the **polling** query — §3.4), `useMarkNotificationRead`, `useMarkAllRead`. Mark-read mutations **`setQueryData`** to flip `isRead` and decrement the cached count optimistically, then invalidate on settle — no full refetch of the list. - **`index.ts`** barrel. - A small **`notificationDeepLink(n: AppNotification): string`** util that maps the parsed `dataJson` to an in-app route (e.g. `bookingId → /bookings/{id}`, `ticketId → /support/tickets/{id}`). Centralise it so the bell and the center both deep-link identically. ### 3.3 Ticket screens (customer + nurse apps) - **"My Tickets" inbox** (`/support/tickets` in the customer shell; the equivalent under the nurse shell) — a paginated list of `Ticket` cards built from MUI/`App*` primitives: **`reference_code` shown prominently**, subject/category, status chip (reuse the f0 status chip), linked-booking/refund hint (handle the null link gracefully), relative Shamsi time, and an **unread indicator**. States: **empty** ("هنوز گفتگویی ندارید" / "No conversations yet"), loading skeleton, error→retry. A **"Contact support"** CTA opens a new ticket (category select → submit → confirmation showing the new `reference_code`). - **Ticket thread view** (`/support/tickets/[id]`) — role-aware message bubbles (mine vs theirs, mirrored for RTL), the `reference_code` in the header, linked-booking chip, a sticky composer at the bottom. States: thread skeleton, **empty** ("هنوز پیامی نیست، هماهنگی را شروع کنید" / "No messages yet — start coordinating"), per-message send states (sending / sent / **failed→retry, draft preserved**), not-a-participant / ticket-closed errors. **The user thread NEVER renders internal-note content or styling** — there is no internal-note affordance anywhere in the user app (§5). - **Open-from-booking entry point** — on the booking-detail screen from [`frontend-phase-8-b9`](./frontend-phase-8-b9.md), add a **"Get support / Open ticket"** action that opens a new ticket pre-linked to that `bookingId` (or jumps to the existing coordination ticket if one exists). Wire it through `services/tickets`; don't fetch the booking again — reuse its query. ### 3.4 Notification center + bell - **Notification bell** — a shared component in the app chrome (customer bottom-nav/top-bar and nurse shell) showing the **unread count badge**. The count comes from `useUnreadCount`, which **polls with stale-while-revalidate**: set a sensible `refetchInterval` (e.g. 60s), `refetchOnWindowFocus`, and a matching `staleTime` so you serve the cached count instantly and revalidate in the background — **do not hammer the endpoint** (§5). - **Notification center** (`/notifications`, or a drawer/sheet off the bell) — a paged, **unread-first** list; each row deep-links via `notificationDeepLink` and **marks itself read on open** (optimistic `setQueryData` flips `isRead` and decrements the cached unread count, invalidate on settle). A **"Mark all read"** action. States: **empty** ("به‌روز هستید" / "You're all caught up"), loading, error→retry, unread badge styling. Reuse the f0 status chip / list primitives; build the row as a shared composite with a co-located test. ### 3.5 Optimistic message send (the interaction that must feel instant) `usePostMessage` is `useMutation` with `onMutate`: generate a `clientMessageId`, `cancelQueries` on the thread key, snapshot, and `setQueryData` to append a **pending bubble**; on error **roll back to the snapshot but keep the typed text in the composer draft** so the user can retry without retyping; on success replace the pending bubble with the server message; `onSettled` invalidate the thread key. Reconcile by `clientMessageId` so you never double-render a message. The composer disables submit while sending but never clears the draft until the server confirms. ### 3.6 Emergency banner (the operational playbook) A shared **emergency banner** composite shown on the **booking detail (esp. nurse app)** and on the **support entry**: prominent, branded, copy = "For emergencies, call the emergency contact, then open a ticket" (both locales). It surfaces the **emergency-contact number as a `tel:` click-to-call link** drawn from the booking's (post-confirmation, decrypted) care instructions exposed by [`frontend-phase-8-b9`](./frontend-phase-8-b9.md)/the booking contract — **the platform never exposes a nurse's or family's general phone number; only this controlled, post-confirmation emergency surface** (§5). Pre-confirmation, render **nothing** (no placeholder). If the contact can't be loaded, still show the banner with a path to open a support ticket. This is `tel:` only — **do not build any calling/VoIP seam** (telephony is out-of-platform by design). ### 3.7 i18n + types housekeeping - Add a `messaging`/`tickets` and a `notifications` namespace to **both** `messages/en.json` and `messages/fa.json`, in sync, RTL-first (`fa` default). Every user-visible string is a key. - Types come from the published contract; any gap → append to [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and mock behind the seam. **(DEFERRED)** — admin global ticket queue + internal-note composer, support-alert worklist ([`frontend-phase-15-b15`](./frontend-phase-15-b15.md)); push/real-time message delivery, SignalR/SSE replacing polling, file attachments on messages, a first-class incidents/SLA UI (product-doc DEFERRED). Build the services so f15 layers the admin lens on top; do not stub admin screens here. ## 4. Mocks & seams in this phase This is a **frontend** phase — its only "seams" are the two domain services behind which a mock `clientApi` lives until the backend is merged (operating-rules §6, frontend-checklist last bullet). - **`services/tickets` seam** — if b15 isn't merged, ship a mock `clientApi` (same method signatures as the real one) returning realistic tickets/threads (with `reference_code`, **no internal messages** — the mock must mimic the server's user-view filtering), and an in-memory append for optimistic-send testing. - **`services/notifications` seam** — likewise: a mock returning a paged unread-first list, a decrementing unread count, and a `data_json` payload per type so `notificationDeepLink` can be exercised. Record both mocks in your **frontend report** and (since they're client-side mocks behind a seam) note them so f15/real-endpoint swap is a one-file change. **Do not** introduce backend seams (`IFieldEncryptor`, `INotificationDispatcher`, etc.) — those are b1/b15's; **reuse** the booking-detail care-instruction disclosure from [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) for the emergency contact, do not re-implement decryption client-side. ## 5. Critical rules you must not get wrong - **`is_internal` never reaches the user app.** Internal admin notes are server-filtered, but **do not leak the concept**: don't model an `isInternal` field in the user-app types, don't render internal styling, and never add an internal-note affordance in the customer/nurse thread. Treat any internal message in a user-view payload as a backend defect — file it via `for-backend.md`, don't render it. - **No direct out-of-band channel.** There is no live chat and no nurse↔customer phone exchange. The **only** sanctioned bypass is the **emergency-contact `tel:` surface**, and only **post-confirmation**. Never expose a nurse's or family's general phone number; never turn the emergency surface into a contact directory. The emergency path is an **operational playbook, not a real-time feature** — no SLA timers, no VoIP/calling seam, just `tel:` + "then open a ticket". - **Show `reference_code` prominently** in the inbox and thread header — it is what a user quotes to support and must be stable and visible. - **Poll the unread count politely.** `useUnreadCount` uses **stale-while-revalidate** (sensible `refetchInterval` + `staleTime`, refetch-on-focus) — serve the cached count, revalidate in the background, and **do not hammer** the endpoint. Don't poll the full notification list on an interval; only the count. - **Optimistic send is draft-preserving.** On failure, roll the thread back to its snapshot **but keep the user's text in the composer** with a retry; reconcile by `clientMessageId` to avoid double-render. Never clear the draft until the server confirms. - **`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`. - **Tenancy / null links.** A user sees only their own tickets and notifications (server-enforced — don't fetch by raw id you don't own). Ticket↔booking/refund links are **optional** — render the linked-entity chip only when present; handle `null` gracefully. - **Frontend conventions (non-negotiable):** fetch only through `clientFetch` in `services/{domain}`; TanStack Query caching with deliberate keys + invalidation/`setQueryData` (no needless refetch); minimise re-renders (`select`, stable refs, colocated state — the bell's fast-changing count must not re-render the whole shell); MUI primitives stay MUI, shared composites (notification row, emergency banner, message bubble) live at `src/components/…` with co-located tests; colours from `tokens.css`; both locales in sync; RTL-correct (mirror message bubbles); no layout above `[locale]`; respect the RSC/client boundary. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] `services/tickets` and `services/notifications` exist following the `auth` service shape (types from the b15/b1 contract, keys factory, `clientApi`, one hook per file, barrel), with mutations invalidating / `setQueryData`-ing cache. - [ ] **My Tickets inbox** (customer + nurse) lists tickets with prominent `reference_code`, status chip, unread indicator, null-safe linked-entity hint, and empty/loading/error states; **Contact support** opens a ticket and shows the new `reference_code`. - [ ] **Ticket thread** renders role-aware bubbles, **never any internal-note content/styling**, with empty/skeleton/error and per-message send states; **optimistic send** appends instantly and, on failure, rolls back while **preserving the composer draft** and offering retry. - [ ] **Open-from-booking** entry on the f8 booking detail opens/links a ticket without re-fetching the booking. - [ ] **Notification bell** shows a **polled** unread count (stale-while-revalidate, not hammering); the **center** lists unread-first, **marks read on open** (optimistic), has **Mark all read**, and **deep-links via `data_json`** through `notificationDeepLink`. - [ ] **Emergency banner** appears on booking detail (nurse) + support entry **only post-confirmation**, with a `tel:` click-to-call and the "call the emergency contact, then open a ticket" copy; hidden pre-confirmation; degrades to a support path if the contact can't load. No calling/VoIP seam built. - [ ] `messaging`/`tickets` + `notifications` i18n namespaces added to `en.json` **and** `fa.json` in sync; RTL verified (mirrored bubbles, badge placement). - [ ] Shared composites (notification row, message bubble, emergency banner) live at the shared level each with a co-located `*.test.tsx`; `npm run check` green; `npm run test:ci` green. - [ ] Any contract gap is in [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and the corresponding client-side mock is behind the seam and recorded in the report. - [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/tickets`, `services/notifications`, the new route segments (`/support/tickets`, `/notifications`), and the shared bell/banner components. ## 7. How to test (what a human can verify after this phase) Run `npm run dev` (with the b15/b1 endpoints live, or the seam mocks if not yet merged): 1. **Open a ticket from a booking.** Go to a confirmed booking's detail → tap **"Get support / Open ticket"** → a ticket opens pre-linked to that booking and lands in **My Tickets** with its `reference_code` shown. *Expected:* the new ticket appears at the top of the inbox without a manual refresh. 2. **Post a message (optimistic).** Open the thread, type, send → the bubble appears **immediately** with a "sending" state, then resolves to "sent". Kill the network (or trigger the mock's error path) and send again → the bubble shows **failed→retry** and **the text stays in the composer**; retry succeeds when the network returns. *Expected:* no duplicate bubble, no lost draft. 3. **No internal notes leak.** With a ticket that has an admin internal note (mock or seeded), confirm the **user thread shows none of it** — no hidden styling, no affordance. *Expected:* user view is identical whether or not internal notes exist. 4. **Notification bell unread count.** Trigger a notification (e.g. a new ticket message) → the **bell badge increments** within the poll interval. Open the **center**, open a notification → it **marks read**, the **badge decrements**, and it **deep-links** to the right screen (booking/ticket/etc.) via `data_json`. **Mark all read** clears the badge. *Expected:* count is served from cache instantly, revalidates in the background, and the endpoint is **not** hit more often than the interval (check the network tab). 5. **Emergency banner.** On a **confirmed** booking (nurse app), the **emergency banner** is visible with a `tel:` link and the playbook copy; on an **unconfirmed** booking it is **absent** (not a placeholder). *Expected:* tapping the link initiates a phone call; "open a ticket" routes to the ticket flow. 6. **RTL + locales.** Switch `fa`/`en`: bubbles mirror, the badge sits correctly, every string is translated. `npm run check` and `npm run test:ci` pass. ## 8. Hand off & document (close the phase) - **Docs to update:** `client/CLAUDE.md` *Project Structure* — add `services/tickets`, `services/notifications`, the `/support/tickets` + `/notifications` route segments, the shared notification-bell / message-bubble / emergency-banner components, and a one-line note on the polling unread-count pattern and the optimistic-send/draft-preserve pattern so f15 reuses them. If you discover a business-rule drift (e.g. the contract exposes `is_internal` to users), record it and file the request — do not invent rules. - **Contract to consume:** [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md) (b15 tickets) + the b1 notification endpoints — derive all types from it; **never** guess shapes. Any missing field/filter/endpoint → append a `REQ-NNN` entry to [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) (e.g. a `clientMessageId`/idempotency field for optimistic send, an `unreadCount` on the ticket list, the emergency-contact field on the booking payload) and mock behind the seam meanwhile. - **Handoff & report:** append your phase summary to [`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write `../../shared-working-context/reports/frontend-phase-14-report.md` (operating-rules §7) — what was built, **what is now testable and exactly how** (the 6 steps above), which client-side mocks sit behind the two seams and how f15/the real endpoint swaps them, the contracts consumed, and the follow-ups left for [`frontend-phase-15-b15`](./frontend-phase-15-b15.md) (the admin lens over these services). Update the mock registry [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) for the two client-side service mocks. - **Memory:** save a `project` memory note (with a `MEMORY.md` pointer) for the non-obvious decisions this phase locks in — the user-app types deliberately omit `isInternal`, the polled-unread-count stale-while-revalidate pattern, the optimistic draft-preserving send + `clientMessageId` reconciliation, the `data_json`→`notificationDeepLink` typed-union mapping, and the post-confirmation `tel:`-only emergency surface (no VoIP seam).