ui phase 10
This commit is contained in:
@@ -934,3 +934,47 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
- **Proposed shape:** `PatientDto { …, avatarUrl: string | null }` + `POST patients/{id}/avatar` (multipart),
|
||||
same seam shape as the nurse profile's `uploadAvatar`.
|
||||
- **Status:** deferred, non-blocking — no UI currently reads a patient `avatarUrl`; initials-only ships either way.
|
||||
|
||||
## REQ-059 — Ticket inbox enrichment: last-message preview + author role + an unread-total read — filed by ui-phase-10 — 2026-07-19
|
||||
- **Need:** Extends REQ-028 (delivered: `unreadCount`/`lastMessageAt` are now real on `TicketSummaryDto`).
|
||||
Three more additions:
|
||||
1. `lastMessagePreview: string | null` on `TicketSummaryDto` — the first ~80 chars of the last
|
||||
**non-internal** message (server-truncated so an internal admin note can never leak into a preview
|
||||
snippet, same boundary REQ-028/the contract already enforces for the thread).
|
||||
2. `lastAuthorRole: 'customer' | 'nurse' | 'admin' | 'system' | null` on `TicketSummaryDto` — the last
|
||||
visible message's author role, so the inbox card can label the preview ("پرستار: …" vs "شما: …").
|
||||
3. A cheap unread-**total** read for the caller (e.g. `GET tickets/unread_count`, mirroring
|
||||
`notifications/unread_count`) — a single number summed server-side across the caller's tickets.
|
||||
- **Why:** The inbox redesign (phase §3.1) turns the ticket list into a real messaging-app inbox: a bold
|
||||
subject + one-line last-message snippet + unread pill + relative last-activity time, and a small unread
|
||||
badge on the chrome's support entry (TopBar icon on the customer shell, the sidebar item on the nurse
|
||||
shell). `unreadCount`/`lastMessageAt` already ship (REQ-028); the preview/author-role fields don't exist
|
||||
anywhere in the wire summary, and there is no chrome-badge-sized aggregate read at all today (fetching the
|
||||
whole first page of tickets just to sum `unreadCount` client-side doesn't scale and isn't the badge's job).
|
||||
The client's `TicketSummary` type carries `lastMessagePreview`/`lastAuthorRole` as optional, mock-tolerant
|
||||
fields (`services/tickets/types.ts`); the mock computes them from its own store
|
||||
(`services/tickets/apis/mockApi.ts`); the real `ticketsClientApi` maps them to `null` and the card
|
||||
degrades to subject + status chip + time — never an empty slot, never a fake "0 unread". The chrome badge
|
||||
is behind a new `TicketsApi.getUnreadTotal()` seam method — the mock sums its tickets' `unreadCount`, the
|
||||
real implementation returns `null` (no signal) until this lands, and the badge simply doesn't render.
|
||||
- **Proposed shape:** `TicketSummaryDto { …, lastMessagePreview: string | null, lastAuthorRole: string | null
|
||||
}`; `GET tickets/unread_count → { unreadCount: number }` (or fold the total into the existing list envelope
|
||||
as a `totalUnreadCount` sibling of `items`/`total`, whichever fits the pagination envelope better).
|
||||
- **Status:** open — mock-only; the real path shows subject + status + Shamsi/relative time with no preview
|
||||
line, and the support-entry chrome badge renders nothing until a signal exists.
|
||||
|
||||
## REQ-060 — Ticket message photo attachments — filed by ui-phase-10 — 2026-07-19
|
||||
- **Need:** Upload + serve a photo attachment on a ticket message, using the existing object-storage seam
|
||||
(`IObjectStorage`, already wired for verification documents — REQ-006's avatar upload is the closest
|
||||
precedent). A message-attachment linkage (one or more attachments per message), server-side size/type
|
||||
validation (images only, a sane size ceiling), and a signed-URL-style read for displaying them in a thread.
|
||||
- **Why:** Refund and coordination tickets routinely need photo evidence (a receipt, a care-situation photo)
|
||||
and today the only channel (tickets, by product design — no free chat) has no way to attach one. The
|
||||
composer's attachment button + pending-upload chip are **designed but gated** behind
|
||||
`TICKETS_ATTACHMENTS_ENABLED` (`services/tickets/constants.ts`, default `false`) — no dead button ships in
|
||||
production; flipping the flag once this lands is the only client change needed.
|
||||
- **Proposed shape:** `POST tickets/{id}/messages` gains an optional `attachmentIds: string[]` (uploaded
|
||||
beforehand via a new `POST tickets/{id}/attachments`, multipart, returning an id + a short-lived signed
|
||||
URL), and `TicketMessageDto` gains `attachments: { id: string, url: string, contentType: string }[]`.
|
||||
- **Status:** deferred — the affordance is designed and gated off; nothing renders until this lands and the
|
||||
capability flag flips.
|
||||
|
||||
@@ -130,7 +130,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT
|
||||
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false` — `reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888` → `canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
|
||||
| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper** — `mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) |
|
||||
| `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false` — `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||
| `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1; **REQ-028 is now delivered** (`unreadCount`/`lastMessageAt` + `clientMessageId` idempotency are real on the wire), so `USE_TICKETS_MOCK = false` by default — the mock stays available for offline/demo use. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`**; `postMessage` appends as the current viewer, throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path — **ui-phase-10:** a failed send now flips the bubble to `sendStatus:'failed'` in place instead of rolling it back, and a retry re-mutates the same `clientMessageId`). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types**. **ui-phase-10 additions:** `toSummary()` now also computes `lastMessagePreview` (first ~80 chars of the last non-internal message) + `lastAuthorRole` (REQ-059 gap — real path maps both `null`, card degrades gracefully); a new `getUnreadTotal()` sums `unread` across every seeded ticket for the chrome support-badge (REQ-059 — real path returns `null`, badge doesn't render) | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default **false** — REQ-028 delivered) | Deliver **REQ-059** (`lastMessagePreview`/`lastAuthorRole` on the summary + a cheap unread-total read) to fully retire the mock's enrichment role; **REQ-060** (message photo attachments) gates the composer's designed-but-off attachment affordance (`TICKETS_ATTACHMENTS_ENABLED`, default `false`). `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively) | 🟢 real by default (REQ-028); 🟡 REQ-059/060 gaps remain mock-only |
|
||||
| `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false` — `notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) |
|
||||
| `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 0–1 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 |
|
||||
| `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false` — `partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 |
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# UI Phase 10 — Messaging & notifications — Report (2026-07-19)
|
||||
|
||||
## What was built
|
||||
|
||||
### Ticket inbox → a real inbox (`TicketInboxScreen`, `TicketListCard`)
|
||||
- **Status filter chips** (همه/باز/بسته) + **load-more paging**: `useMyTickets({ status, pageSize, page: 1 })`
|
||||
with a growing `pageSize` (the same "growing limit" pattern `NotificationCenter` already used), reset to
|
||||
`TICKETS_PAGE_SIZE` on a filter change. `keepPreviousData` (already set on the hook) means a chip switch
|
||||
never flashes.
|
||||
- **`TicketListCard` redesigned around activity:** bold subject + unread pill (unchanged behavior — it turns
|
||||
out `unreadCount`/`lastMessageAt` are **already real** on the wire, REQ-028 having been delivered since the
|
||||
audit was written — see "Stale audit note" below) **plus** a one-line last-message preview prefixed with
|
||||
the translated author label (e.g. "پرستار: ساعت ۵ عصر هماهنگ شد") and a **relative** last-activity time
|
||||
(`formatRelativeTime`, decaying to Shamsi past 7 days) instead of the old absolute
|
||||
`formatShamsiDateTime`. The preview/author-role fields don't exist on the wire summary yet — filed as
|
||||
**REQ-059** — so `TicketSummary.lastMessagePreview`/`lastAuthorRole` are optional and the card renders
|
||||
subject + status + time only when they're absent (no empty slot, never a fake value).
|
||||
- **Support-entry unread badge in the chrome:** a new `useSupportUnreadTotal()` read behind the `services/tickets`
|
||||
seam (mock sums its tickets' `unreadCount`; real returns `null` — REQ-059). Mounted as a `Badge` on the
|
||||
customer TopBar's support icon (only on the 5 root tabs) and on the nurse sidebar's support item (a new
|
||||
`LinkToPage.badgeCount` prop, rendered by `SideBarNavItem` — a minimal, intentional touch to the
|
||||
phase-2-owned `layout/` files, noted per the ownership rules).
|
||||
- **Emergency affordance right-sized:** the permanent alarm-red `EmergencyBanner` is gone from both ticket
|
||||
inboxes, replaced by a new **`EmergencyPlaybookRow`** — a compact, neutral, collapsed-by-default row that
|
||||
expands to rewritten copy (never instructs calling a number the inbox can't show) + the "open a ticket"
|
||||
action. `EmergencyBanner` itself is untouched and stays exactly where it belongs: the nurse's
|
||||
post-confirmation booking detail (`BookingSupportEntry`), the only surface with a real `tel:` contact.
|
||||
- Unified the messaging surface width (inbox now 720px, matching the thread — was 640/720 mismatched).
|
||||
|
||||
### Live thread (`useTicket`/`useTicketThread`, `useThreadScroll`, `TicketMessageList`)
|
||||
- **Polls while mounted:** `useTicket`/`useTicketThread` both got `refetchInterval: TICKET_THREAD_REFETCH_INTERVAL`
|
||||
(15s, new constant) — TanStack Query scopes this to active observers, so it's automatically off once the
|
||||
thread unmounts. No `refetchIntervalInBackground`. The 60s notification-count poll stays the only
|
||||
always-on poll (§5 posture unchanged).
|
||||
- **`useThreadScroll`** — a new, reusable scroll-orchestration hook (`components/messaging/useThreadScroll.ts`,
|
||||
exported from the barrel): opens the thread scrolled to the **newest** message (instant, on first load),
|
||||
always scrolls to a newly-sent (mine) message, and auto-scrolls on a received message only when the
|
||||
viewer is already within ~120px of the bottom — otherwise shows a floating «پیام جدید ↓» pill. Uses an
|
||||
`IntersectionObserver` on a bottom sentinel rather than tracking a specific scroll container, so it works
|
||||
whether the *page* or an inner box scrolls (guarded for environments without `IntersectionObserver`, e.g.
|
||||
jsdom). Deliberately exported as a consumable for **phase 11's admin thread**, which has the inverse bug
|
||||
(a 520px scrollbox that also opens at the top).
|
||||
- **Chat typography** (`TicketMessageList`): messages are grouped into date-separator / system-event /
|
||||
consecutive-same-author blocks (`buildBlocks`); centered Shamsi separators read امروز/دیروز/an absolute
|
||||
date (`formatDaySeparator`, new in `utils/date.ts`, string-equality comparison — never raw ms diffs, so
|
||||
it's calendar-exact); consecutive messages from the same author collapse under one author label; bubbles
|
||||
show **hh:mm only** (`formatShamsiTime`, new in `utils/date.ts`); `authorRole === 'system'` renders as a
|
||||
centered neutral chip, never a bubble.
|
||||
- **Fixed the bidi timestamp bug:** removed the forced `direction: 'ltr'` from the bubble's time label
|
||||
(`MessageBubble.tsx`) — with hh:mm-only Persian-digit stamps, no forced direction is needed; the Latin
|
||||
`referenceCode` keeps its forced LTR everywhere it's shown.
|
||||
- **Sticky composer separation:** the composer's sticky box now has a real `borderTop` (divider token)
|
||||
instead of a bare `bgcolor` block, so bubbles no longer scroll flush into the input.
|
||||
|
||||
### Composer (`MessageComposer`, `TicketConversationPanel`, `usePostMessage`, `useDiscardFailedMessage`)
|
||||
- **Enter semantics per input modality:** `useMediaQuery('(pointer: coarse)')` branches the composer's
|
||||
`onKeyDown` — desktop (fine pointer): Enter sends, Shift+Enter newlines (unchanged); touch (coarse
|
||||
pointer): Enter always inserts a newline, the send button is the only send path.
|
||||
- **Retry-in-place on failure** — the invariant-preserving rewrite:
|
||||
- `usePostMessage`'s `onMutate` is now **idempotent on `clientMessageId`**: a fresh send appends a pending
|
||||
bubble; a retry (the same id already in the cache as `sendStatus: 'failed'`) flips it back to `sending`
|
||||
in place instead of appending a duplicate.
|
||||
- `onError` **no longer rolls the thread back** — it flips the bubble to `sendStatus: 'failed'` and leaves
|
||||
it in place with its typed body.
|
||||
- `MessageBubble`'s failed state renders `role="alert"` (screen readers are told), the error-accented
|
||||
bubble, a «تلاش مجدد» action (re-mutates the same `clientMessageId`), and a discard (delete) action.
|
||||
- A new `useDiscardFailedMessage()` (not a mutation — nothing was ever sent) removes the failed message
|
||||
from the cached thread; `TicketConversationPanel` wires its `onDiscard` to also restore the message's
|
||||
body into the composer's draft — **a failure never loses typed text**, and `clientMessageId`
|
||||
reconciliation still never double-renders. Both invariants are proven by the updated
|
||||
`MessageBubble.test.tsx`.
|
||||
- **`TicketConversationPanel`** (new) is the component that makes this possible without leaking state
|
||||
across tickets: it owns the one `usePostMessage`/draft instance both `TicketMessageList`'s retry/discard
|
||||
and the composer's own send now share, and it's mounted `key={ticketId}` from `TicketThreadScreen` — so
|
||||
navigating thread→thread (the App Router reuses the `[id]` subtree) remounts the whole panel, exactly
|
||||
preserving the pre-existing "drafts/in-flight state never cross tickets" invariant (previously enforced
|
||||
by keying only `MessageComposer`).
|
||||
- **Attachment affordance — designed, gated:** the composer has an attachment icon button + the capability
|
||||
flag `TICKETS_ATTACHMENTS_ENABLED` (`services/tickets/constants.ts`, default `false`) — it renders nothing
|
||||
until the seam lands (REQ-060). No dead button ships.
|
||||
- **RTL send-icon mirror:** `send` added to `AppIcon`'s `DIRECTIONAL_ICONS` set (config.ts) — phase 0's
|
||||
auto-mirroring mechanism already existed, this was a one-line registry addition.
|
||||
|
||||
### Emergency affordance (recap)
|
||||
- Full alarm-red `EmergencyBanner` + `tel:` stays **only** on the nurse's post-confirmation booking read
|
||||
(untouched). Both ticket inboxes now show the compact `EmergencyPlaybookRow` instead.
|
||||
|
||||
### Notification center (`NotificationCenter`, `NotificationRow`, `notificationIcon.ts`)
|
||||
- **Day grouping:** امروز/دیروز/اینهفته, then a Shamsi date header for anything older — grouped **in list
|
||||
order** (the server's unread-first-then-newest ordering is preserved per §5's "keep the mark-read UX as
|
||||
is"; a bucket can recur if an older unread item sits above newer read ones — a deliberate, documented
|
||||
trade-off rather than silently reordering the list).
|
||||
- **Relative timestamps** (`formatRelativeTime`, decaying to Shamsi) replace the absolute
|
||||
`formatShamsiDateTime` on every row.
|
||||
- **Per-kind tinted icon container:** a new `notificationTint(kind)` helper (`notificationIcon.ts`) — booking
|
||||
teal, payout success-green, ticket/support terracotta (the deliberate "a human from Balinyaar" accent),
|
||||
refund info, nurse-trust the dedicated `--bal-trust` token, `none` fully neutral. All existing `--bal-*-soft`
|
||||
tokens — no new tokens needed.
|
||||
- **Non-navigable rows render non-interactive:** `NotificationRow` now takes a `navigable` prop (the caller
|
||||
computes it from `notificationDeepLink(...) != null`). Navigable rows are a real `ButtonBase` with a
|
||||
trailing chevron (`forward` icon) and a visible `:focus-visible` ring; non-navigable rows render as a
|
||||
plain, static surface (no ripple, no pointer cursor, no chevron) that still marks the notification read on
|
||||
click/Enter/Space (kept keyboard-operable via `role="button" tabIndex={0}`, even though visually inert).
|
||||
- **Kept unchanged:** mark-read-on-open (optimistic), mark-all-read, load-more.
|
||||
|
||||
### Bell behavior (`NotificationBell`, `NotificationBellPopover`, `NotificationBellView`)
|
||||
- **Desktop popover on the nurse shell:** `NotificationBell` now branches on `role === 'nurse' && isDesktop`
|
||||
(`useMediaQuery(theme.breakpoints.up('md'))`) — opens a new `NotificationBellPopover` (5 most recent,
|
||||
mark-all-read, «مشاهده همه» to the full center) instead of navigating. The popover fetches **on open**
|
||||
(`useNotifications(5, { enabled: open })` — `useNotifications` gained the optional `{enabled}` param),
|
||||
reusing the same `notificationKeys` cache the full center reads, never on the polled count's tick.
|
||||
Everywhere else (customer — always, mobile-first by design; nurse mobile) keeps direct navigation.
|
||||
- **Isolation preserved:** only `NotificationBell` (the container) calls `useUnreadCount()`; the popover's
|
||||
content and the shell around it never do. `NotificationBellView` now `forwardRef`s to its `IconButton` so
|
||||
the container can anchor the popover to it (via `anchorEl` state set through the ref callback — not a
|
||||
read-during-render, which the `react-hooks/refs` lint rule correctly flagged and blocked).
|
||||
- Badge pulse-on-increment (the phase's "optional polish") was **not** built this pass — noted under
|
||||
Follow-ups.
|
||||
|
||||
### Admin notifications — the phase-11 handshake (§3.7)
|
||||
- `admin/notifications/page.tsx` stays a `PlaceholderScreen` (untouched — building it is phase 11's call),
|
||||
but its **only entry point was the TopBar bell** (`AdminLayout` had no separate "notifications" nav item —
|
||||
the bell click was the dead link). Removed `<NotificationBell role="admin" />` from `AdminLayout` entirely
|
||||
— a documented, intentional removal (comment in `AdminLayout.tsx`), not a silent regression. Re-add it
|
||||
(reusing `NotificationBellPopover`, already exported from the barrel) once phase 11 ships a real admin
|
||||
feed.
|
||||
- `useThreadScroll` and `NotificationBellPopover` are both exported from their barrels specifically for
|
||||
phase 11 to consume for the admin thread's inverse scroll bug and (eventually) an admin bell popover.
|
||||
|
||||
## Stale-audit note (recorded for the record, not re-litigated)
|
||||
The phase doc's problem list (drawn from `audit/messaging-notifications.md`) describes `unreadCount`/
|
||||
`lastMessageAt` as "dead on the real API, REQ-028 gap, mock-only." That was true when the audit was
|
||||
written, but by the time this phase ran, **REQ-028 had already been delivered** — `USE_TICKETS_MOCK` and
|
||||
`USE_NOTIFICATIONS_MOCK` are both `false` in this codebase, and `ticketsClientApi.mapSummary` genuinely maps
|
||||
`unreadCount`/`lastMessageAt` off the wire (confirmed by reading the current code, not the audit). This
|
||||
phase's actual REQ-039/040-equivalent gaps turned out to be narrower than described — `lastMessagePreview`/
|
||||
`lastAuthorRole`/the unread-total badge (filed as **REQ-059**, since REQ-039/040 were already taken by
|
||||
ui-phase-3/4 by the time this phase ran — always check the tracker's live highest number, not a phase doc's
|
||||
indicative one) and the attachment affordance (**REQ-060**). This mirrors the same "audit predates a
|
||||
same-day fix" pattern the ui-phase-9 stale-audit memory already flagged — worth a standing reminder for
|
||||
future phases to verify audit claims against current code before treating them as gaps to close.
|
||||
|
||||
## What is now testable (and exactly how)
|
||||
1. `/fa` customer → پشتیبانی (`/support/tickets`): status chips filter the list without a flash; if there
|
||||
are >20 tickets in a scenario, «نمایش بیشتر» loads more; the compact «موارد اضطراری» row is collapsed by
|
||||
default and expands on tap; each card shows a bold subject, unread pill (mock scenario), one-line
|
||||
preview + relative time.
|
||||
2. Open a thread with a booking-linked coordination ticket → opens scrolled to the newest message,
|
||||
date-separated and author-grouped, `hh:mm` stamps, a centered system event line if present. Scroll up,
|
||||
wait ~15s for the poll (or use the mock's reply path) → a reply appears and, if scrolled up, shows
|
||||
«پیام جدید ↓»; tap it → scrolls to newest.
|
||||
3. Send a message → bubble appears instantly with "در حال ارسال…"; on confirm it shows `hh:mm`. Post the
|
||||
dev sentinel `/fail` as the body → the bubble turns error-accented with «تلاش مجدد» + a delete icon,
|
||||
`role="alert"` (verify with a screen reader or the accessibility tree); tap «تلاش مجدد» → succeeds (a
|
||||
real send) with **exactly one** bubble, never a duplicate. Try again and tap the delete icon instead →
|
||||
the bubble disappears and its text reappears in the composer.
|
||||
4. On a touch-emulated viewport (devtools device toolbar), Enter in the composer inserts a newline; on a
|
||||
desktop viewport, Enter sends. `/fa`: the send arrow points out of the field (toward the inline-end).
|
||||
5. `/nurse/support/tickets`: the sidebar's «پشتیبانی» item shows a small unread badge when the mock's
|
||||
`getUnreadTotal()` is positive.
|
||||
6. Notification center (`/fa` + `/en`, light + dark): امروز/دیروز/اینهفته day groups, «۵ دقیقه پیش» decaying
|
||||
to a Shamsi date past 7 days, tinted per-kind icon circles (teal/success/terracotta/info/trust); a
|
||||
non-deep-linking row (seeded via the mock's unknown-type row) doesn't ripple and has no chevron, but a
|
||||
click still marks it read; navigable rows show a trailing chevron and a visible focus ring on Tab.
|
||||
7. Nurse desktop (`≥md` viewport): the bell opens a popover (5 recent + mark-all + «مشاهده همه»); resize to
|
||||
mobile → the same bell now navigates to the full center. Customer shell: the bell always navigates,
|
||||
any viewport. Devtools Network tab: only the unread-count endpoint polls (60s) plus the thread endpoint
|
||||
while a thread screen is mounted (15s) — nothing else ticks.
|
||||
8. `/admin`: no notification bell in the TopBar at all (removed, not a dead link).
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
- `TicketsApi` (`services/tickets/apis/mockApi.ts`) — mock stays available for the two REQ-059 fields
|
||||
(`lastMessagePreview`/`lastAuthorRole`) + `getUnreadTotal()`; the real client maps the first two to `null`
|
||||
and the third to a constant `null`. See `mocks-registry.md`'s updated `TicketsApi` row.
|
||||
- The composer's attachment affordance is fully designed but inert (`TICKETS_ATTACHMENTS_ENABLED = false`)
|
||||
until REQ-060 ships an object-storage-backed attachment endpoint.
|
||||
|
||||
## Contracts
|
||||
- No contract consumed this phase (no new backend endpoint existed to consume — REQ-028 was already
|
||||
delivered before this phase started).
|
||||
- Requests filed in `dev/shared-working-context/frontend/requests/for-backend.md`:
|
||||
- **REQ-059** — Ticket inbox enrichment: `lastMessagePreview` + `lastAuthorRole` on `TicketSummaryDto`
|
||||
(extends REQ-028) + a cheap unread-total read for the chrome badge.
|
||||
- **REQ-060** — Ticket message photo attachments (object-storage-backed), gating the composer's
|
||||
already-built affordance.
|
||||
|
||||
## Docs updated
|
||||
- `client/CLAUDE.md` — the `messaging/` and `notifications/` Project Structure entries rewritten for the
|
||||
new components/hooks (`TicketConversationPanel`, `useThreadScroll`, `EmergencyPlaybookRow`,
|
||||
`NotificationBellPopover`, `notificationTint`); the `services/tickets`/`services/notifications` domain
|
||||
bullets updated (retry-in-place semantics, thread polling, `useSupportUnreadTotal`,
|
||||
`useDiscardFailedMessage`, REQ-028-delivered vs REQ-059-gap distinction); the `'tickets'`/`'notifications'`
|
||||
i18n namespace descriptions updated with the new keys; `CustomerLayout.tsx`/`NurseLayout.tsx`/
|
||||
`AdminLayout.tsx`/`SideBarNavItem.tsx` structure-tree lines updated for the badge + admin-bell-removal
|
||||
changes.
|
||||
- `dev/shared-working-context/reports/mocks-registry.md` — the `TicketsApi` row corrected (it was stale:
|
||||
marked "deliver REQ-028, then set flag false" when the flag was already `false`) and updated with the
|
||||
REQ-059/060 gaps.
|
||||
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-059/060 appended (see Contracts).
|
||||
|
||||
## Foundation extensions (minimal, per the ownership rules)
|
||||
- **`layout/` (phase 2-owned):** `LinkToPage` gained an optional `badgeCount` field; `SideBarNavItem`/
|
||||
`SideBarNavList` render it as a small `Badge`; `CustomerLayout`/`NurseLayout` wire it to
|
||||
`useSupportUnreadTotal()`; `AdminLayout` lost its notification bell (§3.7 handshake, documented above).
|
||||
- **`utils/date.ts`:** added `formatShamsiTime` (hh:mm-only) and `formatDaySeparator`
|
||||
(امروز/دیروز/absolute-date, calendar-exact via formatted-string comparison, not ms diffs).
|
||||
- **`AppIcon/config.ts`:** `send` added to `DIRECTIONAL_ICONS` (one-line registry addition, phase 0's
|
||||
existing mechanism).
|
||||
- **`services/notifications`:** `useNotifications` gained an optional `{ enabled }` param (for the popover's
|
||||
fetch-on-open); no breaking change to existing callers.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- **Phase 11 (admin console):** build the real admin notification feed (or keep it hidden) — the bell +
|
||||
popover pattern (`NotificationBellPopover`) and the scroll hook (`useThreadScroll`) are ready to reuse for
|
||||
the admin thread's inverse scrollbox bug.
|
||||
- **REQ-059**/**REQ-060** as filed.
|
||||
- **Badge pulse-on-increment** (§3.6's "optional polish") and a document-title/favicon unread hint were not
|
||||
built this pass — low-risk, low-priority visual polish, safe to pick up whenever.
|
||||
- **Ticket lifecycle + trust affordances** (user-side close/reopen, an expected-response-time promise, a
|
||||
"support has seen this" state) — called out as an opportunity in the audit but out of this phase's
|
||||
explicit scope (§3 didn't ask for it); worth a future phase if support-response-time becomes a measured
|
||||
product metric.
|
||||
Reference in New Issue
Block a user