add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
# Frontend Phase 0 — Foundations: app shells, design system & the data/contract patterns
> **Mission:** turn the inherited starter into a clean Balinyaar foundation for the three actor
> experiences (family/customer, nurse, admin). Remove the demo leftovers, build the **app shells** and
> route groups each actor needs, and lock in the **patterns** every later frontend phase will follow:
> how a `services/{domain}` talks to the API, how TanStack Query caches it, how types come from the
> published contract, and where shareable components live. No real feature data yet (no backend
> dependency) — this phase makes f1f15 fast, consistent, and re-render-cheap.
>
> **Track:** frontend · **Depends on:** nothing (`frontend-phase-0`, no backend phase required) ·
> **Unlocks:** every frontend phase
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
The client (`client/`, **Next.js 16 App Router + React 19 + MUI v9 + next-intl**, `fa` default & RTL)
already ships a full app shell and an `App*` component library — you **keep and build on** these and
**remove** the demo bits.
**What already exists (do not rebuild) — confirmed in the codebase:**
- Root layout `src/app/[locale]/layout.tsx` (renders `<html lang dir>`, providers: NextIntl → Auth →
Theme → Query → Notistack), `(private-routes)`/`(public-routes)` groups, the `TopBarAndSideBarLayout`
engine + `TopBar`/`SideBar`/`BottomBar`.
- Theme system (brand tokens **teal `#1d4a40`**, **terracotta `#d98c6a`**, cream in `theme/colors.ts` +
`tokens.css`; `APP_THEME_LTR/RTL`, dark mode), i18n (`routing.ts`, `request.ts`), the cookie manager,
`clientFetch`/`serverFetch` + `ApiError`, TanStack Query (`makeQueryClient`/`QueryProvider`), the toast
bridge, `AuthContext`, the middleware auth gate.
- The `App*` library: `AppButton`, `AppIconButton`, `AppAlert`, `AppIcon`, `AppImage`, `AppLink`,
`AppLoading` (+ `ErrorBoundary`, `UserInfo`); the `auth` service (`useLogin`/`useLogout`/`useCurrentUser`).
**What is demo scaffolding you will remove in this phase:**
- The `toastDemo` i18n namespace (both `en.json`/`fa.json`) and the placeholder `HomePage`
(`<Typography>Balin yaar</Typography>` with its unused `t`).
- The unregistered dead icons `AppIcon/icons/CurrencyIcon.tsx` and `YellowPlanIcon.tsx`.
- Fix the small drift noted in the audit: `AppLoading` missing from the `@/components` barrel export;
`BottomBar` reading global `location` instead of `usePathname`.
> **Auth note:** login is currently **username/password**. It becomes **phone-OTP** in **f1-b2** — don't
> wire login screens here; this phase only prepares the shells and patterns.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md).
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) in full — the engineering contract (RSC boundary,
layouts, i18n, theme, cookies, fetch services, anti-patterns). Note the doc/code drift the audit found
(e.g. a `ColorSchemeScript` is referenced but doesn't exist) — trust the code, and fix the doc if you
touch that area.
- **Invoke the `frontend-designer` skill** — it is the design/brand contract (palette, tokens,
typography, the `App*` library, layout shells, the hard UI rules). All visual work goes through it.
- [`product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline:
mobile-first RTL app, deep-green brand, the **5-tab bottom nav** for the customer app
(خانه/Home · رزروها/Bookings · بیماران/Patients · کیف‌پول/Wallet · پروفایل/Profile), and the nurse-only
screens. This phase builds the *shells* those screens will live in.
- [`../../contracts/README.md`](../../contracts/README.md) +
[`conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) +
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — how you'll type and format
server data (envelope, IRR-as-string + Toman display, enums-as-codes, UTC + Shamsi display).
- The existing `src/services/auth/*` — the exact pattern (`types.ts`/`keys.ts`/`apis/clientApi.ts`/
`hooks/use*.ts`/`index.ts`) every new domain service copies.
## 3. Scope — build this
### 3.1 Clean up the demo scaffolding
Remove the `toastDemo` namespace, the placeholder home page content, the two dead icons; fix the
`AppLoading` barrel export and the `BottomBar` `usePathname` bug. Keep everything else. `npm run check`
stays green throughout.
### 3.2 The three actor app shells + routing
Balinyaar has three audiences with different chrome. Establish them now (empty/placeholder content is
fine — they fill up in later phases):
- **Customer (family) app** — mobile-first shell with the **bottom tab nav** (Home/Bookings/Patients/
Wallet/Profile) per the wireframe; this is the primary experience.
- **Nurse app** — its own shell (the wireframe's "نمای پرستار" screens: verification, dashboard, EVV).
- **Admin/backoffice** — a desktop-oriented shell (sidebar nav) for the ops console (f15).
Decide the routing that expresses these cleanly within the existing `[locale]` + route-group structure
(e.g. role-scoped route groups/segments under `(private-routes)`), **without** adding any layout above
`[locale]` and **without** breaking the server/client boundary. Drive nav by role from `AuthContext`
(roles arrive in f1-b2 — design the shell to read a role and render the right chrome; default gracefully
until roles exist). Build a **shared bottom-nav** and **shared sidebar-nav** component at the right level
in `src/layout/` / `src/components/`. Update the **Project Structure** tree in `client/CLAUDE.md` for any
new route group/folder.
### 3.3 The `services/{domain}` + Query caching pattern (the reference implementation)
Codify the data pattern every later phase copies, using the `auth` service as the template and the
checklist's caching rules:
- A `keys.ts` query-key factory per domain; deliberate `staleTime`/`gcTime`; **mutations invalidate or
`setQueryData`** so data already in cache is never needlessly refetched.
- `apis/clientApi.ts` (+ `serverApi.ts` only when an RSC needs it) wrapping `clientFetch`/`serverFetch`;
**one hook per file**.
- A documented way to derive `types.ts` from the published contract (`dev/contracts/`) — and, when a
backend phase isn't ready, a **mock `clientApi`** behind the same seam plus a row in your report (so
it's swapped cleanly once the real endpoint lands). Provide a tiny example domain (or thoroughly
document the `auth` one) so f1 starts by copying, not inventing.
- A small **money/format util** (`formatIrrToToman`, integer-safe parse of IRR strings, Shamsi date
display) in `src/utils/` per the money-and-types contract — used wherever prices/dates render.
### 3.4 Shared composite components (built once, reused everywhere)
Build the cross-cutting composite components the wireframe implies, at the shared level (`src/components/…`),
each with a co-located `*.test.tsx`, each composed from MUI/`App*` primitives (never re-implementing a
root primitive): an **OTP code input**, a **phone-number field** (Iranian format, RTL-safe), a **stepper/
progress header** (used by onboarding + verification), a **status chip** (verified/pending/…); a
**nurse/result card** and a **price-breakdown** can be stubbed here or deferred to their phases — your
call, but if you build them, build them shared. Keep page-only composition in pages.
### 3.5 i18n namespaces baseline
Establish the namespace conventions for the feature areas to come (e.g. `auth`, `onboarding`,
`verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`, `notifications`, `admin`, `common`,
`nav`) — seed `common`/`nav` with the shell strings you add, in **both** `en.json` and `fa.json`, in
sync. RTL-first.
## 4. Mocks & seams in this phase
No backend dependency. Where you demonstrate the data pattern without a live endpoint, use a **mock
`clientApi`** behind the `services/{domain}` seam and note it in your report — this is the template f1+
follow until their backend phase is merged.
## 5. Critical rules you must not get wrong
- **Never add a layout above `[locale]`** and never break the RSC/client boundary (the audit shows the
current setup is load-bearing — see `client/CLAUDE.md`).
- **Design RTL-first**, `fa` default; every string in both locale files.
- **Colours from tokens**, MUI v9 API only, pre-built themes only.
- **Caching is a feature:** set `queryKey`/`staleTime` deliberately and invalidate on mutation — the
whole point of this phase is that later phases don't over-fetch or over-render.
- **MUI primitives stay MUI;** shareable composites live shared, not in a page.
- Keep `npm run check` green and translations in sync at every step.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] Demo scaffolding removed; the two barrel/`BottomBar` bugs fixed; `npm run check` green;
`npm run test:ci` green for the shared components you add.
- [ ] The three actor shells exist and render with role-aware nav (degrading gracefully before roles
exist); no layout added above `[locale]`.
- [ ] The `services/{domain}` + Query caching pattern is implemented and documented as the reference;
the money/format util exists.
- [ ] The shared composite components added each have a co-located test.
- [ ] `client/CLAUDE.md` *Project Structure* updated for new route groups/folders; any doc drift you
touched is corrected.
## 7. How to test (what a human can verify after this phase)
- `npm run dev` → the app boots; visiting the customer area shows the mobile shell with the 5-tab bottom
nav; the nurse and admin areas show their shells; switching locale flips `dir`/strings correctly; dark
mode still works.
- `npm run check` and `npm run test:ci` pass; the new shared components render and their interactions
fire callbacks in tests.
- The reference `services/{domain}` (or the documented `auth` one) shows a query caching + a mutation
invalidating it in React Query Devtools.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* (route groups, new shared components, the
services pattern note); fix any drift you touched.
- **Contracts:** none produced (frontend consumes). If the b0 swagger snapshot exists, wire the
types-from-contract step against it; otherwise document the intended step. File any envelope/format
question in `shared-working-context/frontend/requests/for-backend.md`.
- **Handoff & report:** append to `shared-working-context/frontend/STATUS.md`; write
`reports/frontend-phase-0-report.md` (shells built, the reference data pattern, which composites are
shared, what's mocked client-side and how f1 swaps it).
- **Memory:** save a `project` memory note for the actor-shell/routing decision and the data pattern,
with a `MEMORY.md` pointer.
+342
View File
@@ -0,0 +1,342 @@
# Frontend Phase 1 — Auth: phone-OTP login & role routing
> **Mission:** make people able to actually sign in. Replace the inherited username/password stub with
> Balinyaar's real credential — **phone-OTP** — and wire the gate that every other screen sits behind.
> Build the customer login (A1), the OTP screen (A2), the family↔nurse login switch (B1/B2 entry), and
> the **role router** that reads `/me` after sign-in and sends a customer to the family app, a nurse to
> the nurse app, and a brand-new user with no role to role selection. After this phase the app has a real
> front door; f2-b3 (onboarding & profiles) and every authenticated screen build on it.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-0`](./frontend-phase-0.md) (shells, the OTP/phone
> composites, the `services/{domain}` + Query pattern, the cookie manager, `AuthContext`) · **backend
> contract** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md) (from
> backend-phase-2) · **Unlocks:** every authenticated screen; f2-b3 onboarding & profiles
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
The client (`client/`, **Next.js 16 App Router + React 19 + MUI v9 + next-intl**, `fa` default & RTL) has
a full app shell, the three actor app shells from f0, and a cookie/fetch/Query/auth stack — but **no way
to log in**. Today's auth domain is a **username/password stub** (`useLogin({ username, password })`
`/auth/login`) with no UI; per the product docs, Balinyaar's only real credential is **phone-OTP**
([`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md):
"Phone number is the primary login credential … Authentication is phone-OTP"). This phase swaps the stub
for the real thing and adds the role router that turns "authenticated" into "in the right app".
**What already exists (do not rebuild) — confirmed in the codebase:**
- **From [`frontend-phase-0`](./frontend-phase-0.md):** the three actor app shells + role-scoped route
groups under `(private-routes)`; the **shared OTP code input** and **Iranian phone-number field**
composites (`src/components/…` with co-located tests); the **`services/{domain}` + TanStack Query**
reference pattern (a `keys.ts` factory, `apis/clientApi.ts` over `clientFetch`, one-hook-per-file,
invalidate-on-mutation); the **types-from-contract** convention; the money/format util; the `auth`,
`common`, `nav` i18n namespaces seeded in **both** `en.json`/`fa.json`. Read the f0 report
([`reports/frontend-phase-0-report.md`](../../shared-working-context/reports/frontend-phase-0-report.md))
for exactly what shipped and how the mock-`clientApi` seam works.
- **The stack (built before f0):** root layout `src/app/[locale]/layout.tsx` (providers NextIntl → Auth →
Theme → Query → Notistack); `clientFetch`/`serverFetch` + `ApiError` (`@/lib/api`) — **401 already
clears cookies + toasts + redirects without throwing; 403/5xx toast+throw; other 4xx throw-no-toast**;
the **cookie manager** (`@/lib/cookies/client``setClientCookie`/`deleteClientCookie`, with
`AUTH_ACCESS_COOKIE_OPTIONS` ≈15 min and `AUTH_REFRESH_COOKIE_OPTIONS` ≈7 d, both **non-httpOnly** by
the current design); `AuthContext` (`useReducer` `[state, dispatch]`, actions `LOG_IN{user?}`/`LOG_OUT`,
server-seeded `initialState`); `makeQueryClient` (staleTime 30 s); the **middleware auth gate**
(`isTokenAlive` on `access_token`, redirects non-public paths to `/${locale}/login`).
- **The auth domain (the stub you are replacing):** `src/services/auth/``types.ts`
(`LoginDto {username,password}`, `AuthTokens {accessToken,refreshToken}`, `User {id,username}`),
`keys.ts` (`authKeys.currentUser()`), `apis/clientApi.ts` (`AuthClientApi.login/logout/getCurrentUser`),
`hooks/useLogin|useLogout|useCurrentUser`, `index.ts`. The login hook writes both tokens as non-httpOnly
cookies and dispatches `LOG_IN`; `useLogout` deletes both, dispatches `LOG_OUT`, redirects to
`/${locale}/login`. **There is no login page yet** — only the hooks. You extend this domain in place.
**What you are replacing / removing in this phase:**
- The username/password `LoginDto` + `useLogin({username,password})` + `POST /auth/login` path — replaced
by the OTP request/verify pair. Remove the dead `username/password` types and the `login` api/hook once
the OTP flow is wired; don't leave both (no-unused-vars is a lint **error**).
> **Wireframe reality check:** A1/A2 (customer) and B1/B2 (nurse) are the **same OTP mechanism** with
> different copy and a different post-login destination. Build **one** OTP flow and parameterise the
> intended-role/copy — do not fork two parallel login stacks.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you
work, the gate, the §6 contract-gap handoff, the caching/re-render rules.
- **The contract you consume:** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md)
(published by **backend-phase-2**) — the **source of truth** for the OTP/refresh/logout/me shapes, routes,
status codes, and the `Me`/roles enum. **Do not guess shapes.** Read it together with
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, `snake_case` routes — `[controller]/[action]` so `RequestOtp``.../request_otp`; 400/401/403/409
meanings; the locale header) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md)
(enums cross the wire as **stable string codes**`male`/`female`, roles as codes — and the frontend
mirrors them as string-literal unions; **never** hardcode a display label off a code, labels are i18n keys).
**If the contract isn't published yet or a needed shape is missing** (e.g. the exact `Me` role codes, the
resend-cooldown / max-attempts fields), follow operating-rules §6: append a request to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the `services/auth` seam meanwhile (§4 below).
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, the cookie rules (**auth state only
through the cookie manager — never `document.cookie`/`localStorage`**), the services pattern, anti-patterns.
- **Invoke the `frontend-designer` skill** before any visual work — A1/A2/B1 are full branded screens
(brand mark, teal `#1d4a40` primary CTA, RTL Persian, Vazirmatn/Mikhak). It is the palette/token/typography
and `App*`-library contract; all the login UI goes through it.
- [`product/wireframes/index.html`](../../../product/wireframes/index.html) — screens **A1, A2, B1, B2**
(and the role-router branch implied by A5 home vs B3 nurse status). The exact Persian strings are there:
A1 CTA **"دریافت کد تایید"**, nurse-switch link **"پرستار هستید؟ ورود پرستاران ←"**; A2 resend
**"ارسال مجدد کد تا ۰۰:۴۵"** + CTA **"تایید و ادامه"**; B1 subtitle
**"ویژه پرستاران دارای پروانه نظام پرستاری"**, CTA **"دریافت کد تایید"**; B2 CTA **"تایید و ورود"**.
- [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) —
the auth/role rules: **phone is the primary credential** (email never a login key), customer browses with
only a verified phone, **sessions are revocable with refresh-token rotation + stolen-token detection**,
admin roles are **internal-only / never self-assigned**.
- The existing `src/services/auth/*`, `src/context/auth/*`, `src/lib/cookies/*`, `src/lib/api/*`,
`src/middleware.ts` — the exact code you extend.
## 3. Scope — build this
A vertical slice: **service → hooks → screens → role router**, all over the published contract.
### 3.1 Rewrite `services/auth` for OTP (replace the username/password stub)
Keep the f0 service shape (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`);
swap its contents to OTP. **Mirror the published contract for every shape** — the names below are the
intended surface; reconcile field names/casing against `swagger.json` and file a `for-backend.md` request
for any gap.
- **`types.ts`** — replace `LoginDto` with:
- `OtpRequest``{ phone: string; intended_role?: RoleCode }` (the role the user is logging in *as*,
from the A1/B1 switch — used only for routing/copy; the server decides actual roles).
- `OtpVerify``{ phone: string; code: string }`.
- `Tokens``{ accessToken: string; refreshToken: string }` (rename/keep from `AuthTokens`; match wire casing).
- `Me` — `{ id: number; phone: string; roles: RoleCode[]; active_role?: RoleCode;
profile_completed: boolean; nurse_verification_status?: NurseVerificationStatus }` — the role-router
input. Trim/extend to **exactly** what `GET /me` returns in the contract.
- `RoleCode` — string-literal union `'customer' | 'nurse' | 'admin'` (codes, not labels).
- `NurseVerificationStatus` — string-literal union mirroring the contract's enum (e.g.
`'not_started' | 'in_progress' | 'pending_review' | 'verified' | 'rejected'`); used by the router to
decide whether a nurse lands on B3 status vs the nurse home.
- `OtpRequestResult` — whatever `request_otp` returns (e.g. `{ resend_after_seconds: number;
code_length: number; expires_in_seconds: number }`); drive the **resend countdown** and **OTP box
count** from this, not from a hardcoded `45`/`4`.
- **`keys.ts`** — `authKeys.me()` (replaces `currentUser()`); keep `authKeys.all`.
- **`apis/clientApi.ts`** — `AuthClientApi` over `clientFetch`:
- `requestOtp(body: OtpRequest): Promise<OtpRequestResult>` → `POST .../auth/request_otp`
- `verifyOtp(body: OtpVerify): Promise<Tokens>` → `POST .../auth/verify_otp`
- `refresh(refreshToken: string): Promise<Tokens>` → `POST .../auth/refresh`
- `logout(): Promise<void>` → `POST .../auth/logout`
- `getMe(): Promise<Me>` → `GET .../me`
(Use the **exact snake_case routes** from the contract; the examples follow the `[controller]/[action]`
transform.) Remove the old `login` call.
- **`hooks/` (one per file):**
- `useRequestOtp.ts` — `useMutation` over `requestOtp`. `onSuccess` hands the cooldown/length back to A2.
**Do not toast 401/403/5xx** (the fetch layer does) — only surface **domain 4xx** (e.g. 429
rate-limit "try again in N s", invalid-phone 400) as inline field/screen errors.
- `useVerifyOtp.ts` — `useMutation` over `verifyOtp`. `onSuccess`: **persist tokens via the cookie
manager** (`setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS`/`AUTH_REFRESH_COOKIE_OPTIONS`),
dispatch **`LOG_IN`**, **`invalidateQueries(authKeys.me())`**, then trigger the role router (3.4).
Map the contract's wrong-code / expired-code / max-attempts (lockout) failures to inline states.
- `useLogout.ts` — keep behaviour: call `logout`, `deleteClientCookie` both tokens, dispatch `LOG_OUT`,
**`invalidateQueries(authKeys.me())`** (or `queryClient.clear()` for auth keys), redirect to
`/${locale}/login`.
- `useMe.ts` — `useQuery(authKeys.me(), getMe)` (replaces `useCurrentUser`); `enabled` only when
authenticated; sensible `staleTime`. This is the role router's data source and the shell's role source.
- `useRefresh.ts` (DEFERRED token-rotation UI is out of scope, but) — expose `refresh` as a callable so
the **silent refresh** path can rotate the access token when it expires (see 3.5). If the existing
fetch layer is to own refresh, document that instead and don't duplicate it; flag the decision in your
report.
- **`index.ts`** — re-export the new hooks; drop `useLogin`/`useCurrentUser`.
### 3.2 A1 — customer phone login screen
The public login page (the route the middleware redirects to: `/${locale}/login`). Per A1 / the
frontend-designer skill:
- Brand mark + tagline; the **Iranian phone-number field** composite from f0 (placeholder `۰۹۱۲ ۰۰۰ ۰۰۰۰`,
RTL-safe, validates an Iranian mobile).
- Primary CTA **"دریافت کد تایید"** → `useRequestOtp({ phone, intended_role: 'customer' })`; on success,
advance to A2 (carry the masked phone + cooldown + code length).
- Secondary link **"پرستار هستید؟ ورود پرستاران ←"** → switches to the **nurse login** variant (B1).
- States: **sending** (CTA spinner/disabled), **invalid/non-Iranian number** (inline), **rate-limit hit**
("تا N ثانیه دیگر دوباره تلاش کنید"), **network error** (the fetch layer toasts; keep the field intact).
### 3.3 B1/B2 — nurse login switch (same flow, nurse intent)
Not a separate stack — the **same** login screen with `intended_role: 'nurse'`, nurse copy (B1 subtitle
**"ویژه پرستاران دارای پروانه نظام پرستاری"**, OTP CTA **"تایید و ورود"**), and a link back to family
login. Carry `intended_role` through A1→A2 (B1→B2) so the OTP screen shows the right CTA and the router
knows the entry intent. Implement the variant via a query param or a small client switch on the login
page — **no second route tree**.
### 3.4 A2 — OTP screen + auto-advance
Per A2/B2, using the **shared OTP code input** from f0:
- N-digit boxes (N from `OtpRequestResult.code_length`, default 4 per the wireframe), **masked phone echo**
("کد به شماره ۰۹۱۲•••۰۰۰۰ ارسال شد"), **resend countdown** **"ارسال مجدد کد تا ۰۰:۴۵"** driven by
`resend_after_seconds` (a single timer, cleaned up on unmount — no leaked intervals).
- CTA **"تایید و ادامه"** (customer) / **"تایید و ورود"** (nurse) → `useVerifyOtp`.
- States: **auto-advance** (verify fires automatically when the last box is filled), **wrong code**
(inline, clear boxes, keep focus), **expired code** (offer resend), **max-attempts lockout** (disable
input + show the locked message + when it lifts), **resend** (re-calls `useRequestOtp`, restarts the
countdown, disabled until the countdown hits 0).
- On verify success the hook stores tokens + dispatches `LOG_IN` + invalidates `/me`; then the **role
router** takes over (next).
### 3.5 The role router (after `/me`)
A small client component/hook (`useRoleRouter` or a `RoleRouter` boundary placed where the shell decides
chrome) that consumes `useMe()` and routes:
- **loading** (`/me` in flight) → the f0 `AppLoading` splash; never flash the wrong shell.
- **roles includes `nurse`** → the **nurse app** home; if `nurse_verification_status` is not `verified`,
land on **B3 verification status** (built in f5) — for now route to the nurse shell's home/placeholder
and leave a clear pointer (f5 fills B3). A persistent "verification in progress" banner is **f5's** job;
here just route correctly.
- **roles includes `customer`** (and not nurse, or `active_role === 'customer'`) → the **customer/family
app** home (A5, built in f4 — route to the customer shell's home/placeholder now).
- **roles empty / no usable role** → the **SelectRole** screen (3.6).
- **`active_role`** (if the contract returns one) wins when a user has multiple roles; otherwise prefer the
`intended_role` carried from login, else default customer.
- **admin** → the admin shell (f15) — route correctly but don't build admin screens here.
Honour the **middleware gate**: it already redirects unauthenticated users to `/login`; the role router
runs **after** auth and only decides *which app*. Don't duplicate the auth check in the router.
### 3.6 SelectRole screen (first use)
A minimal post-login screen for a brand-new user with **no role**: choose **خانواده (customer)** or
**پرستار (nurse)** → call the role-selection mutation (`POST /me/role`, `SelectRoleCommand` per the
digest; consume the contract's exact route/body) → `invalidateQueries(authKeys.me())` → re-run the router.
Admin is **never** selectable here (admin roles are internal-only). If `intended_role` from login is set,
pre-select it. Keep this screen shared/simple; the full onboarding (A3 "who is care for", A4 add-patient,
nurse profile bootstrap) is **f2-b3** — link it, don't build it.
### 3.7 Seed `AuthState` with roles
Extend the auth context beyond `{ id, username }`:
- `src/context/auth/types.ts` — extend `AuthState.currentUser` to carry `{ id; phone; roles: RoleCode[];
active_role?: RoleCode }` (mirror the `Me` essentials the shell needs for chrome) and keep
`isAuthenticated`. Keep the reducer's `LOG_IN{user?}`/`LOG_OUT` actions; just widen the `user` payload.
- The server-seeded `initialState` (root layout) should seed roles when it can derive them
(it reads cookies/`getServerAuthState`); if roles aren't available server-side yet, seed
`isAuthenticated` only and let `useMe()` hydrate roles client-side — **don't** put a second source of
truth for roles. Document which it is in your report.
- The f0 shells already read "a role" to pick chrome — now feed them the real `roles`/`active_role`.
### 3.8 i18n
All A1/A2/B1/B2/SelectRole/role-router strings as keys in the **`auth`** namespace (and `common`/`nav`
for shared bits) in **both** `messages/en.json` and `messages/fa.json`, in sync, RTL-first. No label is
ever derived from a role/status **code** in code — codes map to i18n keys. (DEFERRED) onboarding copy
(A3/A4) belongs to f2's `onboarding` namespace.
## 4. Mocks & seams in this phase
This phase **introduces no new external-service seam** — OTP/SMS delivery is the **backend's** seam
(`ISmsSender`, introduced in backend-phase-2; the frontend never sends SMS). The only frontend "mock" is
the standard **contract-gap fallback** from operating-rules §6 and the f0 pattern:
- **If [`identity-auth.md`](../../contracts/domains/identity-auth.md) / the b2 swagger snapshot isn't
merged when you start**, build a **mock `AuthClientApi`** behind the same `services/auth` seam (the f0
mock-`clientApi` pattern): `requestOtp` returns a fixed `{ resend_after_seconds: 45, code_length: 4,
expires_in_seconds: 120 }`; `verifyOtp` accepts a fixed dev code (e.g. `0000`) and returns fake tokens
+ a `Me` you can toggle (customer / nurse-unverified / no-role) to exercise all three router branches;
`getMe` returns the matching `Me`. Selection is by config (env/flag), **never** an `if (mock)` in a
hook or screen.
- **Record it:** append a row to
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam =
`AuthClientApi` mock + file, what's faked, the config key, exact swap steps = point the service at the
real `clientApi` once the contract is live) and a follow-up in your phase report.
- Any shape the contract doesn't cover (resend/lockout fields, `Me` role codes, `active_role`) →
**append a request** to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md); do **not** edit
backend files.
(Reuse the f0 mock-seam mechanism — don't invent a new one.)
## 5. Critical rules you must not get wrong
- **Phone is the only credential.** No username/password anywhere after this phase; email is never a login
key. Remove the stub `login` path completely (no dead code — it's a lint error).
- **No token in `localStorage`, ever.** Tokens live **only** in cookies via the cookie manager
(`@/lib/cookies/client`), non-httpOnly per the current design. Never `document.cookie`/`localStorage`
for auth. Logout must actually clear both cookies *and* the server session (call `logout`).
- **Sessions are revocable; refresh rotates.** Wire `refresh` so an expired access token is silently
rotated (or confirm the fetch layer owns this and don't duplicate it). The server does reuse-detection;
the client must send the current refresh token and replace **both** tokens on a successful refresh.
- **`invalidateQueries(authKeys.me())` on login and logout.** `/me` is the role router's input — a stale
`/me` routes the wrong person to the wrong app. Never read roles from two sources.
- **Don't toast 401/403/5xx in hooks** — the fetch layer already does. Hooks surface only **domain 4xx**
(invalid phone, rate-limit/429, wrong/expired code, lockout) as inline UI states.
- **OTP states are explicit, not edges:** sending, resend-cooldown, wrong code, expired code,
**max-attempts lockout**, auto-advance. A single timer, cleaned up on unmount (no leaked intervals →
no re-render storms).
- **The router never flashes the wrong shell.** Show `AppLoading` while `/me` is in flight; only render a
shell once the role is known. **admin role is never self-assignable** — SelectRole offers customer/nurse
only.
- **RSC/client boundary:** login screens, OTP, the router, and the cookie writes are **client**
(`'use client'`); no `next/headers`/`@/lib/cookies/server` in them. The middleware already gates routes —
don't re-implement the gate in a component.
- **Both locales, RTL-first, tokens for colour, MUI v9, MUI primitives stay MUI.** Every string in
`en.json` **and** `fa.json`; no label derived from a code.
- **Minimise re-renders:** the countdown timer and per-box OTP state stay **low** (in the OTP component),
not in a high context; `useMe` subscribers use `select` if they only need `roles`.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/auth` is OTP-based: `OtpRequest`/`OtpVerify`/`Tokens`/`Me`(+roles) types, `authKeys.me()`,
`requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe` apis, and the `useRequestOtp`/`useVerifyOtp`/
`useMe`/`useLogout` hooks — the username/password stub is **removed**, not left dangling.
- [ ] A1 (customer login), A2 (OTP, auto-advance, resend countdown, lockout), and the **B1/B2 nurse
switch** render per the wireframe via the f0 phone/OTP composites, RTL, both locales, branded.
- [ ] The **role router** sends customer→family app, nurse→nurse app (B3 status if unverified),
no-role→**SelectRole**, admin→admin shell; it shows `AppLoading` during `/me` and never flashes the
wrong shell.
- [ ] Tokens are stored **only** via the cookie manager; `LOG_IN`/`LOG_OUT` dispatched; `authKeys.me()`
invalidated on login **and** logout; refresh wired (or fetch-layer ownership documented).
- [ ] `AuthState` carries `roles`/`active_role`; the f0 shells pick chrome from the real role.
- [ ] All strings in `auth`/`common`/`nav` in **both** `en.json` and `fa.json`, in sync.
- [ ] `npm run check` green; `npm run test:ci` green (you touched/added shared composites and the
router — add tests for the OTP state machine and the router branches).
- [ ] `client/CLAUDE.md` updated: the OTP login flow, the role-router seam, and the widened `AuthState`
noted in *Project Structure* / the auth notes; the now-stale username/password references corrected.
## 7. How to test (what a human can verify after this phase)
With the b2 backend running (or the mock seam configured), `npm run dev`:
- **Customer happy path:** open `/fa/login` → enter a valid mobile → "دریافت کد تایید" → A2 shows the
masked phone + a counting-down resend → enter the code (auto-advance fires verify) → tokens appear in
cookies (DevTools → Application → Cookies; **nothing** in localStorage) → redirected to the **customer**
app home. `/me` is in the Query cache (React Query Devtools).
- **Nurse switch:** from A1 tap "پرستار هستید؟ ورود پرستاران ←" → B1 nurse copy/subtitle → request →
verify ("تایید و ورود") → routed to the **nurse** app (B3 status placeholder if unverified).
- **No-role user:** verify as a user whose `/me` has empty `roles` → lands on **SelectRole** → choose a
role → re-routed into that app.
- **OTP edge states:** wrong code → inline error + boxes clear; let the code expire → resend re-enabled by
the countdown; trigger max attempts → input locks with a clear message.
- **Session:** log out → both cookies cleared, `/me` invalidated, redirected to `/login`; let the access
token expire and act → silent refresh rotates it (or you're sent to `/login` cleanly).
- **i18n/RTL:** switch to `en` → all auth strings translate, layout stays correct; `fa` is RTL.
- `npm run check` and `npm run test:ci` pass (OTP state-machine + router-branch tests included).
## 8. Hand off & document (close the phase)
- **Docs:** update [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the phone-OTP flow, the `useRoleRouter`
seam, the widened `AuthState` (roles/active_role), and the new auth screens/route; **fix the stale
username/password references**. If you add a route group/folder, update *Project Structure* in the same
change. No `product/` rule changes expected (you're implementing decided rules); if you discover drift,
record it there.
- **Contract:** **consume** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md)
— types come from it, not guesses. Append every gap (resend/lockout fields, exact `Me` role codes,
`active_role`, role-selection route/body) to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md).
Frontend produces **no** contract.
- **Handoff & report:** append to
[`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`reports/frontend-phase-1-report.md`](../../shared-working-context/reports/frontend-phase-1-report.md)
(what was built, **what is now testable and exactly how**, what's mocked behind the `AuthClientApi` seam
+ how f-next swaps it, the contract consumed + gaps filed, follow-ups: B3 banner is f5, A3/A4 onboarding
is f2, admin screens are f15). Update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) for the
`AuthClientApi` mock if you used it.
- **Memory:** save a `project` memory note for the phone-OTP decision, the role-router seam + branch logic,
and the widened `AuthState`, with a `MEMORY.md` pointer — what a future agent can't cheaply re-derive.
@@ -0,0 +1,279 @@
# Frontend Phase 10 — Cancellation & refund status (customer)
> **Mission:** give the family an honest, trust-first picture of *what cancelling costs* and *where their
> money is*. Before a customer confirms a cancellation, the screen must **resolve the applicable
> cancellation policy by lead time** and **disclose the fee / refund percentage up front** — no surprise
> charges. After a cancellation, the customer follows a read-only **refund status** (pending → on-its-way
> with an expected ETA → completed) that tells the truth about the asynchronous BNPL window (~710
> business days). Refunds themselves are admin-approved — the customer can *request* a cancellation and
> *see* the refund's progress, but never self-issues money. This is the customer half of the refund story;
> the admin refund console is built later.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md) (checkout,
> payment, invoice + the `services/payment` + booking-detail surfaces) and the **backend-phase-11**
> contract ([`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md)) · **Unlocks:** nothing
> downstream depends on it; it completes the post-payment customer flow before BNPL checkout (f11-b12).
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
The customer can now search, request, book, pay, and view a booking with its sessions and invoice. The
one thing missing from the money lifecycle on the customer side is the **exit**: cancelling a booking and
watching the refund land. Balinyaar is a *trust-first* marketplace — the cancellation screen exists
precisely so a family is never charged a fee they didn't see coming, and the refund-status screen exists
so they're never left wondering whether a card or (especially) a BNPL refund is actually moving. This
phase builds those two read-heavy, decision-critical screens against the `refunds` contract from b11.
**What already exists (do not rebuild) — link the prior phases:**
- **App shells, the `services/{domain}` + TanStack Query caching pattern, the contracts→types pattern, the
money/format util (`formatIrrToToman`, integer-safe IRR parse, Shamsi date display), the shared
composites (status chip, stepper/progress header, price-breakdown), the i18n namespaces and RTL
baseline** — [`frontend-phase-0.md`](./frontend-phase-0.md). Reuse all of it; do not re-create a money
util, a status chip, or a service skeleton.
- **Booking detail, sessions, EVV status timeline, the `services/booking` domain and its query keys** —
[`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md). The cancel entry point hangs off the booking-detail
screen; per-session cancellability comes from the session rows you already render there.
- **Checkout, card payment (mock redirect), confirmation, invoice view, the `services/payment` domain, the
commission/tax/escrow breakdown component** — [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md).
The refund-status screen reuses that domain's money-rendering and links back to the same booking/invoice.
- **The published b11 contract** — [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md):
the refund read shape (status, `refund_channel`, fee-leg decomposition, `expected_customer_refund_eta`,
`refund_percentage_applied`, `cancellation_policy_code`), the resolve-cancellation-policy query, and the
customer-initiated cancel command. **Types come from this contract, not from guesses.**
> **Admin side is out of scope.** The admin refund console (create/approve refunds, leg-split editor,
> ticket linkage, clawback banner, retry) is **(DEFERRED)** to **f15-b15** — see
> [the roadmap](../README.md). This phase is strictly the **customer** read + cancel-request surface.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md).
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary, layouts,
i18n, theme/tokens, cookies, `clientFetch`/`serverFetch` services, the anti-patterns). Non-negotiable.
- **Invoke the `frontend-designer` skill** — every screen, banner, ETA card, fee-disclosure dialog, and
status step in this phase is visual work and must go through it (palette, tokens, typography, the `App*`
library, RTL mirroring, dark-mode, the layout shells). Do not hand-roll styling.
- **Product — the business + money rules you must encode in the UI (read both fully):**
- [`../../../product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md)
— tiered policy by lead time + actor (free >24h, partial <24h, customer no-show up to 100%, nurse
no-show full refund), the **policy is snapshotted on the booking**, refunds are **admin-only +
ticket-linked**, refunds **decompose across the two fee legs**, and **per-remaining-session**
cancellation for multi-session engagements.
- [`../../../product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md)
— the BNPL refund truth: money flows `customer ↔ provider ↔ Balinyaar` only, the provider unwinds
asynchronously, **already-paid installments return to the customer's bank in ~710 business days**, and
the UI must **surface that window honestly** (never imply instant).
- **Contract to consume:** [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md) (b11) +
the conventions it assumes — [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, `snake_case` routes, status codes, pagination) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md)
(**IRR as a string of digits, Toman display-only, no floats; `refund_channel` = `psp_card` | `bnpl_revert`
| `manual`; UTC ISO-8601, Shamsi display is a client concern**).
- **Code to mirror:** the existing `src/services/auth/*` skeleton (`types.ts`/`keys.ts`/`apis/clientApi.ts`/
`hooks/use*.ts`/`index.ts`) and the `services/booking` + `services/payment` domains from f8/f9 — copy
their caching, key-factory, and mock-seam shape exactly. The shared composites in `src/components/` and
the money util in `src/utils/` from f0.
- **Handoff:** skim the latest backend handoff `dev/shared-working-context/backend/handoff/after-backend-phase-11.md`
and the prior reports in `dev/shared-working-context/reports/` for what b11 actually shipped and any
contract caveats (e.g. the `provider_commission_reversed_amount` nullable note).
## 3. Scope — build this
A vertical slice: **service → hooks → screens**, all customer-scoped, all RTL/both-locales, all money via
the util. Build the two wireframe screens — **cancellation flow (policy-fee disclosure)** and **customer
refund status (BNPL ETA)**.
### 3.1 `services/refunds` domain (read + cancel)
A new domain folder `src/services/refunds/` mirroring `auth`/`booking`/`payment`:
- `types.ts` — string-literal unions + DTOs **derived from [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md)**, not invented. At minimum:
- `RefundStatus` = `pending` | `processing` | `completed` | `failed` (mirror the contract's exact set; the
customer-facing wording maps these to *pending → on-its-way → completed*).
- `RefundChannel` = `psp_card` | `bnpl_revert` | `manual`.
- `RefundSummary` — `{ id, booking_id, refund_status, refund_channel, refund_percentage_applied,
cancellation_policy_code, platform_fee_refunded_irr, nurse_payout_refunded_irr, total_refunded_irr,
expected_customer_refund_eta, external_revert_reference, created_at, completed_at }` (IRR fields are
**strings of digits**; timestamps UTC ISO-8601; treat `external_revert_reference` as opaque).
- `CancellationPolicyPreview` — `{ cancellation_policy_code, refund_percentage_applied, fee_percentage,
refund_amount_irr, fee_amount_irr, applies_to, lead_time_label }` plus, for multi-session bookings, a
`sessions: { booking_session_id, refundable, reason_code }[]` breakdown (refundable = un-started;
locked = completed-and-verified).
- `keys.ts` — a query-key factory: `refundKeys.policyPreview(bookingId)`, `refundKeys.byBooking(bookingId)`,
`refundKeys.detail(refundId)`. Deliberate `staleTime` (policy preview is short-lived because it depends on
`now` vs the booking start — keep it fresh; refund status polls — see 3.3).
- `apis/clientApi.ts` wrapping `clientFetch` (no raw `fetch`): `resolveCancellationPolicy(bookingId)`,
`cancelBooking(bookingId, { sessionIds?, reason })`, `getRefundByBooking(bookingId)`,
`getRefund(refundId)`. Add `serverApi.ts` only if an RSC needs to prefetch refund status for SSR.
- `hooks/` — one hook per file: `useCancellationPolicyPreview.ts` (`useQuery`), `useCancelBooking.ts`
(`useMutation`; on success **invalidate** `bookingKeys.detail`/`bookingKeys.list` from f8 *and*
`refundKeys.byBooking` so the detail screen reflects the new cancelled/refund state without a manual
refetch), `useRefundStatus.ts` (`useQuery` with polling — see 3.3).
- `index.ts` barrel.
### 3.2 Cancellation flow (policy-fee disclosure)
Entry point: a **"Cancel booking"** action on the customer booking-detail screen (built in f8). The flow:
1. **Disclosure step — built from `resolveCancellationPolicy`.** Before any confirm, fetch and show the
resolved tier: the human policy label (free / partial / under-24h, by i18n key off
`cancellation_policy_code` — **never** render a label off the raw code), the **refund %** and the
**fee/penalty %**, and the concrete **amount you'll get back** vs **amount kept** (via the money util,
in Toman, integer-safe from the IRR strings). Reuse the f0 **price-breakdown** composite for the
refund-vs-fee split. If the booking is **multi-session**, render the per-session breakdown: which
sessions are **refundable** (un-started) and which are **locked** (completed-and-verified, stay
payout-eligible) — disabled rows with a reason chip.
2. **Confirm step — only after disclosure.** A confirm dialog/screen that restates "you will be refunded X,
a fee of Y applies" and a reason field, wired to `useCancelBooking`. On success, route to / reveal the
**refund status** for this booking. Surface the **admin-approval reality**: the copy makes clear the
cancellation request is submitted and the refund is processed by the team (the customer does not
self-issue money) — match the product doc's admin-only, ticket-linked rule.
3. **States:** loading (resolving policy), the disclosure itself, submitting, success→refund-status,
error (e.g. `409` outside-policy/state-machine, already-cancelled, payment-not-captured). Build a
small **`CancellationPolicyDisclosure`** composite in `src/components/` (reused by the dialog and any
future per-session cancel) with a co-located `*.test.tsx`; keep page-only glue in the page.
### 3.3 Customer refund status (BNPL ETA)
A customer-facing **read-only** refund-status surface for a booking (a section on booking-detail and/or a
dedicated `.../refund_status` screen):
- A **status stepper** mapping the contract status to the three customer-facing steps: **pending →
on-its-way → completed** (reuse the f0 **stepper/progress header**; map `failed` to a distinct error
state, not a 4th happy step).
- The refunded amount (total, via the money util) and, where the design calls for it, the fee-leg split
for transparency.
- **The BNPL ETA, surfaced honestly.** When `refund_channel === 'bnpl_revert'`, render an **ETA card**
built from `expected_customer_refund_eta` that states the **~710 business-day** window in plain language
(Shamsi-formatted date via the util) and explains the refund returns through the BNPL provider — never
imply it's instant. For `psp_card` show the card-refund wording; for `manual` show the manual-transfer
wording. Drive all three off the same component branching on `refund_channel`.
- **Polling without over-fetching.** `useRefundStatus` polls (`refetchInterval`) **only while the status is
non-terminal** (`pending`/`processing`); stop polling (interval `false`) once `completed`/`failed`. Set a
sane `staleTime`/`gcTime` so re-entering the screen doesn't re-hit the network needlessly. Invalidate /
`setQueryData` from the cancel mutation so the first render is warm.
- **States:** loading, no-refund/empty (booking has no refund — e.g. not cancelled), pending, on-its-way
(with ETA), completed, failed/needs-attention (contact support copy — *not* a retry button; retry is
admin-only, DEFERRED to f15). Build a shared **`RefundStatusCard`** / **`RefundEtaBanner`** composite in
`src/components/` with co-located tests.
### 3.4 i18n
Add a `refunds` namespace (and any `cancellation` keys) to **both** `messages/en.json` and `messages/fa.json`,
in sync, RTL-first: policy-tier labels keyed by `cancellation_policy_code`, the three refund-status step
labels, the per-channel ETA copy (`bnpl_revert` 710-day window, `psp_card`, `manual`), the fee-disclosure
strings, the admin-approval explainer, and the failed/contact-support copy. **Never** hardcode a label off
an enum code — codes map to keys.
**(DEFERRED) — explicitly out of scope this phase:** admin refund create/approve console, leg-split editor,
ticket linkage, clawback "nurse already paid" banner, refund retry, self-service *partial* refund UI, and
holiday-specific policy overrides — all to **f15-b15** (admin) per [the roadmap](../README.md). Build the
read + cancel-request customer surface only.
## 4. Mocks & seams in this phase
This phase **introduces no new cross-cutting seam** — it reuses the established frontend mock pattern. Per
[operating-rules §6](../_shared/agent-operating-rules.md): the moment a needed shape is missing or ambiguous
in [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md), **append the gap to**
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and **mock behind the `services/refunds` seam** meanwhile:
- Put the mock behind the same `apis/clientApi.ts` interface the real calls use (a mock `clientApi`
selected by config/env, **never** an `if (mock)` scattered in a hook). The mock returns contract-shaped
data that exercises every UI state: a card refund walking `pending → processing → completed`, a
`bnpl_revert` refund with a future `expected_customer_refund_eta` (so the 710-day banner renders), a
`failed` refund, a multi-session policy preview with mixed refundable/locked sessions, and an
outside-policy `409` on cancel.
- Record the mock in your **report** and in
[`dev/shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
so it's swapped cleanly for the real b11 endpoints once they're confirmed live. Reuse the auth/payment
service for the live-vs-mock selection convention; do not invent a new one.
## 5. Critical rules you must not get wrong
- **Disclose the fee/refund *before* confirm.** The applicable cancellation policy (resolved by lead time
and actor) and its **refund % + fee %** must be on screen and acknowledged **before** the user can submit
the cancellation — an outside-policy fee is never a surprise. This is the whole point of the screen.
- **Refunds are admin-approved; the customer cannot self-refund.** The UI lets the customer *request* a
cancellation and *track* the refund — it must **never** present a "refund yourself" / "issue refund"
action. Reflect the admin-only, ticket-linked reality in the copy.
- **Surface the BNPL async window honestly.** For `refund_channel === 'bnpl_revert'`, show the
`expected_customer_refund_eta` and the **~710 business-day** window in plain language; never imply the
money is back instantly. Money flows back **through the provider** — don't imply Balinyaar pays the
customer directly.
- **Money is IRR `BIGINT` on the wire as a string of digits — no floats, ever.** Parse and format **only**
through the f0 money util (`formatIrrToToman`, integer-safe IRR parse); Toman is **display-only**; never
do client-side arithmetic that coerces an IRR string to a JS `number`. The refund is the **decomposition
of `gross = balinyaar_commission + nurse_payout`** — render the fee leg and the payout leg from the
contract's `platform_fee_refunded_irr` / `nurse_payout_refunded_irr`; do not recompute the split client-side.
- **Per-session, not all-or-nothing.** For multi-session bookings, only **un-started** sessions are
refundable; **completed-and-verified** sessions stay payout-eligible and must render as locked — never
offer to refund a session the contract marks non-refundable.
- **Never render a label off a raw enum code.** `cancellation_policy_code`, `refund_status`, `refund_channel`
map to **i18n keys** in both locales; treat `external_revert_reference`/IDs as opaque strings.
- **Caching is a feature.** Poll refund status **only** while non-terminal, stop at `completed`/`failed`;
invalidate booking + refund queries on the cancel mutation so nothing stale lingers; don't re-fetch the
policy preview on every keystroke. Respect the RSC/client boundary; MUI primitives stay MUI; shareable
composites (`CancellationPolicyDisclosure`, `RefundStatusCard`, `RefundEtaBanner`) live shared, not in a
page. Colours from tokens, MUI v9 API only, both locales in sync, RTL-correct.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/refunds` exists (`types.ts`/`keys.ts`/`apis`/`hooks`/`index.ts`) with types **derived from
the b11 contract**; live calls go through `clientFetch`, with the documented mock `clientApi` behind
the same seam until b11 endpoints are confirmed live.
- [ ] **Cancellation flow** resolves and **discloses the applicable policy fee/refund % before confirm**,
handles the multi-session refundable/locked breakdown, and submits via `useCancelBooking` (which
invalidates booking + refund caches).
- [ ] **Refund-status** screen renders pending → on-its-way → completed, plus failed; the **BNPL channel
shows the ~710-day window** from `expected_customer_refund_eta`; polling runs only while non-terminal.
- [ ] All money rendered via the f0 util (Toman display, integer-safe), no floats; the fee-leg split comes
from the contract, not client math.
- [ ] New shared composites have co-located `*.test.tsx`; `en.json`/`fa.json` in sync; RTL verified; colours
from tokens; `npm run check` green and `npm run test:ci` green for the shared components added.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/refunds` domain, any new route
segment, and the new shared components; any doc drift you touched corrected.
- [ ] Every contract gap hit was appended to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md); the mock is recorded
in the registry; the phase report is written.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (with the `services/refunds` mock active until the b11 endpoints are confirmed live):
1. **Policy disclosure before confirm.** Open a booking → **Cancel** → the screen shows the resolved tier
(label from the i18n key, not the raw code), the **refund %** and **fee %**, and the concrete Toman
amounts refunded vs kept — **before** any confirm button is enabled. Mock a >24h lead time → free/100%
refund; mock a <24h lead time → partial refund + fee. Confirm → routes to refund status.
2. **Multi-session breakdown.** Open a multi-session booking's cancel flow → un-started sessions show as
refundable, completed-and-verified sessions show as **locked** with a reason chip and cannot be selected.
3. **Refund status progression.** On the refund-status screen, the mock walks `pending → processing →
completed`; the stepper advances pending → on-its-way → completed; polling stops once completed.
4. **BNPL ETA.** A `bnpl_revert` mock refund shows the **~710 business-day** window with a Shamsi date
from `expected_customer_refund_eta` and provider-routed wording — not "instant".
5. **No self-refund.** There is **no** customer-facing "issue/approve refund" control anywhere; `failed`
shows contact-support copy, **not** a retry button.
6. **Locale + RTL.** Toggle `fa`/`en` → every string flips and is present in both files; layout mirrors
correctly in RTL; dark mode intact.
7. **Caching.** In React Query Devtools: the cancel mutation invalidates `bookingKeys.detail` and
`refundKeys.byBooking`; refund-status polling is active only while non-terminal; re-entering the screen
doesn't trigger a needless refetch.
8. `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs:** update the *Project Structure* tree in [`client/CLAUDE.md`](../../../client/CLAUDE.md) for the
new `services/refunds` domain, the new shared composites (`CancellationPolicyDisclosure`,
`RefundStatusCard`, `RefundEtaBanner`), and any new route segment; note the refund-status polling
convention if it's a new reusable pattern. Fix any drift you touched. If you discovered a refund/
cancellation business rule the `product/` docs don't capture, record it there (don't invent rules).
- **Contracts:** this phase **consumes** [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md)
(b11) — derive `services/refunds/types.ts` from it (and the published `swagger.json` snapshot for exact
casing); produce no contract. Append any missing/ambiguous shape (e.g. the customer-cancel command's
body, the per-session refundability flags, `provider_commission_reversed_amount` nullability) to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
— never edit a backend-owned file.
- **Handoff & report:** append a summary to
[`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
`dev/shared-working-context/reports/frontend-phase-10-report.md` (what was built, **what is now testable
and exactly how**, what is mocked behind the `services/refunds` seam and how it's swapped for the real b11
endpoints, the contract consumed + any gaps filed, follow-ups for f15-b15 admin). Update the
[mock registry](../../shared-working-context/reports/mocks-registry.md) for the `services/refunds` mock.
- **Memory:** save a `project` memory note for any non-obvious decision (the refund-status step mapping
pending→on-its-way→completed, the polling-only-while-non-terminal rule, the BNPL-ETA honesty rule, the
admin-only refund constraint reflected in UI), with a one-line `MEMORY.md` pointer.
@@ -0,0 +1,331 @@
# Frontend Phase 11 — BNPL checkout (installments)
> **Mission:** give the family an alternative to full-card payment at checkout — pay a booking **in
> installments** through a provider (دیجی‌پی / اسنپ‌پی / اقساط بالین‌یار). Build the five BNPL screens
> from the wireframe (D1 method → D2 plan → D3 eligibility → D4 contract/schedule → D5 wallet status),
> wired to the b12 BNPL endpoints, styled in the **financial terracotta** language the design system
> reserves for money/installments. The load-bearing product truth you must encode in the UI: **the
> installment repayment is owned by the provider, not Balinyaar** — the provider pays Balinyaar the full
> amount up-front and bears 100% of customer-default risk, so Balinyaar *shows* the installment status
> it is told about; it does **not** run the schedule. D5 is provider-reported status, never a
> Balinyaar-managed ledger.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md) (checkout
> & payment) + the **b12 BNPL contract** ([`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md)) ·
> **Unlocks:** nothing downstream depends on it (BNPL is an alternate checkout branch)
> **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 **alternate-checkout branch** of the customer money path. By f9 a family can already see the
price breakdown (C6 خلاصه و پرداخت) and pay a confirmed booking by card. This phase adds the second exit
off C6: instead of paying the full amount on a card, the family chooses **اقساط (installments)** and is
taken through a provider's BNPL flow — pick a provider, pick a plan, pass a credit check, accept a
repayment schedule, pay the down-payment — after which the **booking confirms exactly as the card path
confirms it** (the provider has paid Balinyaar in full). The family then tracks repayment from the
**کیف‌پول (Wallet)** tab.
The single rule that shapes every screen: in Balinyaar's books a BNPL order is **identical to a card
payment that lands net-of-fee in one inbound settlement** ([product/business/09](../../../product/business/09-installments-bnpl.md)).
The customer's 4-installment (or 3/6/12-month) repayment is **decoupled** from Balinyaar's escrow/EVV/payout
cycle. So the Wallet screen (D5) renders a **provider-reported** balance and due-date list — not a ledger
Balinyaar owns or can settle. The copy must make that ownership clear without scaring the user.
**What already exists (do not rebuild) — from prior phases:**
- **Foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the three actor shells, the
**customer 5-tab bottom nav** (خانه · رزروها · بیماران · **کیف‌پول/Wallet** · پروفایل), the
`services/{domain}` + TanStack Query pattern (a `keys.ts` factory, `apis/clientApi.ts`,
one-hook-per-file, mutation-invalidates-cache), the **money/format util** (`formatIrrToToman`,
integer-safe IRR parse, Shamsi date display) in `src/utils/`, the shared composites (stepper/progress
header, status chip, price-breakdown), the i18n namespaces — including a reserved **`bnpl`** namespace
— and the RTL baseline. Reuse all of it; do not re-derive the data pattern.
- **Checkout & payment** ([`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md)): the C6 summary-&-pay
screen with the commission/tax breakdown and the **escrow notice**, the card-payment redirect flow, the
confirmation screen, and the booking-confirmed state. This phase **adds a branch** to C6's payment-method
step and **reuses C6's confirmation/booking-confirmed UI** once the down-payment clears — it does not
build a second confirmation.
- **Refund & cancellation** ([`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md)): the cancellation
flow and the customer refund-status view, including the **BNPL revert ETA** copy ("~710 business days,
provider-owned"). When a BNPL booking is cancelled, the refund status surface from f10 is what the user
sees — **do not** build a BNPL-specific refund screen here; that path belongs to f10. This phase owns only
the *forward* checkout (D1D4) and the *status* view (D5).
> **Branch note:** BNPL is gated to bookings above a configurable threshold (e.g. total/duration) — a
> **config flag**, not a feature, per [scope notes](../README.md#scope-notes--deferrals). If the contract
> exposes an `bnplEligible` flag on the booking/checkout summary, hide the "اقساط" option when it's false;
> otherwise show it always and let D3 eligibility be the gate. Do not invent a client-side threshold rule.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md).
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary, layouts,
i18n, theme, cookies, the fetch services, anti-patterns). You add a `services/bnpl` domain and screens
under the customer shell; nothing above `[locale]`.
- **Invoke the `frontend-designer` skill before any visual work.** It is the design/brand contract. For
this phase it carries the rule that matters most: **money/installment surfaces use the terracotta
financial accent** (`--bal-terracotta` `#d98c6a`, the wireframe's "installments/financial" legend
colour) — D1D5 are terracotta-accented, distinct from the teal of the core booking flow. Ask it for the
installment-plan card, the eligibility-result panel, the repayment-schedule table, and the
outstanding-balance Wallet card.
- **Product truth:**
- [`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md) — the
full-upfront / provider-bears-risk / decoupled-repayment model. This is *why* D5 is provider-reported.
- [`product/payments/bnpl-landscape.md`](../../../product/payments/bnpl-landscape.md) — the provider
comparison: SnappPay (4 interest-free), Digipay (3/6/12 + 4-installment), Torob Pay (25% down, 6.6%),
Balinyaar in-house plan. Use these for the **provider/plan copy and fee/down-payment shapes** D1/D2 show.
Note: settlement timing is **not instant** and commission is **per-contract** — never hardcode a fee in
the client; render whatever the contract returns.
- **Wireframe:** [`product/wireframes/index.html`](../../../product/wireframes/index.html), **Section D
(D1D5)** — the exact screens, RTL Persian, terracotta financial accent. D1 روش پرداخت, D2 انتخاب طرح
اقساط, D3 اعتبارسنجی, D4 تایید طرح و قرارداد, D5 پیگیری اقساط (in Wallet). D5 carries the bottom tab nav
(Wallet active); D1D4 are mid-flow (no tab bar).
- **Contract to consume:** [`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md) (from b12) —
the request/response shapes, routes, the BNPL `status` enum codes, and the eligibility result shape. Plus
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR-as-string,
Toman display, enums-as-codes, UTC + Shamsi) and [`api-conventions.md`](../../contracts/conventions/api-conventions.md)
(the envelope). **Types come from the contract — do not guess shapes.**
- The existing `src/services/payment/*` from f9 (the checkout service) and `src/services/auth/*` — the
exact service pattern (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`) your
new `services/bnpl` copies; and the C6 screen you branch from.
## 3. Scope — build this
A new **`services/bnpl`** domain + the five wireframe screens, all under the **customer** shell, all
terracotta-financial. Build the forward checkout (D1D4) and the Wallet status view (D5).
### 3.1 `services/bnpl` (the data layer — copy the f0 pattern)
Under `src/services/bnpl/`:
- **`types.ts`** — derived from [`bnpl.md`](../../contracts/domains/bnpl.md). At minimum:
`BnplProvider` (`providerCode` e.g. `digipay` / `snapppay` / `balinyaar`, display name, supported plans),
`BnplPlanOption` (`termMonths` 3/6/12 or `installmentCount` 4, `feePercent`, `downPaymentPercent`,
`monthlyAmountIrr`, `totalIrr` — all IRR amounts as **string**), `BnplEligibilityResult`
(`eligibilityStatus`: `eligible` / `not_eligible` / `ceiling_exceeded`, `creditCeilingIrr`),
`BnplSchedule` (`downPaymentIrr`, `dueDate` + `amountIrr` per installment), `BnplTransaction`
(`status` state-machine code: `eligible` / `token_issued` / `verified` / `settled` / `reverted` /
`cancelled` / `failed`, `installmentCount`, `outstandingBalanceIrr`, `installments[]` with per-row
`status` and `dueDate`). **All money is the IRR-string type from the money-and-types contract.**
- **`keys.ts`** — a query-key factory: `bnplKeys.providers(bookingId)`,
`bnplKeys.eligibility(bookingId)`, `bnplKeys.transaction(bookingId)` / `bnplKeys.walletStatus()`.
- **`apis/clientApi.ts`** wrapping `clientFetch``getBnplOptions(bookingId)`,
`checkEligibility({ bookingId, nationalId, mobile, consent })`, `issueToken({ bookingId, providerCode,
planSelection })`, `acceptSchedule({ bookingId, ... })` / pay-down-payment, `getBnplTransaction(bookingId)`,
and `getWalletInstallments()`. Map these to the **b12 routes** from the contract
(`POST /checkout/bnpl/eligibility`, `POST /checkout/bnpl/token`, the provider-handoff/verify return, and
the read endpoints). If a route or shape is missing from the contract, see §4.
- **`hooks/`** — one hook per file: `useBnplOptions` (query), `useCheckEligibility` (mutation),
`useIssueBnplToken` (mutation), `useAcceptBnplSchedule` (mutation, **invalidates the booking + checkout
query** so the confirmed booking is not refetched stale), `useBnplTransaction` (query),
`useWalletInstallments` (query). Deliberate `staleTime` (eligibility/options are short-lived;
wallet-status is moderate). Don't toast 401/403/5xx — only domain 4xx (ineligible, ceiling-exceeded,
token-expired) get a message.
- **`index.ts`** barrel.
### 3.2 D1 · روش پرداخت (Payment method) — the branch off C6
The payment-method chooser the family reaches from **C6 (Summary & pay)**. Shows the **payable amount**
(reuse the f0 price-breakdown / money util — Toman display), then the method options:
- **پرداخت کامل با کارت (full card)** — selecting it returns to / continues the **f9 card flow** (do not
rebuild it).
- **Installment providers** (terracotta-accented option cards, each with provider branding/label and a
one-line plan summary): **دیجی‌پی** (312 installments), **اسنپ‌پی** (۴ قسط بدون بهره / 4 interest-free),
**اقساط بالین‌یار** (in-house plan). Render the provider list **from `useBnplOptions`** — do not hardcode
the provider set or fees; the contract is the source. Primary action: "ادامه با {provider}".
States: options-loading (skeleton), loaded, empty/none-eligible (only card shown), error (retry / fall back
to card). If the booking is not BNPL-eligible (§1 branch note), the installment options are hidden and only
card shows.
### 3.3 D2 · انتخاب طرح اقساط (Choose plan)
For the chosen provider, the plan selector. Shows the **total amount** and the plan options the contract
returned — e.g. **۳ ماهه (بدون کارمزد)**, **۶ ماهه (کارمزد ۴٪)**, **۱۲ ماهه (کارمزد ۹٪)** — each rendering
its **monthly amount** and (where present) **down-payment %** (پیش‌پرداخت ۲۰٪). A single-select plan card
group (terracotta), plus a down-payment indicator. **Every amount comes through the money util from the
contract's IRR strings** — the client computes nothing about money beyond formatting; if the contract gives
per-plan `monthlyAmountIrr` use it, otherwise show only what the contract provides. Primary action: "ادامه".
States: loading, loaded, none (no plans for this provider → back to D1).
### 3.4 D3 · اعتبارسنجی (Credit eligibility)
The provider credit check. Fields: **کد ملی (national ID)**, **موبایل (mobile, prefilled from the session)**,
and a **consent checkbox** — "با استعلام اعتبارسنجی … موافقم" — which **gates** the submit (no consent →
disabled). On submit, call `useCheckEligibility`. Result panel:
- **approved** → "اعتبار شما تایید شد" + the returned **credit ceiling** (سقف اعتبار, money util). Action:
"تایید و ادامه" → D4.
- **not_eligible / ceiling_exceeded** → a clear declined panel with a **"پرداخت با کارت" fall-back** to the
f9 card flow. (Ceiling-exceeded copy: the booking total exceeds the available credit.)
- **error/timeout** → retry or fall back to card.
National-ID input validation is client-side format only (10 digits) — the real check is the provider's;
surface its result, don't pre-judge. Reuse the f0 phone-field for the mobile display.
### 3.5 D4 · تایید طرح و قرارداد (Schedule & contract)
The repayment schedule + contract acceptance. Renders the **repayment table** from the contract's schedule:
a **پیش‌پرداخت (down-payment) — today** row, then **قسط ۱…N** rows each with a **Shamsi due date** and an
**amount** (money util). A **terms/contract acceptance checkbox** that **gates** the final action. Primary
action: **"تایید نهایی و پرداخت پیش‌پرداخت"** → `useIssueBnplToken` / `useAcceptBnplSchedule`, which performs
the **provider handoff** (redirect or in-app token state — follow whatever the contract specifies, mirroring
f9's card redirect handling) and pays the down-payment. On success: **the booking confirms** — route to the
**f9 confirmation / booking-confirmed UI** (reused, not rebuilt), now reflecting "paid via installments".
States: schedule-loading, handoff/redirect in-progress (spinner + "در حال انتقال به {provider}"), success
(→ confirmation), declined/expired-token (→ retry or card).
The contract-acceptance copy must reflect the ownership truth: **the installment agreement is between the
customer and the provider** (provider-financed, provider bears risk); Balinyaar is the merchant being paid
in full. Get this exact copy from the frontend-designer skill / product docs, in both locales.
### 3.6 D5 · پیگیری اقساط (Installment status — in Wallet)
The Wallet-tab view of an active installment plan (bottom tab nav, **Wallet active**). It reads
`useWalletInstallments` and renders a **provider-reported** status:
- **Outstanding-balance card** (مانده بدهی, money util) — terracotta.
- **Next-installment** date + an **"پرداخت زودهنگام" (early pay)** affordance — but **early-pay is a
provider action, not a Balinyaar transaction**: link out / hand off to the provider; do **not** build a
Balinyaar payment for it.
- **Due-date list** — one row per installment with a **status chip** (reuse the f0 status chip):
پرداخت‌شده (paid) / سررسید نزدیک (due soon) / آینده (future), each with a Shamsi due date and amount.
- A short **"وضعیت اقساط نزد {provider} ثبت می‌شود" / provider-owned** note so the user understands
Balinyaar is displaying, not managing, this schedule.
States: no-active-plan (empty Wallet installments section), loading, error (provider status unavailable →
"وضعیت اقساط در دسترس نیست"). If the f12 nurse-earnings Wallet content also lands here later, keep D5 a
self-contained section under the Wallet route.
### 3.7 i18n & tokens
Every user-visible string is a key in the **`bnpl`** namespace (seeded in f0) in **both** `messages/en.json`
and `messages/fa.json`, in sync, **`fa` default & RTL-first**. Provider names render from the contract but
their surrounding copy is i18n. Colours from `tokens.css` (the terracotta financial accent via the
designer's tokens) — never hardcoded hex in `sx`.
### Out of scope (DEFERRED — do not build here)
- **BNPL refund / revert UI** — the cancellation + refund-status surface (incl. the BNPL "~710 business
days, provider-owned" ETA) is **f10** ([`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md)). Don't
duplicate it.
- **Admin BNPL revert/cancel console** — admin-side BNPL ops live in **f15**
([`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md)).
- **Multiple-provider routing / tranched settlement** — b12 ships one provider mock; treat the provider
list as data, but don't build provider-comparison or multi-provider reconciliation UI.
- **Customer per-installment webhook / default handling** — there is none on Balinyaar's side; the provider
owns it (D5 is read-only status).
## 4. Mocks & seams in this phase
No new client seam family is *introduced* here — you **reuse the `services/{domain}` seam pattern from
[`frontend-phase-0.md`](./frontend-phase-0.md)**. The BNPL provider integration itself is mocked **on the
backend** behind `IBnplProvider` (b12) — the frontend just consumes the b12 endpoints.
- **If the b12 contract is published and live:** wire `services/bnpl/apis/clientApi.ts` to the real routes;
no client mock needed.
- **If b12 is not yet merged when you run:** build a **mock `clientApi`** behind the same `services/bnpl`
seam (per operating-rules §6) that returns deterministic shapes — a provider list, an always-`eligible`
eligibility result with a sample ceiling, a sample 6-month plan + schedule, and a sample D5 status with a
paid/due/future mix — so D1D5 are fully exercisable. Record the mock in your report and as a row in
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md); it is swapped for
the real `clientApi` (one file) once b12 lands.
- **Contract gaps:** any shape the contract doesn't provide that D1D5 need (e.g. per-plan `monthlyAmountIrr`,
the schedule rows, the D5 provider-reported `installments[]`, a `bnplEligible` flag on the booking) →
**append a request to**
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the seam meanwhile. Never edit backend files.
## 5. Critical rules you must not get wrong
- **The installment repayment is OWNED BY THE PROVIDER. Balinyaar shows status; it does not run the
schedule.** D5 renders **provider-reported** balance/due-dates/status — it is **not** a Balinyaar-managed
ledger and Balinyaar never settles a customer installment. "Early pay" hands off to the provider.
- **The provider pays Balinyaar the full amount up-front and bears 100% of customer-default risk** — the
contract-acceptance copy (D4) and the D5 ownership note must reflect that the installment agreement is
**customer ↔ provider**, interest-free-to-customer, provider-financed. Do not imply Balinyaar lends.
- **Money correctness (verbatim, the payments-track invariants that bind the client):** all internal money
is **IRR `BIGINT`, no floats anywhere** — the client receives IRR as **strings** and **must use the money
util** (`formatIrrToToman` / integer-safe parse) for every amount; never `Number()`-coerce an IRR string
or do arithmetic on money in the client. The split is **gross = commission + payout**; the **ledger is
append-only and balanced**; settlement is reconciled via **webhook idempotency**; payout is **one per
booking** and **dispute-window-gated**. The client never computes commission, fee, ceiling, or schedule
amounts — it **renders whatever the contract returns** (commission and BNPL fee are per-contract config,
never hardcoded in the UI).
- **A confirmed BNPL booking is, to Balinyaar, a card payment that landed net-of-fee** — after the
down-payment clears, **reuse the f9 booking-confirmed/confirmation UI**; do not build a parallel
confirmation, and do not show the customer a Balinyaar-side installment ledger.
- **Eligibility & consent are gates.** D3 submit is disabled without the consent checkbox; D4 final action is
disabled without contract acceptance. Decline / ceiling-exceeded must offer the **card fall-back** (f9),
never a dead end.
- **RSC/client boundary** — payment interactions are client components; don't pull `next-intl/server` or
server cookies into them. **Caching:** set `queryKey`/`staleTime` deliberately; the
accept-schedule mutation **invalidates the booking/checkout queries** so the confirmed booking isn't
refetched stale and the wallet status reflects the new plan.
- **RTL-first, `fa` default, both locales in sync; terracotta financial accent from tokens** (not teal, not
hardcoded hex); MUI **v9** primitives reused (no re-implemented Button/Card); shareable composites at the
shared level. Keep `npm run check` green throughout.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/bnpl` exists following the f0 pattern (types from the b12 contract, `keys.ts`, `clientApi`,
one-hook-per-file, deliberate caching + mutation invalidation).
- [ ] **D1D5** are built under the customer shell, terracotta-financial (frontend-designer-driven), with
all states (loading / loaded / empty / declined / error) handled; D5 lives under the **Wallet** tab.
- [ ] The **branch off C6** works: choosing اقساط leads into D1D4 and, on a cleared down-payment, **routes
to the reused f9 confirmation with the booking confirmed**; the card fall-back is reachable from
decline/ceiling-exceeded/error.
- [ ] D5 is presented as **provider-reported status** with the ownership note; early-pay hands off to the
provider; no Balinyaar-side installment ledger is implied anywhere.
- [ ] Every money value renders through the money util; nothing about money is computed client-side; no
hardcoded fee/ceiling/provider set.
- [ ] `bnpl` strings in **both** `en.json`/`fa.json`, in sync, RTL-correct; colours from tokens.
- [ ] `npm run check` green; `npm run test:ci` green for any shared composite you add/touch (e.g. a
reusable installment-schedule row/card or plan-option card gets a co-located `*.test.tsx`).
- [ ] `client/CLAUDE.md` *Project Structure* updated if you add a route group/folder for the BNPL screens
or the `services/bnpl` domain.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev`, sign in as a customer, reach a **confirmed** booking's checkout (C6) — using the f9
flow (or the seam mock if b12 isn't merged):
1. On **C6**, choose **اقساط****D1** shows the payable amount + provider options (دیجی‌پی / اسنپ‌پی /
اقساط بالین‌یار) loaded from the contract/mock, terracotta-accented. Pick a provider → "ادامه با …".
2. **D2** shows the plan options (e.g. ۳/۶/۱۲ ماهه) with monthly amount + down-payment for the chosen
provider; select a plan → "ادامه". Verify amounts render in Toman via the money util.
3. **D3** — enter کد ملی, confirm the prefilled mobile, **tick consent** (submit stays disabled until you
do), submit → **mock approves** with a credit ceiling → "تایید و ادامه". Then verify the **declined**
path (mock a `not_eligible`/`ceiling_exceeded`) shows the panel **and the card fall-back**.
4. **D4** — the repayment table shows پیش‌پرداخت (today) + قسط rows with **Shamsi due dates** and amounts;
**accept the contract** (final action disabled until ticked) → "تایید نهایی و پرداخت پیش‌پرداخت" →
provider handoff → **the booking confirms** and you land on the **f9 confirmation** marked as paid via
installments.
5. Open the **کیف‌پول (Wallet)** tab → **D5** shows the **provider-reported** outstanding balance, next due
date, the per-installment due list with status chips (پرداخت‌شده / سررسید نزدیک / آینده), the ownership
note, and an early-pay hand-off (not a Balinyaar payment).
6. Switch locale to `en` → all BNPL strings translate, layout flips correctly (RTL ⇄ LTR); dark mode intact.
7. `npm run check` and (if a shared composite changed) `npm run test:ci` pass.
This becomes the "what can be tested" section of your report.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* for the BNPL route(s) and the `services/bnpl`
domain; if you establish a reusable installment-schedule/plan-option composite, note it. If you discover a
BNPL business-rule gap while building (e.g. the eligibility/ceiling copy, the threshold flag), record the
decision in [`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md)
— don't invent rules; flag uncertain ones in your report.
- **Contract:** **consume** [`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md) (b12) for all
types/routes — do **not** guess shapes. Any gap (per-plan monthly amount, schedule rows, D5
`installments[]`, a `bnplEligible` flag) goes to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md).
- **Handoff & report:** append your phase summary to
[`shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
`reports/frontend-phase-11-report.md` (the D1D5 screens + `services/bnpl` built; **what is now testable
and exactly how** — the C6→D1→…→D4→confirm→D5 walkthrough; what is mocked behind the seam and how it
swaps to the real b12 `clientApi`; the contract consumed + any gaps filed; follow-ups for f15 admin BNPL).
Add/refresh the BNPL row in
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) if you mocked the
client seam.
- **Memory:** save a `project` memory note for the non-obvious decision that **D5 is provider-reported,
not a Balinyaar-managed ledger**, and that BNPL confirmation **reuses the f9 confirmation** — with a
`MEMORY.md` pointer, so a future agent doesn't build a Balinyaar installment ledger or a duplicate
confirmation screen.
@@ -0,0 +1,334 @@
# Frontend Phase 12 — Nurse earnings & payout history
> **Mission:** give a nurse a clear, trustworthy view of the money they have earned and when it
> arrives. Build the **nurse earnings** screen that distinguishes the four money states a nurse cares
> about — **pending** (still in escrow, dispute window open), **eligible** (cleared, awaiting the weekly
> batch), **paid** (transferred, with a `transfer_reference` and `paid_at`), and **clawback-applied**
> (a refunded-after-payout amount netted out of the total) — plus the **payout history** list and a
> **batch detail** view. Money is read-only here (nurses don't trigger transfers); the job is to render
> the ledger-derived numbers correctly, explain the weekly cadence and the dispute-window gate in plain
> Persian, and never confuse "earned" with "paid".
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) (booking
> detail · sessions · EVV completed-work view) + the **b13** payouts contract
> ([`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md)) · **Unlocks:** —
> (last money-path frontend phase; the nurse earnings surface other phases link into)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **last money-path frontend phase**. The customer-side money flows are already built
(checkout/payment in f9, refund/cancellation in f10, BNPL in f11); this phase finally closes the loop
on the **nurse** side — *"I did the work, where is my money?"*. A nurse completes visits (EVV check-out
in f8), the booking enters a **72h dispute window**, then the amount becomes eligible for the **weekly
payout batch**, which an admin runs (b13). Once the batch processes, the nurse sees the transfer
landed against their verified primary IBAN. This phase renders all of that as a read-only nurse view —
no nurse ever initiates a transfer, retries a payout, or runs a batch (those are admin actions in
b13 / f15).
**What already exists (do not rebuild) — link the prior phases:**
- **Foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the **nurse app shell** and its
route group/segment, role-aware nav from `AuthContext`, the `services/{domain}` + TanStack Query
caching pattern (copy the `auth` service shape), the contracts→`types.ts` step, the shared composite
components (status chip, card, stepper/progress header), the **money/format util** in `src/utils/`
(`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display), and the i18n namespace
conventions. **Reuse the money util and the status chip — do not re-implement them.**
- **Booking detail · sessions · EVV** ([`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md)): the
nurse's view of completed visits, the per-session schedule, the EVV check-in/out flow, and the booking
status timeline. Earnings rows **link back to** these booking/session screens; do not duplicate the
booking detail here — deep-link to it.
- **Checkout & refund money UI** ([`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md),
[`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md)): the price-breakdown / money-display
conventions on the customer side. Match the same Toman-display + IRR-string handling; **reuse the same
money util**, don't fork a second formatter.
- **The contract** ([`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md), produced by
**b13**): the exact request/response shapes, routes, status codes, and enum codes for the nurse
earnings & payout endpoints. This is the **source of truth for types** — do not guess shapes.
> **Read note:** the file [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) is the prior nurse
> phase you build on; if it is not yet on disk when you run, rely on its handoff
> (`dev/shared-working-context/reports/frontend-phase-8-report.md`) and the nurse-shell facts from f0.
## 2. Required reading (do this first)
- [`../_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 this phase is graded against.
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client
boundary, the `services/{domain}` shape, TanStack Query caching/invalidation, i18n in both locales,
tokens-based colours, RTL, the `App*` library). Non-negotiable.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(palette, tokens, typography, the `App*` library, status-chip styling, the money-display look,
empty/loading/error treatments, RTL mirroring). **All UI in this phase goes through it.** The four
earnings states must be visually distinct and instantly readable; the designer skill owns how.
- **Business truth — read before designing anything:**
- [`../../../product/business/10-payouts.md`](../../../product/business/10-payouts.md) — the weekly
batch model, the **EVV + dispute-window eligibility gate**, the **payout amount =
`gross_price_irr balinyaar_commission_irr`** rule, clawback netting (`gross_earnings`,
`clawback_applied`, `net_amount`), one-payout-per-booking, holiday-aware scheduling, verified-IBAN
payout with `transfer_reference`. This is *why* each state exists.
- [`../../../product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md)
— Q2 ("who pays the nurse, and when"): the nurse is paid by **Balinyaar** on the **normal weekly
schedule after the dispute window closes**, the **same amount whether the family paid by card or
BNPL** (the BNPL provider commission is a Balinyaar expense, **never** deducted from the nurse). The
worked example (gross 5,000,000 → nurse 4,250,000) is the copy you explain to the nurse.
- **The contract you consume:** [`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md)
— the b13 nurse-read endpoints and shapes. Also read the cross-cutting conventions:
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, pagination `page`/`page_size`, status codes) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md)
(**IRR as integer string on the wire**, parse with integer-safe helpers, Toman is display-only, UTC
timestamps → Shamsi on the client, enums as stable string codes).
- **Code to mirror:** `client/src/services/auth/*` (the canonical `types.ts`/`keys.ts`/
`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts` shape every new domain copies) and the f0 money/format
util + status-chip component. The nurse earnings list pattern (paginated, status-filtered) mirrors the
nurse booking list from f8.
## 3. Scope — build this
Build the **`payouts` domain service** (nurse read) and the **two nurse screens** it feeds. Everything
admin-side (create/process/retry a batch, the clawback write-off queue, eligible-earnings preview) is
**(DEFERRED)** to the admin console — see §3.5.
### 3.1 `services/payouts` domain (nurse read)
Copy the `auth` service shape into `client/src/services/payouts/`:
- **`types.ts`** — string-literal union types and response shapes **derived from
[`payouts.md`](../../contracts/domains/payouts.md)** (not guessed). Expect at least:
- an **earnings-summary** shape — the four-bucket roll-up: `pending_total_irr`, `eligible_total_irr`,
`paid_total_irr`, `clawback_outstanding_irr`, and a derived **payable/net balance** (which **may be
negative** when clawbacks exceed earnings — model it as a signed string, never clamp to zero).
- an **earnings-item** shape — one row per completed booking/session contributing to earnings:
`booking_id` (+ enough to deep-link), `gross_price_irr`, `balinyaar_commission_irr`,
`nurse_payout_amount` (= gross commission), an **earnings state** enum
(`pending` | `eligible` | `paid` | `clawback_applied`), `dispute_window_ends_at` (for the
pending countdown/explanation), `payout_eligible_at`, and — when paid — `paid_at` +
`transfer_reference` + `nurse_payout_id` / `batch_id`.
- a **payout (history) item** shape — one row per `nurse_payouts`: `id`, `batch_id`,
`gross_earnings_irr`, `clawback_applied_irr`, `net_amount_irr` (= gross clawback), `amount`
(actually transferred), `iban_snapshot` (masked, last-4 only), `transfer_reference`, a **payout
status** enum (`pending` | `processing` | `paid` | `failed`), `paid_at`, `failure_reason`.
- a **batch detail** shape — the `nurse_payout_batches` context for one payout: `period_start`/
`period_end` (holiday-shifted), `status`, `total_amount`, `payout_count`, `processed_at`, and the
list of **booking links** (`nurse_payout_booking_links`) so the nurse sees exactly which bookings a
payout covered.
- paginated list envelopes (`items` + `total` + `page`/`page_size`) per the api-conventions.
- **Use string-literal unions for every enum**; never hardcode a display label off a code — labels are
i18n keys.
- **`keys.ts`** — a query-key factory: `payoutKeys.all`, `payoutKeys.earningsSummary()`,
`payoutKeys.earningsList(filters)` (key includes the state filter + page so each filter caches
separately), `payoutKeys.history(page)`, `payoutKeys.batchDetail(payoutId|batchId)`.
- **`apis/clientApi.ts`** — a `PayoutsClientApi` namespace wrapping `clientFetch` for the nurse-read
routes (exact paths from the contract; expected from b13):
- `GET .../get_nurse_earnings_balance` → earnings summary (the four buckets + net balance).
- `GET .../get_nurse_earnings?state=&page=&page_size=` → paginated earnings items, filterable by state.
- `GET .../get_nurse_payout_history?page=&page_size=` → paginated payout history.
- `GET .../get_nurse_payout/{id}` (or batch detail) → one payout + its batch context + booking links.
- (Routes are `snake_case`; derive the exact segments from the published contract — don't assume.)
- Add a `serverApi.ts` **only** if an RSC prefetches the summary for first-paint (optional; see 3.4).
- **`hooks/`** — one hook per file: `useNurseEarningsBalance.ts` (`useQuery`),
`useNurseEarnings.ts` (`useQuery`, takes the state filter + page), `useNursePayoutHistory.ts`
(`useQuery`, paged), `useNursePayoutDetail.ts` (`useQuery` by id). All **read-only `useQuery`** — there
are **no mutations** in this phase (nurse never writes payout state). Set a deliberate `staleTime`
(earnings move slowly — a generous `staleTime`, e.g. minutes, avoids needless refetch).
- **`index.ts`** — re-export the **hooks only** (not `types`/`keys`/`apis`), per the client barrel rule.
### 3.2 Nurse earnings screen
The nurse's money home, in the nurse shell. Composition:
- A **balance header** (the f0 money util formats every amount; Toman display, Shamsi where dates show):
the **net payable balance** prominently, with the four-bucket breakdown beneath —
**pending / eligible / paid (lifetime) / clawback outstanding**. When the net balance is **negative**
(clawbacks exceed earnings), show an explicit **"owed back" state** (don't render a bare minus sign as
if it were a positive amount) — the designer skill owns the visual.
- A short, plain-Persian **explainer** of the cadence and gate (an info callout / collapsible "how
payouts work"): *paid in weekly batches; an amount becomes eligible only after the visit is verified
and the 72-hour dispute window closes; the same amount whether the family paid by card or installments.*
This copy comes straight from `product/business/10-payouts.md` + `cancellation-and-payout.md` — both
i18n keys, both locales.
- A **state-segmented earnings list** (tabs/segmented control filtering by earnings state →
`useNurseEarnings(state, page)`), each item a **shared earnings-row component** showing the booking
reference, the **three-amount breakdown** (gross / commission / nurse payout) via the price-breakdown
primitive, the **earnings-state chip** (reuse the f0 status chip), and the state-specific affordance:
- **pending** → "in escrow · dispute window open" with the time-to-eligible derived from
`dispute_window_ends_at` (display-only; the *server* decides eligibility — never compute eligibility
client-side, only render the countdown).
- **eligible** → "cleared · awaiting the next weekly batch" (+ an estimated window if the contract
supplies one; otherwise generic copy — never invent a date).
- **paid** → "paid" with `paid_at` (Shamsi) + the `transfer_reference`; links to the payout detail.
- **clawback_applied** → a **net explanation**: the original earned amount, the clawback amount, and
the resulting net — so the nurse understands *why* a paid total is lower than expected (a booking was
refunded after payout). Link to the refund/booking context.
- Each row **deep-links** to the booking/session detail from f8 (don't rebuild it).
- **Empty / loading / error** states for the list (loading skeletons; "no earnings yet" empty; a retry
affordance on error — but **don't** toast 401/403/5xx in the hook; the fetch layer already does).
### 3.3 Payout history list + batch detail
- **Payout history** — a paginated list (`useNursePayoutHistory`) of the nurse's `nurse_payouts`, newest
first: per row the **net amount transferred**, the **payout-status chip**
(`pending`/`processing`/`paid`/`failed`), `paid_at` (Shamsi), the masked IBAN (last-4 only — it is an
encrypted/masked field), and the `transfer_reference`. A **failed** payout shows its `failure_reason`
as an informational banner (read-only — the nurse cannot retry; retry is an admin action).
- **Payout / batch detail** — one payout expanded (`useNursePayoutDetail`): the batch period
(holiday-shifted `period_start`/`period_end`, Shamsi), `processed_at`, the **money decomposition**
(`gross_earnings_irr` `clawback_applied_irr` = `net_amount_irr`; `amount` actually transferred), the
`transfer_reference`, and the **list of bookings this payout covered** (the
`nurse_payout_booking_links` rows), each deep-linking to its booking detail. This is the nurse's
reconciliation view — "this transfer paid for these specific visits."
- **Empty / loading / error** for both (loading skeletons; "no payouts yet" empty; error retry).
### 3.4 Caching & data-flow rules (this is graded)
- All reads go through **`clientFetch` in `services/payouts/apis`** — never raw `fetch()`.
- **TanStack Query with deliberate keys + `staleTime`**: the summary and lists key separately (state
filter + page are part of the key) so switching the state tab or paging never refetches data already
in cache. A generous `staleTime` is correct here (earnings change on a weekly cadence, not per second).
- **No needless refetch / re-render**: subscribe to slices with `select` where a screen needs only part
of the payload; keep the state-filter tab state colocated low; stable references for row callbacks.
- **No mutations** ⇒ no invalidation logic to write this phase; if a future phase adds a nurse action,
it invalidates `payoutKeys` then. Optionally **prefetch the summary in the nurse-shell RSC** for a
no-flash first paint (via `serverApi.ts` + `initialData`/hydration) — only if it removes a real
round-trip and respects the RSC/client boundary.
### 3.5 Out of scope (DEFERRED — do not build here)
- **Admin payout console** — create/process/retry batch, the eligible-earnings preview wizard, the
clawback write-off queue, per-payout failure retry → **(DEFERRED** to
[`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md), the admin & partner console).
- **Nurse bank-account add/verify (استعلام شبا) UI** — the add-IBAN → pending-verification → verified/
failed flow → **(DEFERRED**; built in the nurse onboarding/profile phase
[`frontend-phase-2-b3.md`](./frontend-phase-2-b3.md). This phase only *displays* the masked
`iban_snapshot` on a payout; it never edits bank accounts.)
- **On-demand / instant withdrawal**, per-nurse payout-frequency settings → **(DEFERRED** product-side;
MVP is weekly batches only — see `product/business/10-payouts.md` (c)).
- **Computing eligibility or payout dates on the client** → never; the **server** owns eligibility and
holiday-shifted dates. The client only renders what the contract returns (see §5).
## 4. Mocks & seams in this phase
- **No new seam is introduced here.** This phase consumes the b13 nurse-read endpoints; the
bank-transfer rail (`IBankTransferProvider`) and IBAN-ownership (`IIbanOwnershipVerifier`) seams live
**server-side** and were introduced in **b13** — the frontend never touches them.
- **If the b13 contract isn't merged when you run** (or a needed shape is missing): build a **mock
`PayoutsClientApi`** behind the **same `services/payouts` seam** (the namespace object the hooks call),
returning real-shaped fixtures that cover **all four earnings states** + a **negative net balance**
(clawback > earnings) + a **failed** payout, so every UI state is exercisable. Then:
- append the missing/needed shape to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(you **request** it; you never edit backend files), and
- record the mock in your phase report + the
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so it's swapped for the
real `clientApi` cleanly once b13 lands (per operating-rules §67). The hooks/screens stay unchanged
on swap — only the `apis/clientApi.ts` implementation flips.
## 5. Critical rules you must not get wrong
**Money correctness (verbatim, the sacred invariants across b9b13):** money is **IRR `BIGINT`, no
floats** — parse the wire integer string with the integer-safe util, never `Number()`/float math;
**Toman is display-only**. The three booking amounts always satisfy **gross = commission + payout**
(`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`); render the breakdown so it sums.
The ledger is an **append-only, balanced double-entry ledger** — the nurse's **payable balance is
derived from the ledger and may go negative** (don't clamp it to zero); a clawback **nets**, it does not
auto-reverse. Payout gating is **dispute-window gating**: an amount is eligible only after EVV
completion **AND** `dispute_window_ends_at < now()` — never show "eligible"/"paid" for an amount still in
its dispute window, and **never compute eligibility on the client**. **One payout per booking**
(`nurse_payout_booking_links.booking_id` is UNIQUE) — a booking appears in exactly one payout; the batch
detail's booking links reflect that, don't double-count. **Webhook idempotency** is a server concern, but
its consequence on the client is real: never assume a settlement/transfer is instant — render the status
the contract returns (`pending`/`processing`/`paid`/`failed`), not an optimistic "done".
**Payout-amount rule (do not get this wrong):** the nurse payout is **`gross_price_irr
balinyaar_commission_irr`**, identical whether the family paid by **card or BNPL**. The **BNPL provider
commission (`bnpl_commission_irr`) is a Balinyaar expense and is NEVER deducted from the nurse** — never
surface it on the nurse earnings screen, never subtract it from a nurse amount. The nurse's number is
payment-method-invariant.
**Read-only & authority:** the nurse view is **strictly read-only** — no transfer, no retry, no batch
action, no eligibility computation. The **server is the only authority** on eligibility, holiday-shifted
dates, and amounts; the client renders the contract's values and only ever *displays* a countdown
derived from `dispute_window_ends_at` (cosmetic, never a gate).
**PII / masking:** `iban_snapshot` is an encrypted/masked field — show **last-4 only**, never a full
IBAN; `transfer_reference` is an opaque string shown for reconciliation. Don't log full sensitive values.
**Frontend invariants:** respect the **RSC/client boundary** (no `next/headers`/`next-intl/server`/
`@/lib/cookies/server` in client components); **design RTL-first**, `fa` default, **every string in both
`en.json` and `fa.json`** in sync; **colours from `tokens.css`** (the four state chips use the
`--bal-{success,warning,info,error}` semantic tokens, never hardcoded hex); **MUI v9 API only**, pre-built
themes only; **MUI primitives stay MUI**, shared composites (earnings row, payout row, balance header)
live at the **shared** level with a co-located `*.test.tsx`, not inline in the page.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus this phase's specifics:
- [ ] `services/payouts/` exists in the `auth`-service shape (`types.ts` from the **contract**, `keys.ts`,
`apis/clientApi.ts`, read-only `hooks/use*.ts`, hooks-only `index.ts`); no raw `fetch()`.
- [ ] The **nurse earnings screen** renders the **net payable balance** (correct when **negative**), the
four-bucket breakdown, the cadence/dispute-window explainer (both locales), and a **state-segmented
earnings list** whose four states (**pending / eligible / paid / clawback_applied**) are visually
distinct, each with the three-amount breakdown and the correct state affordance, deep-linking to the
f8 booking detail.
- [ ] The **payout history** list + **payout/batch detail** render the net decomposition
(`gross_earnings clawback_applied = net_amount`), the masked IBAN (last-4), the
`transfer_reference`, `paid_at` (Shamsi), the status chip, a **failed** payout's `failure_reason`,
and the **list of bookings each payout covered**.
- [ ] **Empty / loading / error** states exist for both lists and the detail; hooks don't toast 401/403/5xx.
- [ ] Caching is deliberate: state-filter + page are part of the query key, `staleTime` set sensibly, no
needless refetch on tab/page switch; minimal re-renders.
- [ ] All money via the **f0 money util** (IRR-string integer-safe parse → Toman display); the three
amounts sum; no float math anywhere.
- [ ] `en.json`/`fa.json` in sync; RTL-correct; colours from tokens (state chips off the semantic tokens).
- [ ] `npm run check` green; `npm run test:ci` green for the shared components added (earnings row, payout
row, balance header each have a co-located test).
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/payouts` domain and any new
shared components / nurse route segment; the `frontend-designer` skill was invoked for the visual work.
## 7. How to test (what a human can verify after this phase)
Prereq: the b13 nurse-read endpoints are reachable (`npm run dev` against the API), **or** the mock
`PayoutsClientApi` (§4) is active with fixtures covering every state. Then:
1. **Pending earnings.** As a nurse with a **just-completed** booking (EVV checked out, inside the 72h
dispute window), open the earnings screen → the amount shows under **pending / "in escrow · dispute
window open"** with a countdown derived from `dispute_window_ends_at`; the **net balance** includes it
as pending, not as paid. *Expected:* no "eligible"/"paid" label while the window is open.
2. **Becomes eligible, then paid.** After the dispute window passes (or with a fixture past it), the item
moves to **eligible / "awaiting the weekly batch"**. After a **(mock) batch processes** (b13 admin
action / fixture), it shows as **paid** with a **`transfer_reference`** and **`paid_at`** (Shamsi), and
it appears in **payout history**; the detail lists the exact booking(s) the payout covered.
3. **Clawback nets the total.** With a fixture where a booking was **refunded after payout**, the
earnings row shows **clawback_applied** with the net explanation (original clawback = net), and the
**net payable balance** reflects the netting — when the clawback exceeds earnings, the balance renders
as an explicit **negative / "owed back"** state (not a bare minus).
4. **Failed payout.** A fixture payout with status `failed` shows its `failure_reason` as a read-only
banner in history/detail; **no retry control is present** for the nurse.
5. **Money correctness.** Spot-check a row: `gross commission = nurse payout`; the displayed Toman
equals the IRR string ÷ 10; no BNPL provider commission appears anywhere on the nurse view; the
amount is identical for a card-funded vs BNPL-funded booking of the same gross.
6. **i18n / RTL / caching.** Switch `fa``en` → all labels translate, layout mirrors correctly. Switch
the state tabs and page the lists → React Query Devtools shows separate cache entries per
filter/page and **no refetch** of data already loaded.
7. **Gate:** `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 the `services/payouts` domain, the new
shared earnings/payout/balance components, and any new nurse route segment. If you discover/decide any
business rule the `product/` docs don't capture (e.g. an eligible-window estimate shown to the nurse),
record it in [`../../../product/business/10-payouts.md`](../../../product/business/10-payouts.md) — don't
invent rules; record decisions and flag uncertain ones in your report.
- **Contract:** *consume* [`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md) — derive
`types.ts` from it, do **not** guess shapes. Any missing/needed shape (e.g. the four-bucket summary, the
eligible-window estimate, the booking-links payload on batch detail) is **appended** to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
— you request it; the backend delivers it in a later change.
- **Handoff & report:** append your phase summary to
[`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
[`dev/shared-working-context/reports/frontend-phase-12-report.md`](../../shared-working-context/reports/frontend-phase-12-report.md)
— what was built, **what is now testable and exactly how** (the §7 steps), what is mocked behind the
`services/payouts` seam and how it swaps to the real b13 `clientApi`, contracts consumed, follow-ups
(the deferred admin console + bank-account UI). Update
[`dev/shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
if you mocked `PayoutsClientApi`.
- **Memory:** save a `project` memory note for any non-obvious decision this phase made (the four
earnings-state model + how each maps to a contract field, the negative-balance "owed back" treatment,
the read-only nurse-view boundary vs admin actions), with a one-line `MEMORY.md` pointer. Don't record
what the code/docs already make obvious.
@@ -0,0 +1,326 @@
# Frontend Phase 13 (b14) — Reviews & patient care records
> **Mission:** close the trust loop and the continuity-of-care loop in the client. After a visit is
> completed, the family leaves **one moderated review** that surfaces on the nurse's public profile only
> once it clears moderation; and the **family-owned, patient-scoped care record** becomes a real screen —
> the customer reads/edits it (داروها/روتین/سوابق/وظایف) under an ownership banner, while the assigned
> nurse may only **append a visit note** (the EVV check-in/out itself already shipped in f8). This is the
> brand-survival surface: vulnerable patients are cared for unobserved at home, so we never render
> unmoderated content publicly, we never let a nurse edit the family's record, and we treat clinical
> fields as sensitive.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) (booking
> detail · sessions · EVV) + the **b14** contract [`reviews-records.md`](../../contracts/domains/reviews-records.md) ·
> **Unlocks:** (last vertical-feature frontend phase — the support/notification surfaces in
> [`frontend-phase-14-b15.md`](./frontend-phase-14-b15.md) reuse these patterns)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **last feature-domain frontend phase** before the support/admin consoles. The booking
lifecycle is fully built: a customer can search, request, get accepted, pay (escrow / BNPL), and the
nurse runs the visit with EVV. The two things still missing on the client are the **post-visit review**
(what makes the marketplace's rating signal real) and the **patient care record viewer/authoring**
(what makes continuity-of-care real across nurse changes). Both are described in the wireframe's
**Section E** (E1/E2 patient record, E3 visit note) and **Section C** (the review snippet on the nurse
profile C3). You implement the family-facing review + record screens and the nurse-facing append-only
visit-note part of E3.
**What already exists (do not rebuild) — confirmed in the codebase + prior handoffs:**
- **The whole frontend foundation** from [`frontend-phase-0.md`](./frontend-phase-0.md): the three actor
shells (customer mobile + 5-tab bottom nav خانه/رزروها/بیماران/کیف‌پول/پروفایل, nurse, admin), the
`services/{domain}` + TanStack Query caching pattern (copy the `auth` service shape:
`types.ts`/`keys.ts`/`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts`), the contracts→types pattern, the
shared composite components (status chip, stepper, cards), the money/format util, and the i18n
namespace baseline (a `reviews` namespace was reserved in f0 — fill it; add a `records` namespace).
- **Booking detail, sessions & EVV** from [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md): the
booking-detail screen, the status timeline, the nurse EVV **check-in/check-out** banner and the
post-confirmation care-instructions surface, and the booking status enum (the **completed/closed**
state that gates a review). **Reuse the booking-detail screen and the booking `services` domain** — the
"Leave a review" entry point hangs off a completed booking; the visit-note authoring is the **note +
task-checklist** part of E3 that sits *below* the EVV banner f8 already built. Do **not** rebuild EVV.
- **The patients list & a patient's identity** from [`frontend-phase-2-b3.md`](./frontend-phase-2-b3.md):
E1 (patients list, Patients tab) and the patient header (name, age/gender, conditions) already exist —
the **record viewer E2 is a new screen reached from a patient**; reuse the patient header, don't
re-fetch the patient identity from scratch.
- **The nurse public profile (C3)** from [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md): it already
renders avatar/badges/services and a *single* review snippet. This phase adds the **reviews tab** to
that existing profile — extend it, don't fork it.
- The **contract** [`reviews-records.md`](../../contracts/domains/reviews-records.md) produced by
backend phase b14 — the source of truth for every shape below. If it is not yet published, mock behind
the seam (§4) and file the gap (§8).
## 2. Required reading (do this first)
- [`../_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 you are graded on.
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) in full — the RSC/client boundary, the
`services/{domain}` + Query rules, i18n, theme/tokens, cookies. Non-negotiable.
- **Invoke the `frontend-designer` skill before any visual work.** It is the design/brand contract
(palette: teal `#1d4a40`, terracotta `#d98c6a` for the nurse-view E3 accent, cream; tokens, typography,
the `App*` library, layout shells, the hard UI rules). Every screen in this phase goes through it — the
star input, the tag chips, the tabbed record viewer, the ownership banner, the visit-note composer.
- [`reviews-records.md`](../../contracts/domains/reviews-records.md) — **the b14 contract you consume.**
Read it end-to-end for exact request/response shapes, routes, status codes, the `review_status` enum
(`pending_moderation`/`published`/`hidden`/`rejected`), the care-record tab/section shape, and which
clinical fields are masked vs. full. Derive your `types.ts` from this, not from guesses.
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) +
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — envelope, `snake_case`
routes/JSON, pagination (`page`/`page_size`, `items`+`total`), enums-as-codes (mirror as string-literal
unions, **never** hardcode a label off a code), UTC + **Shamsi display is a client concern**, and the
**PII/sensitive-field** rule (clinical notes are encrypted-at-rest, returned only to authorized callers,
sometimes masked — the two-stage clinical-disclosure rule applies).
- [`../../../product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
— the business rules: one review per completed booking, `pending_moderation` default, recompute-on-every-
transition (a server concern, but it means a hidden review must *vanish* from the profile — your cache
must invalidate), low-rating → support alert (server-side; you just render the "under review" state).
- [`../../../product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
`reviews` (1:1 booking, rating 15 CHECK, body, moderation status), `review_tags_master`/
`review_tag_links` (the tag vocabulary), `patient_care_records` (nurse-authored, **patient-scoped not
booking-scoped**, encrypted, strict access: owning customer + nurse with a confirmed booking for that
patient + admin).
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — **Section E** (E1
patients list, **E2 patient record** with the four tabs + ownership banner "این پرونده متعلق به خانواده
است …", **E3 visit note** in the terracotta "نمای پرستار" frame: EVV banner [already built] + today's
task checklist [give متفورمین, measure blood pressure, short walk] + free-text visit-note field) and
**Section C** (C3 nurse profile with the latest-review snippet you turn into a tab).
- The existing `client/src/services/auth/*` — the exact `services/{domain}` shape to copy, and the
booking + patients services from f8 / f2 you will reuse.
## 3. Scope — build this
Two new `services/{domain}` domains (`reviews`, `patientRecords`), their hooks, and four wireframe
screens (+ one tab added to an existing screen). Every screen is RTL-first, `fa` default, both locales in
sync, colours from tokens, MUI v9 primitives reused, query-cached with deliberate keys and
invalidate-on-mutation. **Invoke the `frontend-designer` skill for each screen.**
### 3.1 `services/reviews` domain
Copy the `auth` service shape. Consume the b14 contract.
- `types.ts``Review` (`id`, `booking_id`, `nurse_id`, `customer_display_name`, `rating` 15,
`body`, `status: 'pending_moderation' | 'published' | 'hidden' | 'rejected'`, `tag_codes: string[]`,
`created_at`), `NurseReviewsResponse` (`items`, `total`, `aggregate_rating`, `review_count`),
`ReviewTag` (`code`, plus the i18n key is the client's, **not** a label off the wire),
`CreateReviewRequest` (`booking_id`, `rating`, `body`, `tag_codes`), `ReviewEligibility`
(`can_review: boolean`, `reason?`). Mirror the **exact** wire shape/casing from `swagger.json`.
- `keys.ts` — a query-key factory: `reviews.nurse(nurseId, page)`, `reviews.eligibility(bookingId)`,
`reviews.myReviewForBooking(bookingId)`.
- `apis/clientApi.ts` — wrap `clientFetch`: `getNurseReviews(nurseId, page)`
(`GET .../get_nurse_reviews`, **published only** — the server already filters; never request or render
other statuses publicly), `getReviewEligibility(bookingId)`, `createReview(body)`
(`POST .../create_review`). A `serverApi.ts` only if the nurse-profile reviews tab is prefetched in the
RSC (prefer it — removes a client round-trip on C3).
- `hooks/` (one per file): `useNurseReviews` (`useInfiniteQuery` or paged `useQuery` with `select` for
the aggregate slice), `useReviewEligibility`, `useCreateReview` (`useMutation` → on success
`invalidateQueries` for `reviews.eligibility(bookingId)` **and** `reviews.myReviewForBooking(bookingId)`
so the booking-detail CTA flips to the "under review" state immediately; do **not** optimistically push
the review into the public nurse list — it is `pending_moderation` and must not appear publicly).
- `index.ts` barrel.
### 3.2 `services/patientRecords` domain
Same shape. Consume the b14 contract. **Patient-scoped**, not booking-scoped.
- `types.ts``CareRecordTab = 'medications' | 'routine' | 'history' | 'tasks'`; `Medication`
(`name`, `frequency`, `timing_note`), `RoutineItem`, `HistoryEntry`, `CareTask` (`label`, `done`),
`VisitNote` (`id`, `booking_id`, `nurse_display_name`, `body`, `task_results`, `created_at` — **read-
only/append-only** from the client's perspective), `PatientCareRecord` (the family-owned editable
record: medications/routine/tasks the customer maintains), `RecordAccess`
(`can_view`, `can_edit`, `can_append_note`, `denied_reason?`), `CreateVisitNoteRequest`
(`booking_id`, `body`, `task_results`). Clinical fields are **sensitive** — treat masked/full per the
contract; never log them.
- `keys.ts``records.patient(patientId)`, `records.history(patientId, page)`,
`records.access(patientId)`.
- `apis/clientApi.ts``getPatientCareRecord(patientId)` (`GET .../get_patient_care_record`, the
four-tab payload), `getPatientHistory(patientId, page)` (longitudinal visit-note history, paged),
`updateCareRecord(patientId, body)` (customer edits — medications/routine/tasks),
`createVisitNote(patientId, body)` (**nurse append** — `POST .../create_visit_note`). The access check
rides on the read responses (403 from the envelope → render access-denied, don't crash).
- `hooks/``usePatientCareRecord`, `usePatientHistory` (paged/infinite), `useUpdateCareRecord`
(customer mutation → invalidate `records.patient`), `useCreateVisitNote` (nurse mutation → invalidate
`records.history` and `records.patient`; the nurse **cannot** call `updateCareRecord` — don't even wire
that hook into the nurse view).
- `index.ts` barrel.
### 3.3 Screens & flows
**(a) Leave-a-review flow** (customer; entry from completed booking detail / completed-bookings list)
- A `<LeaveReviewSheet>` (or page) reached only when `useReviewEligibility(bookingId).can_review` is true
**and** the booking status is completed/closed. Contains: a **15 star input** (a new shared
`<RatingInput>` composite — see §3.4), a multiline **body** field, and **tag chips** (multi-select from
the contract's tag vocabulary; chip labels are i18n keys keyed off `tag_codes`, never off the wire).
Primary action "ثبت نظر".
- States: **not-eligible** → CTA hidden/disabled with a clear reason ("نظر فقط برای ویزیت‌های تکمیل‌شده
امکان‌پذیر است"); **eligible** → the form; **submitting**; **submitted** → an **"در حال بررسی" / "under
review"** banner (the review is `pending_moderation`, not yet public) and the CTA becomes a passive
"نظر شما ثبت شد و در حال بررسی است"; **already-reviewed** (1:1) → show the existing pending/published
state, never a second form; **error** → domain 4xx message, preserve the draft.
**(b) Nurse public-profile reviews tab** (customer; on the existing C3 nurse profile)
- Add a **reviews tab** to the existing nurse-profile screen. Render **only `published`** reviews via
`useNurseReviews`, with the **aggregate rating + review count** header, paginated/infinite list, each
row: stars, body, tag chips, masked customer display name, Shamsi date. States: **loading** (skeleton),
**empty** ("هنوز نظری ثبت نشده"), **error**. Never render `pending_moderation`/`hidden`/`rejected` — if a
review is hidden server-side, the next fetch simply omits it (the aggregate recompute is the server's
job; the client just trusts the published list and its `aggregate_rating`).
**(c) Patient record viewer E2** (customer; reached from a patient in the Patients tab / E1)
- Header (reuse the f2 patient header: name, age/gender, condition chips, an **ویرایش/edit** affordance)
+ a **tabbed** body: **داروها (Medications)** [default], **روتین (Routine)**, **سوابق (History)**,
**وظایف (Tasks)**. Medication cards (drug, frequency, timing/notes). The **سوابق** tab shows the
longitudinal visit-note history (§(e)). A persistent **ownership banner**: "این پرونده متعلق به خانواده
است" (the record belongs to the family). Customer can edit medications/routine/tasks
(`useUpdateCareRecord`); the **سوابق** (nurse visit notes) are read-only to everyone.
- States: **loading** (skeleton per tab), **empty** per tab ("دارویی ثبت نشده" / "یادداشتی ثبت نشده"),
**access-denied** (403 → a clear, non-leaking "شما به این پرونده دسترسی ندارید" card — never show
partial clinical data), **error**.
**(d) Nurse visit-NOTE authoring E3 — the note + task-checklist part only** (nurse; terracotta
"نمای پرستار" frame, on the booking-visit screen f8 built)
- **Below the EVV check-in/out banner f8 already renders**, add: **today's task checklist** (from the
patient's care tasks — render each `CareTask` as a checkbox the nurse ticks: give متفورمین, measure
blood pressure, short walk) and a **free-text visit-note field**. Primary action "ثبت یادداشت"
(`useCreateVisitNote`). The nurse view is **append-only**: it must **never** expose the customer's
edit affordances (no medication/routine/tasks editing, no `updateCareRecord` hook wired) — the nurse
can read prior history for continuity and append a note, nothing more.
- States: **append form** (default), **submitting**, **saved** (note appended → it appears in the
longitudinal history), **error** (preserve draft). If the nurse lacks a confirmed booking for that
patient (`access.can_append_note === false`), hide the composer.
**(e) Longitudinal patient history** (customer in the سوابق tab + nurse for continuity)
- A patient-scoped, paginated visit-note timeline (`usePatientHistory`): each entry = nurse display name,
Shamsi date, note body, completed-task summary — ordered newest-first. It **persists across nurse
changes** (patient-scoped, so a new nurse reads it before/at the visit). Read-only. States:
loading/empty/error.
> **(DEFERRED)** — do **not** build in this phase: review *moderation* UI (the admin approve/hide/reject
> queue → [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md)); two-way (nurse-reviews-customer)
> reviews; structured tag *aggregation* dashboards ("% punctual") — render the tag chips, but the
> aggregate analytics are deferred per the product doc; the in-app "raise a concern" flag and emergency
> banner → [`frontend-phase-14-b15.md`](./frontend-phase-14-b15.md). Tag those entry points with a pointer
> if a placeholder is unavoidable; otherwise leave them out.
## 4. Mocks & seams in this phase
- **Reuse the `services/{domain}` seam pattern** from [`frontend-phase-0.md`](./frontend-phase-0.md): all
data goes through `clientFetch`/`serverFetch` in `services/reviews` and `services/patientRecords`. No
raw `fetch()`.
- If the **b14 contract** [`reviews-records.md`](../../contracts/domains/reviews-records.md) (or the
`swagger.json` snapshot) is **not yet published** when you run, build a **mock `clientApi`** behind the
same domain seam returning real-shaped fixtures (a completed-booking eligibility, a small published-
review list with an aggregate, a four-tab care record, an append-able history) — selected by config,
never an `if (mock)` in a component — and:
1. append the missing/uncertain shapes to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(per operating-rules §6), and
2. record the mock in your phase report + the
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so it swaps out cleanly
once the real endpoint lands.
- No third-party client seam is introduced here (AI moderation `IReviewModerationService` and field
encryption `IFieldEncryptor` are **server-side** b14 concerns — the client never sees plaintext-vs-
ciphertext, only the authorized/masked payload).
## 5. Critical rules you must not get wrong
- **Review eligibility is gated on a completed/closed booking.** The "Leave a review" CTA is enabled
**only** when the booking status is completed/closed (from f8's booking enum) **and** the server says
`can_review`. Never offer a review for a cancelled/expired/other-customer booking, and enforce **one
review per booking** (1:1) — if a review already exists, show its state, never a second form.
- **Never render `pending_moderation` (or `hidden`/`rejected`) content publicly.** The nurse-profile
reviews tab requests and renders **published only**. After a user submits, show an **"under review"**
state locally — do **not** optimistically inject the new review into any public list or aggregate.
Trust the server's published list + `aggregate_rating`; when a review is hidden server-side, invalidate
and re-fetch rather than mutating the count yourself.
- **The patient care record is FAMILY-OWNED and PATIENT-scoped.** The customer owns and edits it
(medications/routine/tasks); the record persists **across nurse changes** because it is keyed to the
**patient, not the booking**. Render the ownership banner "این پرونده متعلق به خانواده است" on E2.
- **The nurse can ONLY append a visit note — never edit the record.** The nurse view exposes the task
checklist + a note composer and the read-only history; it must **not** wire `updateCareRecord` or any
medication/routine/task editing. Append-only is a hard boundary, not just a hidden button.
- **Strict access; surface access-denied clearly.** Only the owning customer, a nurse with a **confirmed**
booking for that patient, and admin may view a record. A `403` from the envelope → render a clear,
non-leaking access-denied card (no partial clinical data), never a crash or a blank tab.
- **Clinical fields are sensitive.** Treat masked vs. full strictly per the contract (two-stage clinical
disclosure spirit); never log clinical text, never persist it to `localStorage`, never put it in a
query string.
- **RSC/client boundary, caching, re-renders, i18n, RTL, tokens.** No layout above `[locale]`; no
`next/headers`/`next-intl/server` in client components. Set `queryKey`/`staleTime` deliberately and
**invalidate on every mutation** (review create → eligibility/my-review; note append → history+record;
record edit → record) so nothing over-fetches. Use `select` for the aggregate/tab slices to avoid
needless re-renders. Every string is a key in **both** `en.json` and `fa.json`; `fa` default & RTL;
colours from `tokens.css` (terracotta accent for the nurse E3 frame via tokens, never hardcoded);
MUI v9 primitives reused; Shamsi date display is the client's job.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/reviews` and `services/patientRecords` exist with the f0 shape (`types`/`keys`/`apis`/
`hooks`/`index`); types are derived from the published b14 contract (or mocked behind the seam with a
`for-backend.md` entry), never guessed.
- [ ] The leave-a-review flow enforces completed-booking eligibility + 1:1, shows the **under-review**
state on submit, and never injects unmoderated content into a public list.
- [ ] The nurse-profile **reviews tab** renders published-only with aggregate rating + count, paginated,
with loading/empty/error states.
- [ ] The **E2 patient record viewer** renders the four tabs (داروها/روتین/سوابق/وظایف), the ownership
banner, customer edit of medications/routine/tasks, and a clear **access-denied** state on 403.
- [ ] The **nurse E3 visit-note** authoring (task checklist + note composer) is **append-only**, sits
below the f8 EVV banner, exposes no record-editing affordances, and the appended note appears in the
longitudinal history.
- [ ] The **longitudinal history** is patient-scoped, paginated, read-only, newest-first, and persists
across nurse changes.
- [ ] New shared composites (`<RatingInput>`, the tag-chip selector, the tabbed record viewer if reused)
live at the right shared level with co-located `*.test.tsx`; `npm run check` and (if a shared
component changed) `npm run test:ci` are green; `en.json`/`fa.json` in sync (`reviews` + `records`
namespaces).
- [ ] `client/CLAUDE.md` *Project Structure* updated for the two new service domains + any new shared
component/route; the `frontend-designer` skill was invoked for the visual work.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and have the b14 backend reachable, or the seam mock active).
1. **Leave a review on a completed booking → pending → appears after moderation.** As a customer on a
**completed** booking, open "ثبت نظر", give 4 stars + body + a tag chip, submit → the screen shows the
**"در حال بررسی / under review"** state and the CTA does not offer a second review. The review does
**not** appear on the nurse's profile yet. After the admin publishes it (b14/f15 path, or flip the
mock to `published`), it appears on the **nurse profile reviews tab** and the aggregate rating/count
updates on next fetch. Confirm a **cancelled** booking shows no review CTA.
2. **View a patient record with tabs + ownership banner.** From the Patients tab, open a patient → E2
shows the four tabs, medication cards under داروها, the **"این پرونده متعلق به خانواده است"** banner,
and the customer can edit a medication/routine/task and see it persist (cache invalidates, no full
reload). Visiting a patient you don't own returns the **access-denied** card, not a crash.
3. **A nurse appends a visit note (cannot edit the record).** As the assigned nurse on today's visit
(E3, terracotta frame), below the EVV banner: tick the task checklist, write a note, "ثبت یادداشت" →
the note saves and shows in the history. Confirm there is **no** medication/routine/task edit control
anywhere in the nurse view.
4. **History persists across nurse changes.** The سوابق tab (customer) and the nurse's continuity view
show the full patient-scoped, newest-first visit-note timeline — including notes from a *different*
nurse — paginated.
5. **Gate checks:** `npm run check` green; `npm run test:ci` green for the new shared components; toggling
locale flips `dir`/strings; the reviews tab/list and the record edit show query caching + invalidation
in React Query Devtools (no needless refetch).
## 8. Hand off & document (close the phase)
- **Docs:** update the *Project Structure* tree in [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md)
for `services/reviews`, `services/patientRecords`, and any new shared component/route; note the
`reviews`/`records` i18n namespaces. If you discovered a business-rule detail the product docs don't
capture (e.g. an exact masking behaviour), record it in
[`../../../product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
or [`../../../product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
— don't invent rules.
- **Contract:** **consume** [`reviews-records.md`](../../contracts/domains/reviews-records.md) (b14) as
the source of truth for every shape. The frontend does **not** write contracts — if a shape is missing,
wrong, or unmasked when it should be masked, 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/{domain}` 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-13-report.md`](../../shared-working-context/reports/frontend-phase-13-report.md)
(what was built, **what is testable and exactly how** per §7, what is mocked client-side + how it swaps,
contracts consumed, follow-ups — e.g. the deferred moderation UI for f15); update
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
for any client-side mock you used.
- **Memory:** save a `project`-type memory note for the non-obvious decisions this phase locks in (review
is published-only on the client and never optimistically injected; the patient record is family-owned
and patient-scoped with the nurse strictly append-only; access-denied is a first-class state), with a
one-line pointer in `MEMORY.md`.
@@ -0,0 +1,330 @@
# 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).
@@ -0,0 +1,661 @@
# Frontend Phase 15 — Admin backoffice & partner-center consoles
> **Mission:** ship the **operational cockpit** that runs Balinyaar — the internal, role-gated admin
> backoffice in the **desktop sidebar shell** from f0, plus the **partner-center portal** (a separate
> authz scope for the licensed sponsoring centers). The backoffice consolidates the worklists ops needs:
> the **verification review queue** (pass/reject nurse steps with a signed-URL document viewer + structured
> credential entry), **refund admin** (ticket-linked, fee/payout-decomposed, channel-aware, BNPL ETA),
> the **payout dashboard** (batch preview → processing → completed/partially-failed, retry), **review
> moderation** (publish/hide/reject), the **config editor** (typed inputs by `data_type`, audited save +
> change-history), the **holiday calendar manager**, the **audit-log viewer** (filtered, paginated), and
> the **support-alert worklist** (assign/resolve). The partner portal shows a center its onboarding/
> verification state, its sponsored nurses, its sponsored bookings, and — when it is merchant-of-record —
> its settlement/invoice view. There is **no wireframe** for any of these screens — you design them with
> the **frontend-designer** skill. Internal-only data (`support_alerts`, internal ticket notes) must
> **never** leak to a non-admin. When this lands, **MVP is complete.**
>
> **Track:** frontend · **Depends on:** [`frontend-phase-14-b15`](./frontend-phase-14-b15.md) (the
> `services/tickets` + `services/notifications` domains this phase layers the admin lens over) + the
> **b15** contract ([`messaging-notifications-admin`](../../contracts/domains/messaging-notifications-admin.md))
> **and** the admin endpoints across **b1** (config/holidays/audit/support-alerts), **b6** (verification
> queue), **b11** (refunds/invoices), **b13** (payout batches), **b14** (review moderation) ·
> **Unlocks:** **MVP complete** — this is the final frontend phase.
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **last frontend phase** and the only one whose primary audience is **internal staff** (and
the licensed partner centers), not families or nurses. Every customer- and nurse-facing surface already
exists; what is missing is the back office that makes the marketplace *run*: an admin must be able to
**verify a nurse**, **process a refund**, **preview and run a payout batch**, **moderate a review**,
**edit a config value**, and **resolve a support alert** — and a partner-center admin must be able to see
the nurses and bookings their license covers. All of this rides the **desktop sidebar admin shell**
established in [`frontend-phase-0`](./frontend-phase-0.md) (the third actor shell) and the existing
`services/{domain}` + TanStack Query patterns. No new app-shell architecture; this phase fills the admin
shell with real worklists and adds a **separately-scoped** partner portal.
The backoffice is a **read-and-act** surface over data other domains own. It does **not** re-implement
verification logic, refund math, payout scheduling, moderation recompute, or config typing — those are
**server authority** (b1/b6/b11/b13/b14). The client renders the contract's values and issues the
sanctioned admin commands; it **never** computes eligibility, money decomposition, holiday shifts, or the
`is_verified` flip on the client (§5).
**What already exists (do not rebuild) — link the prior phases:**
- **f0 foundations** ([`frontend-phase-0`](./frontend-phase-0.md)): the three actor app shells + route
groups, the **admin/backoffice desktop sidebar shell** (the shell this phase lives in), role-aware nav
from `AuthContext`, the `services/{domain}` + TanStack Query caching pattern (`keys.ts` factory,
`apis/clientApi.ts`, one-hook-per-file, hooks-only `index.ts`), the contracts→`types.ts` step, the
**money/format util** in `src/utils/` (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date
display), the shared composites (**status chip**, **stepper/progress header**, **price-breakdown**,
cards), and the i18n namespace conventions (the `admin` namespace was reserved in f0 — fill it).
**Reuse the money util, the status chip, and the price-breakdown — do not re-implement them.**
- **f1-b2 auth** ([`frontend-phase-1-b2`](./frontend-phase-1-b2.md)): phone-OTP login, the role router,
**roles in `AuthContext`**. Admins arrive authenticated with an admin role (`super_admin` / `admin` /
`support` / `finance` / `moderator`); partner-center admins arrive with the **partner-center scope**.
This phase **role-gates** every admin route and command off these roles — there is no separate admin
login to build.
- **f5-b6 nurse verification flow** ([`frontend-phase-5-b6`](./frontend-phase-5-b6.md)): the nurse-facing
verification checklist, the per-step status enums, the `services/verification` types, the document
uploader, and the trust badge. **The admin verification queue is the staff lens over the same
`services/verification` domain** — extend it with the admin-review endpoints; reuse the step/status
enums and the badge, do not fork them.
- **f10-b11 refund & cancellation** ([`frontend-phase-10-b11`](./frontend-phase-10-b11.md)): the
customer-side cancellation/refund-status UI, the policy-fee disclosure, the BNPL ETA display, and the
refund money-display conventions. **The admin refund tool is the staff lens** — reuse the same
fee/payout decomposition rendering and the money util; the admin **initiates/approves** refunds the
customer can only watch.
- **f12-b13 nurse earnings & payouts** ([`frontend-phase-12-b13`](./frontend-phase-12-b13.md)): the
read-only nurse earnings/payout-history view, the four earnings states, the payout-status enum
(`pending`/`processing`/`paid`/`failed`), the batch-detail shape with booking links. **The admin payout
dashboard is the action surface** explicitly deferred from f12 — build the **batch preview → run →
retry** flow here; reuse the payout shapes and money rendering.
- **f13-b14 reviews & patient records** ([`frontend-phase-13-b14`](./frontend-phase-13-b14.md)): the
`services/reviews` domain, the review shapes, the star/tag rendering. **The admin moderation queue is
the staff lens** — add the `publish`/`hide`/`reject` moderation actions over the same domain.
- **f14-b15 messaging & notifications** ([`frontend-phase-14-b15`](./frontend-phase-14-b15.md)): the
`services/tickets` and `services/notifications` domains, the ticket thread, the `reference_code`
rendering. **This phase layers the admin ticket lens** (the global queue, the **internal-note
composer**, the refund-from-ticket entry) on top of `services/tickets`, and reuses the notification
bell in the admin shell. The user-app types deliberately omit `isInternal`; the **admin** types include
it (admins *see* internal notes) — keep the user/admin type surfaces distinct (§5).
- **f9-b10 / f11-b12** money UI conventions (checkout, BNPL) — the Toman-display + IRR-string handling the
admin money screens must match. **Reuse the single money util; do not fork a formatter.**
- `clientFetch`/`serverFetch` + `ApiError`, the toast bridge (already toasts 401/403/5xx — do **not**
re-toast those in hooks), the cookie manager, `APP_THEME_LTR/RTL`, `tokens.css`.
> **Backend readiness note.** The primary contract you consume,
> [`messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md), is
> produced by **backend-phase-b15** (admin ticket queue, support-alert worklist, partner centers,
> RBAC role grants) and is the consolidation point for the admin endpoints. The other admin endpoints
> live in their own domain contracts — verification (**b6**, [`verification.md`](../../contracts/domains/verification.md)),
> refunds/invoices (**b11**, [`refunds.md`](../../contracts/domains/refunds.md)), payouts (**b13**,
> [`payouts.md`](../../contracts/domains/payouts.md)), reviews (**b14**, [`reviews.md`](../../contracts/domains/reviews.md)),
> config/holidays/audit (**b1**, [`config-reference.md`](../../contracts/domains/config-reference.md)).
> If a shape you need is absent or wrong when you start, **do not guess and do not block** — append a
> `REQ-NNN` request to
> [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
> and mock behind the `services/admin` / `services/partnerCenter` seam meanwhile (operating-rules §6).
> Record every mock in your report so it swaps cleanly once the endpoint lands.
## 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, the gate, the contract/handoff lanes, the mock-then-swap rule (§6).
- [`../_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/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md) —
**the admin operational spine**: the five worklists (verification / refund / payout / support-alert /
RBAC), the append-only audit trail, config-change auditing, in-app notifications, and that
back-office must reason over the Shamsi calendar + `iranian_holidays`. This is *why* each console exists.
- [`../../../product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md) —
the **partner-center / merchant-of-record** model: the licensed center (پروانه تأسیس + مسئول فنی +
نماد اعتماد الکترونیکی) sponsors nurses and **may be the merchant-of-record / invoice issuer**; the
commission invoice (gross / platform commission / BNPL commission / VAT on commission) and the
config-driven VAT rate. This drives the partner portal's settlement/invoice view and what it may show.
- [`../../../product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md) —
`audit_logs` (immutable, append-only, `changed_fields_json`), `platform_configs` (typed by `data_type`;
the seeded keys — `platform_fee_rate`, `vat_rate`, `dispute_window_hours`, `nurse_payout_interval_days`,
`evv_location_tolerance_meters`, `min_rating_for_support_alert`, cancellation-tier defaults, BNPL keys),
and `iranian_holidays` (`holiday_date`, `name_fa`, `type`, `is_bank_closed`). These shape the config
editor, the holiday manager, and the audit viewer.
- [`../../../product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md) —
`partner_centers` fields (`name`, `legal_entity_type`, `moh_establishment_permit_no`,
`technical_director_nurse_user_id` + `technical_director_license_no`, `enamad_code`, `settlement_iban`
(enc), `is_merchant_of_record`, `commission_rate`, `admin_user_id`, `is_active`, `verified_at`); the
1:N relations to `nurse_profiles` / `bookings` / `invoices`; and the **deferred** tables
(`organizations`, `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`) that have **no
UI** this phase.
> **No wireframe exists for the admin or partner screens** (the wireframe is the mobile customer/nurse
> flow A1E3 — see [`product/wireframes/index.html`](../../../product/wireframes/index.html) for the
> brand/RTL baseline only). The GTM notes explicitly flag the backoffice/ticket/partner surfaces as a
> design gap. **You design these screens from scratch with the `frontend-designer` skill** against the
> brand system — desktop, sidebar-driven, dense worklist layout, RTL-first.
**Contracts & types (the source of truth for shapes — do not guess)**
- [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md) —
**the primary contract** (b15): admin ticket queue + internal-note composer, the **support-alert
worklist** (list/filter, assign, resolve), **partner-center** CRUD/verify/sponsor + roster, and **RBAC**
role grant/revoke. The user-vs-admin `is_internal` filtering note is here.
- The per-domain admin contracts: [`verification.md`](../../contracts/domains/verification.md) (b6 — queue,
record-step, upsert-credential, approve/reject, signed-URL document fetch),
[`refunds.md`](../../contracts/domains/refunds.md) (b11 — initiate/approve/reject, decomposition,
channel, BNPL ETA, invoices), [`payouts.md`](../../contracts/domains/payouts.md) (b13 — batch
preview/list/detail, initiate, retry, transfer-reference reconcile),
[`reviews.md`](../../contracts/domains/reviews.md) (b14 — moderation queue, set-status),
[`config-reference.md`](../../contracts/domains/config-reference.md) (b1 — list/update config, config
change history, holidays CRUD, audit-log list, support-alert raise/list).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — the envelope (`OperationResult`
→ already unwrapped by `clientFetch`), `snake_case` routes/properties, pagination (`page`/`page_size`,
default/max), **enums as stable string codes** (mirror as string-literal unions; labels are i18n keys),
**IRR as integer string on the wire** (parse integer-safe, Toman display-only), UTC timestamps →
**Shamsi** on the client.
**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 prior domains this phase extends: `services/verification` (f5), `services/refunds` (f10),
`services/payouts` (f12), `services/reviews` (f13), `services/tickets` + `services/notifications` (f14).
**Extend these with the admin endpoints; do not re-create them.** Only the genuinely new admin-owned
data (config, holidays, audit, support-alerts, RBAC) and partner centers get **new** domains
(`services/admin`, `services/partnerCenter` — §3).
- The f5 document uploader / signed-URL handling (the verification document viewer reuses the signed-URL
pattern), the f0 money util + status chip + price-breakdown, the f10 refund decomposition rendering, the
f12 payout shapes, the f14 ticket thread.
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, the admin route group, layouts,
i18n, theme, fetch services, anti-patterns.
**Design**
- **Invoke the `frontend-designer` skill** before building any screen. Every admin worklist, the document
viewer, the refund/payout action panels, the config editor, the holiday manager, the audit table, the
support-alert board, and the entire partner portal go through it — brand palette, tokens, typography,
the `App*` library, the desktop sidebar density, table/virtualization treatment, empty/loading/error
states, RTL mirroring. **Do not hand-roll colours, spacing, or table styling.** Because there is no
wireframe, the designer skill owns the visual language for the whole back office.
## 3. Scope — build this
Two **new** domain services (`services/admin`, `services/partnerCenter`) plus **admin endpoint additions**
to the existing `verification` / `refunds` / `payouts` / `reviews` / `tickets` domains, and the
**admin-shell screens** + the **partner-center portal** on top of them. Everything is **internal-only**,
behind **role-gated routes** in the f0 admin shell (the partner portal is its own scope). Build desktop-
first, RTL, both locales, query-cached, virtualized lists, minimal re-renders.
> **Routing & RBAC.** All admin screens mount under the f0 **admin route group** (e.g.
> `(private-routes)/admin/…`) gated by an admin role from `AuthContext`; the partner portal mounts under a
> **distinct** partner-scope segment (e.g. `(private-routes)/partner/…`) gated by the partner-center
> scope. A `support` admin must not see the payout-run control, a `moderator` must not see the refund
> tool, etc. — the **server enforces** role scopes on every command (don't rely on UI hiding for
> security), but the UI **also** hides/disables actions the current role can't perform so a user never
> sees a control that will 403. Drive this from a small `useAdminCapabilities()` selector off `AuthContext`
> roles. Update the **Project Structure** tree in `client/CLAUDE.md` for the new route segments + services.
### 3.1 `services/admin` (new domain — config, holidays, audit, support-alerts, RBAC)
Copy the `auth` service shape into `client/src/services/admin/`. Types come from the b1 + b15 contracts —
do not invent. (Sub-namespace by area if it keeps files small: `config`, `holidays`, `audit`,
`supportAlerts`, `rbac`.)
- **`types.ts`** — string-literal unions + DTOs from the contracts:
- **Config:** `PlatformConfig` (`key`, `value` (string on the wire), `data_type` enum
`string|int|decimal|bool|json`, `description`, `updated_at`, `updated_by`), and a
`ConfigChange` (audit) row (`changed_at`, `actor`, `old_value`, `new_value`).
- **Holidays:** `Holiday` (`id`, `holiday_date`, `name_fa`, `type` enum `official|religious|national`,
`is_bank_closed`).
- **Audit:** `AuditLogEntry` (`id`, `entity_type`, `entity_id`, `action`, `changed_fields_json`
(parsed to a typed record), `actor`, `created_at`) + paged list envelope; filter params
(`entity_type`, `entity_id`, `actor_id`, `from`/`to`, `action`).
- **Support alerts:** `SupportAlert` (`id`, `type` enum e.g.
`low_rating|no_show|evv_mismatch|verification_expiry|fraud_signal`, `status` enum
`open|assigned|resolved`, `entity_type`, `entity_id`, nullable `booking_id`/`review_id`,
`assigned_to`, `resolved_at`, `resolution_note`, `created_at`).
- **RBAC:** `AdminRole` enum (`super_admin|admin|support|finance|moderator`), `RoleGrant`
(`user_id`, `role`, `granted_by`, `granted_at`, `revoked_at`).
- **`keys.ts`** — `adminKeys.config()`, `adminKeys.configHistory(key)`, `adminKeys.holidays(yearOrRange)`,
`adminKeys.audit(filters, page)`, `adminKeys.supportAlerts(filters, page)`, `adminKeys.roles(userId?)`.
Filters + page are part of the key so each filter caches separately.
- **`apis/clientApi.ts`** wrapping `clientFetch` (exact `snake_case` routes from the contracts):
config `list_platform_configs` / `update_platform_config` / `get_config_change_history`; holidays
`list_holidays` / `create_holiday` / `update_holiday`; audit `list_audit_logs`; support-alerts
`list_support_alerts` / `assign_support_alert` / `resolve_support_alert`; RBAC `list_roles` /
`grant_role` / `revoke_role`.
- **`hooks/` (one per file):** `usePlatformConfigs`, `useUpdatePlatformConfig`, `useConfigChangeHistory`,
`useHolidays`, `useUpsertHoliday`, `useAuditLogs`, `useSupportAlerts`, `useAssignSupportAlert`,
`useResolveSupportAlert`, `useAdminRoles`, `useGrantRole`, `useRevokeRole`. Mutations **invalidate**
the relevant `adminKeys` (and the config-history key after a config save) on settle so cached data
isn't refetched needlessly.
- **`index.ts`** barrel (hooks only).
### 3.2 `services/partnerCenter` (new domain — partner portal + admin-side center management)
Copy the same skeleton into `client/src/services/partnerCenter/`. Types from the b15 contract.
- **`types.ts`** — `PartnerCenter` (`id`, `name`, `legal_entity_type`, `moh_establishment_permit_no`
(پروانه تأسیس), `technical_director_nurse_user_id`, `technical_director_license_no`, `enamad_code`,
**`settlement_iban` masked last-4 only** (never the full IBAN), `is_merchant_of_record`,
`commission_rate`, `admin_user_id`, `is_active`, `verified_at`); `SponsoredNurse` (nurse summary +
verification badge); `SponsoredBooking` (booking summary the center covers); `CenterSettlementRow` /
`CenterInvoice` (only meaningful when `is_merchant_of_record` — gross / platform commission / BNPL
commission / VAT / total, `moadian_reference_number`, `pdf` signed-URL link). A **center
verification/onboarding state** enum (`draft|pending_verification|verified|suspended`).
- **`keys.ts`** — `centerKeys.list(filters)`, `centerKeys.detail(id)`, `centerKeys.sponsoredNurses(id)`,
`centerKeys.sponsoredBookings(id, filters)`, `centerKeys.settlement(id, filters)`,
`centerKeys.myCenter()` (the partner-scope "my center" view).
- **`apis/clientApi.ts`** — admin-side: `list_partner_centers`, `get_partner_center`,
`create_partner_center`, `update_partner_center`, `verify_partner_center`, `set_partner_center_active`,
`assign_nurse_to_partner_center`; partner-scope: `get_my_partner_center`,
`list_my_sponsored_nurses`, `list_my_sponsored_bookings`, `list_my_settlement`.
- **`hooks/`:** admin — `usePartnerCenters`, `usePartnerCenter`, `useCreatePartnerCenter`,
`useUpdatePartnerCenter`, `useVerifyPartnerCenter`, `useSetPartnerCenterActive`,
`useAssignNurseToPartnerCenter`; partner-scope — `useMyPartnerCenter`, `useMySponsoredNurses`,
`useMySponsoredBookings`, `useMySettlement`. Mutations invalidate `centerKeys`.
- **`index.ts`** barrel (hooks only).
### 3.3 Admin: verification review queue
The staff lens over `services/verification` (f5) — add the admin endpoints to that domain (queue list,
record-step, upsert-credential, approve/reject, signed-URL document fetch); reuse the f5 step/status enums
and the trust badge.
- **Queue list** (`/admin/verification`) — a paginated, **status-filtered** worklist of nurses with
pending verification (filter by aggregate status `pending|in_review`; sort by oldest-first). Each row:
nurse name/photo, the **step progress** (e.g. "۳ از ۵"), the next pending step, submitted-at (Shamsi),
and any expiring credential warning. Virtualize/paginate; empty state "صف خالی است / Queue clear".
- **Per-nurse review screen** (`/admin/verification/[nurseId]`) — the ordered steps with status chips, a
**document viewer** that fetches each `verification_documents` item via a **signed URL** (never a public
URL — handle **loading / expired-link → re-request / load-error** states; the URL is short-lived, so
fetch on demand, don't cache the URL in a long-lived query). For each step an admin can **pass** or
**reject (with a required reason)**`record_step`; for credential steps a **structured credential
entry** form (`credential_number`, issuing authority, issue/expiry dates) → `upsert_credential`. The
screen's **Approve / Reject** action (`approve_verification` / `reject_verification`) is enabled **only
when all required steps are `passed`** (the **server** flips `is_verified` transactionally — the client
only enables the button and shows a confirmation; it **never** writes `is_verified` itself, §5).
Approve/reject require a confirmation dialog and, on reject, a reason. On success, **invalidate** the
queue + the nurse detail so the row leaves the queue.
### 3.4 Admin: refund tooling (inside the ticket lens)
The staff lens over `services/refunds` (f10) + `services/tickets` (f14). Refunds are **admin-only and
ticket-linked** — the entry point is **from a ticket**, not a standalone form.
- **Refund panel in the admin ticket view** — opened from a ticket (the admin global ticket queue, §3.9):
shows the linked booking, computes a **preview** of the tiered `refund_percentage_applied` from the
cancellation policy and the **fee/payout decomposition** (`platform_fee_refunded_irr` +
`nurse_payout_refunded_irr`) via the f0 **price-breakdown** primitive (the **server** computes these —
the client renders the preview the contract returns, never recomputes the percentage), a **channel
selector** (`psp_card` / `bnpl_revert` / `manual_bank`), and — for BNPL — an **ETA banner**
(`expected_customer_refund_eta`). Actions: **Initiate refund** (`initiate_refund`, carries the
`ticket_id`), **Approve** (`approve_refund`), **Reject** (`reject_refund`). If the nurse was already
paid, surface that a **clawback** will be created (read-only notice — the server creates it). States:
preview / confirm / **provider-revert-failure → retry** (BNPL/PSP), success. Invalidate the refund +
ticket + (if shown) the customer refund-status query on settle.
### 3.5 Admin: payout dashboard
The action surface over `services/payouts` (b13), explicitly deferred from f12. Reuse the f12 payout
shapes, the payout-status enum, and the money util.
- **Batch dashboard** (`/admin/payouts`) — a list of `nurse_payout_batches` with status
(`pending|processing|paid|partially_failed|failed`), period (holiday-shifted `period_start`/
`period_end`, Shamsi), `payout_count`, `total_amount`, and a **holiday-shift indicator** when the
processing date moved off a bank-closed day. Empty/loading/error states.
- **Batch preview → run** — a **"preview next batch"** action (`preview_payout_batch`) that shows the
**eligibility breakdown**: which completed/unpaid bookings qualify (EVV confirmed **and**
`dispute_window_ends_at` passed), the per-nurse roll-up, the **clawback-netting line**
(`clawback_applied_irr`), and the holiday-shifted processing date — all **server-computed**; the client
only renders the preview (never computes eligibility or the holiday shift, §5). A **"run batch"** action
(`initiate_payout_batch`) behind a **confirmation dialog** that requires an **idempotency key** (the
contract's mechanism) so a double-click or retry **cannot pay a booking twice** (one-payout-per-booking,
§5). After running, the batch moves to **processing** → poll/refetch to **completed** or
**partially-failed**.
- **Batch detail + per-nurse drill-down** (`/admin/payouts/[batchId]`) — the per-nurse `nurse_payouts`
rows with status, the net decomposition (`gross_earnings_irr clawback_applied_irr = net_amount_irr`),
the masked IBAN (last-4), and the `transfer_reference`. A **failed** payout shows its `failure_reason`
and a **retry** action (`retry_payout`) — also idempotency-keyed. The
**`RecordPayoutTransferReference`** reconciliation action (`record_transfer_reference`) lets finance
attach the real bank transfer reference to a payout. Invalidate the batch/detail on each action.
### 3.6 Admin: review moderation queue
The staff lens over `services/reviews` (f13).
- **Moderation queue** (`/admin/reviews`) — a paginated list of reviews in `pending_moderation`
(filterable by status), each showing the rating, body, tags, the booking/nurse context, and a
**low-rating flag** (when `rating < min_rating_for_support_alert`). Actions per review:
**publish / hide / reject** (`moderate_review` with the target status; reject carries a reason).
Each transition triggers a **server-side aggregate recompute** of the nurse's rating — the client just
invalidates the review + the nurse's reviews query; it **never** computes the aggregate (§5). States:
empty ("صف بررسی خالی است / Nothing to moderate"), loading, error, optimistic-vs-confirmed on the
action. Never render `pending_moderation` content as if public.
### 3.7 Admin: config editor + change history
The config surface over `services/admin` (b1). Config edits are **money-correctness sensitive** and
**audited** (§5).
- **Config list** (`/admin/config`) — all `platform_configs` rows grouped sensibly (fees/VAT, deadlines,
EVV, BNPL, cancellation tiers), each rendered with a **typed input by `data_type`**: `bool` → switch,
`int`/`decimal` → numeric field with **range validation** (e.g. a rate field validates **01**), `json`
→ a validated JSON editor, `string` → text. Show the `description` and `updated_at`/`updated_by`.
- **Audited save** — `update_platform_config` behind a **confirmation dialog** that states "this change is
audited and takes effect immediately; it does **not** retroactively change already-computed
bookings/ledger" (copy, both locales). On success show the **optimistic-vs-confirmed** save state and
invalidate the config + the change-history key.
- **Change-history drawer** — per config key, a drawer (`get_config_change_history`) listing each change
(old → new value, actor, Shamsi timestamp) so finance can prove the rate in effect at any past moment.
### 3.8 Admin: holiday calendar manager
Over `services/admin` holidays (b1).
- **Holiday manager** (`/admin/holidays`) — a calendar/list of `iranian_holidays` (by year/range), each
row `holiday_date` (Shamsi), `name_fa`, `type` chip, and an **`is_bank_closed` toggle** (this is what
shifts payout scheduling — surface that consequence in the UI copy). Add/edit a holiday
(`create_holiday`/`update_holiday`). The client **does not** compute next-business-day shifts — it only
maintains the calendar the **server** uses for scheduling (§5). States: empty, loading, error,
save-confirmation.
### 3.9 Admin: support-alert worklist + audit viewer + global ticket queue
- **Support-alert worklist** (`/admin/alerts`) — the **internal-only** triage board over
`services/admin` support-alerts: filter by `type` / `status` (`open|assigned|resolved`) / `assigned_to`;
each card shows the alert type (low-rating / no-show / EVV-mismatch / verification-expiry / fraud-signal),
the linked entity (deep-link to the booking/review/nurse), and severity styling (admin-only). Actions:
**assign** (`assign_support_alert` — to self or another admin) and **resolve**
(`resolve_support_alert` with a resolution note). States: empty ("هیچ هشدار بازی نیست / No open
alerts"), loading, error. **`support_alerts` content NEVER appears in any non-admin surface** (§5).
- **Audit-log viewer** (`/admin/audit`) — a **read-only** table over `list_audit_logs` with **filters**
(entity type/id, actor, action, date range) and **pagination/virtualization** for large result sets;
each row shows the entity, action, actor, Shamsi timestamp, and an expandable `changed_fields_json`
diff. Append-only — there are **no edit/delete affordances** (§5). Empty/loading/error states.
- **Global ticket queue + internal-note composer** (`/admin/tickets`, `/admin/tickets/[id]`) — the **admin
lens** over `services/tickets` (f14): a queue across **all** tickets (filter by status / linked booking /
`reference_code`), and the admin thread view that — unlike the user thread — **renders internal
(`is_internal`) messages distinctly** and provides an **internal-note composer** (post a message with
`is_internal=true`). The refund panel (§3.4) opens from here. The admin types include `isInternal`; the
**user-app types from f14 do not** — keep them separate so an internal note can never bleed into the
user view (§5).
- **(Optional) RBAC admin** (`/admin/roles`) — a user↔role grid over `services/admin` RBAC with
grant/revoke (confirmation + records `granted_by`/`granted_at`). If the b15 contract doesn't expose the
role endpoints when you run, **defer this screen** (file a `REQ` and build it when the endpoints land);
it is not part of the testable acceptance path. Tag it **(DEFERRED-IF-MISSING)** in your report.
### 3.10 Partner-center portal (separate authz scope)
The partner portal mounts under the **distinct partner segment** gated by the partner-center scope (a
center admin is **not** a Balinyaar admin and must see **only their own center's** data — server-enforced
tenancy; never fetch a center by raw id the user doesn't own, §5).
- **Center home / onboarding state** (`/partner`) — the center's
onboarding/verification state (`draft|pending_verification|verified|suspended`) with an
**unverified banner** when not yet active, its license fields read-mostly (پروانه تأسیس / مسئول فنی /
نماد اعتماد الکترونیکی), and `is_merchant_of_record` clearly indicated.
- **Sponsored-nurse list** (`/partner/nurses`) — the nurses this center sponsors
(`list_my_sponsored_nurses`), each with their verification badge; empty ("هنوز پرستاری اسپانسر نشده /
No nurses sponsored yet").
- **Sponsored-bookings list** (`/partner/bookings`) — the bookings the center legally covers
(`list_my_sponsored_bookings`), filterable by status/date; read-only summaries (no PII beyond what the
contract exposes to a center).
- **Settlement / invoice view** (`/partner/settlement`) — **rendered only when
`is_merchant_of_record === true`** (otherwise show a "not merchant-of-record / settlement runs through
Balinyaar" state, no settlement table): the per-booking **commission invoices** (gross / platform
commission / BNPL commission / **VAT on the commission line** / total, via the price-breakdown), the
`moadian_reference_number` when issued, and the **invoice PDF** via a signed-URL download. Money via the
f0 util (Toman display, IRR-string integer-safe). States: empty, loading, error on PDF fetch → retry.
- **Admin-side partner management** (`/admin/partners`, `/admin/partners/[id]`) — the Balinyaar-admin
surface for centers: list/create/edit a center (`create_partner_center`/`update_partner_center` — the
`settlement_iban` field is **write-then-masked**: submit a full IBAN, but the list/detail only ever
shows last-4), **verify** (`verify_partner_center`) and **activate/suspend** (`set_partner_center_active`)
toggles, and the **sponsored-nurse roster** with **assign-nurse** (`assign_nurse_to_partner_center`).
### 3.11 i18n + types housekeeping
- Fill the **`admin`** namespace (reserved in f0) and add a **`partner`** namespace to **both**
`messages/en.json` and `messages/fa.json`, in sync, RTL-first (`fa` default). Every user-visible string
is a key, including the **Persian legal terms** (پروانه تأسیس, مسئول فنی, نماد اعتماد الکترونیکی,
سامانه مودیان) and the admin worklist labels.
- Types come from the published contracts; any gap → append a `REQ-NNN` to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and mock behind the
`services/admin` / `services/partnerCenter` seam (or the extended-domain seam) meanwhile.
**(DEFERRED)** — `organizations` / `organization_nurses` employer model, `fraud_flags` ML console,
`recurring_booking_schedules` recurrence UI ([`data-model/13`](../../../product/data-model/13-partner-centers-and-future.md)
— modeled-but-inactive, **no UI**); full سامانه مودیان e-invoice automation / digital-signature pipeline
(the portal only **views** the issued invoice/ref + PDF — it does not submit); push/SMS notification
channels; an analytics-warehouse dashboard over `system_events`; on-demand/instant payout; per-nurse
payout-frequency settings. Build none of these — flag them in the report if a contract field hints at them.
## 4. Mocks & seams in this phase
This is a **frontend** phase — its only "seams" are the domain services behind which a mock `clientApi`
lives until each backend endpoint is merged (operating-rules §6, frontend-checklist last bullet). **Do
not** introduce backend seams — `IObjectStorage` (signed URLs), `IBankTransferProvider` (payouts),
`IBnplProvider`/`IPaymentGateway` (refund reverts), `IMoadianClient` (invoices), `ILicenseVerificationService`
(partner-center verify), the audit interceptor, the notification dispatcher — those are **server-side**
(b1/b6/b11/b13/b14/b15) and the frontend never touches them. **Reuse** the f5 signed-URL document pattern,
the f10 refund decomposition, the f12 payout shapes, and the f14 ticket thread — do not re-implement them.
- **`services/admin` seam** — if b1/b15 admin endpoints aren't merged, ship a mock `clientApi` (same
method signatures) returning realistic **typed configs** (one per `data_type` so the typed inputs +
the 01 range validation are exercisable), a **change-history** trail, **holidays** (some bank-closed),
a **paged audit log** with `changed_fields_json` diffs, and a **support-alert** list spanning all
statuses/types — including at least one of **each** alert type so the worklist filters are testable.
- **`services/partnerCenter` seam** — a mock returning a **merchant-of-record** center (so the settlement
view renders) **and** a non-MoR center (so the "settlement runs through Balinyaar" state renders),
sponsored nurses (verified + unverified), sponsored bookings, and a couple of commission invoices with a
fake 22-digit `moadian_reference_number` and a stub PDF URL. The `settlement_iban` mock returns **last-4
only**.
- **Extended-domain seams** (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) — for the admin
endpoints added to existing domains, mock the **new** admin methods behind the same domain `clientApi`
(e.g. a verification queue with documents needing a signed URL, a refund preview with a fee/payout split
and a BNPL ETA, a batch **preview** with an eligibility breakdown + a **partially-failed** batch + a
**failed** payout to retry, a moderation queue with a low-rating review, an admin ticket thread **with
an internal note**).
Record **every** mock in your **frontend report** and the
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so the real-endpoint swap is
a one-file change per domain (the hooks/screens stay unchanged — only `apis/clientApi.ts` flips). Append
the corresponding shape requests to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md).
## 5. Critical rules you must not get wrong
- **Internal-only data never reaches a non-admin.** `support_alerts` and internal ticket notes
(`is_internal=true`) are **staff-only** — they appear **only** in admin routes, are fetched **only** by
admin-scoped queries, and must never be joined into or rendered in any customer/nurse/partner surface.
The **admin** ticket types include `isInternal` (admins see internal notes, styled distinctly); the
**user-app types from f14 deliberately omit it** — keep the two type surfaces separate so an internal
note cannot bleed into a user view. Treat any internal content in a non-admin payload as a backend
defect — file it via `for-backend.md`, don't render it.
- **Internal-only & role-gated routes.** Every admin screen is behind the role-gated admin shell; the
partner portal is a **separate authz scope**. The **server enforces** role scopes on every command (a
`support` admin can't run a payout, a `moderator` can't refund, a center admin sees only their own
center) — **never rely on UI hiding for security** — but the UI must **also** hide/disable controls the
current role can't use so a user never sees a button that 403s.
- **The server is the only authority; the client never computes the load-bearing values.** Never compute
on the client: the `is_verified` flip (the server flips it transactionally when all required steps pass —
the UI only enables Approve and confirms), the refund **percentage / fee-vs-payout decomposition**
(render the server's preview), payout **eligibility** or the **holiday-shifted** processing date (render
the server's breakdown), the review **aggregate recompute** (just invalidate), and config parsing beyond
rendering by `data_type`. The client renders contract values and issues commands.
- **Money correctness (verbatim — the sacred invariants across b9b13):** money is **IRR `BIGINT`, no
floats** — parse the wire integer string with the f0 integer-safe util, never `Number()`/float math;
**Toman is display-only**. The three booking amounts always satisfy **gross = commission + payout**
(`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`); render every breakdown so it sums.
Escrow is an **append-only, balanced double-entry ledger** — refund/payout/clawback figures are
ledger-derived; a clawback **nets**, it does not auto-reverse, and a nurse's payable balance **may go
negative** (don't clamp). Payout gating is **dispute-window gating**: an amount is eligible only after
EVV completion **AND** `dispute_window_ends_at < now()` — never show "eligible"/run a payout for an
amount still in its dispute window, and **never compute eligibility on the client**. **One payout per
booking** (`nurse_payout_booking_links.booking_id` is UNIQUE) — the "run batch"/"retry payout" commands
are **idempotency-keyed** so a double-click or retry can never pay a booking twice; render the status the
contract returns (`pending`/`processing`/`paid`/`failed`/`partially_failed`), never an optimistic "done".
**Webhook idempotency** is a server concern, but its client consequence is real: settlement/transfer is
**never instant** — poll/refetch the status, don't assume completion.
- **Commission invoice / VAT (partner portal):** the platform issues **only its commission invoice** (never
the nurse's service invoice); **VAT applies to the commission line, not the gross service fee**; the VAT
rate is **config-driven** (read from `platform_configs.vat_rate`, snapshotted on the invoice) — never
hardcode 10%. **Merchant-of-record drives the settlement/invoice view**: render the settlement table
**only** when `is_merchant_of_record === true`; the issuer/settlement target follows `partner_centers`,
not a hardcoded platform.
- **Refunds are admin-only and ticket-linked.** No customer self-service initiation — the refund panel
opens **from a ticket** and every initiate carries the `ticket_id`. The decomposition (fee leg vs payout
leg) and channel (`psp_card`/`bnpl_revert`/`manual_bank`) come from the server; a post-payout refund
creates a **clawback** (server-created — show it read-only).
- **Append-only audit is read-only.** The audit viewer has **no edit/delete affordance**; config edits are
**audited** and a config change does **not** retroactively alter already-computed bookings/ledger — say
so in the save confirmation. Finance must be able to prove the commission/VAT rate at any past moment via
the change-history drawer.
- **Config typing + validation.** Render each config by its `data_type` and **validate** at the boundary
(a rate field is **01**, an int is integer, a bool is a switch, json must parse) before allowing the
audited save.
- **Signed URLs are short-lived.** Verification documents and invoice PDFs load via **signed URLs**
(never public) — fetch the URL **on demand**, don't cache it in a long-lived query; handle
**loading / expired → re-request / load-error → retry**.
- **PII / masking.** `settlement_iban` (center) and the payout `iban_snapshot` are encrypted/masked —
show **last-4 only**, never a full IBAN; `transfer_reference`/`moadian_reference_number` are opaque
strings shown for reconciliation. Don't log full sensitive values. Verification documents are PII —
signed-URL only, never embedded as a public asset.
- **Tenancy.** A partner-center admin sees **only their own center**; an admin sees only what their role
scopes. Never fetch by a raw id the current principal doesn't own (server-enforced — don't bypass it).
- **Frontend conventions (non-negotiable):** fetch only through `clientFetch` in `services/{domain}`;
TanStack Query caching with deliberate keys (filters + page in the key) + invalidation/`setQueryData`
(no needless refetch — switching a worklist filter or paging must not refetch loaded data); **large
worklists are paginated/virtualized** with **empty/loading/error** states each; minimise re-renders
(`select` to subscribe to slices, stable refs, colocate filter state low — a fast-changing config-input
value must not re-render the whole config table); MUI primitives stay MUI, shared composites (the
worklist row/table, the document viewer, the refund panel, the config row, the audit row, the
support-alert card, the partner settlement row) live at `src/components/…` with co-located tests;
colours from `tokens.css` (status chips off the `--bal-{success,warning,info,error}` semantic tokens);
**both locales in sync**, RTL-correct (the desktop sidebar mirrors; Persian legal terms render
correctly); 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 this phase's specifics:
- [ ] `services/admin` and `services/partnerCenter` exist in the `auth`-service shape (types from the
b1/b15 contracts, `keys.ts` with filters/page in the key, `apis/clientApi.ts`, one hook per file,
hooks-only `index.ts`), and the **admin endpoints are added to the existing**
`verification`/`refunds`/`payouts`/`reviews`/`tickets` domains (not re-created); mutations invalidate
the right keys.
- [ ] **Verification queue** lists pending nurses, the **per-nurse review** loads documents via **signed
URLs** (loading/expired/error handled), supports **pass/reject + reason** per step and **structured
credential entry**, and **Approve** is enabled only when all required steps pass — the client never
writes `is_verified`.
- [ ] **Refund tooling** opens **from a ticket**, renders the **server-computed** fee/payout decomposition
+ channel + BNPL ETA, supports initiate/approve/reject with provider-revert-failure → retry, and
shows the read-only clawback notice when applicable.
- [ ] **Payout dashboard** shows batches with status + holiday-shift indicator, a **batch preview** with
the **server-computed eligibility breakdown** + clawback netting, a **run-batch** action that is
**idempotency-keyed** (no double-pay), and a **batch detail** with per-nurse rows, masked IBAN,
`transfer_reference`, a **failed-payout retry**, and transfer-reference reconciliation.
- [ ] **Review moderation** queue supports **publish/hide/reject** (reject reason); the client invalidates
and never computes the aggregate; `pending_moderation` content is never shown as public.
- [ ] **Config editor** renders each value by `data_type`, **validates** (rate 01), saves with an
**audited-save confirmation** ("audited; effective immediately; not retroactive"), and exposes a
**change-history drawer**.
- [ ] **Holiday manager** lists/adds/edits holidays with the **`is_bank_closed` toggle** (consequence
surfaced); the client never computes the next-business-day shift.
- [ ] **Audit-log viewer** is **read-only** (no edit/delete), filtered (entity/actor/action/date) and
**paginated/virtualized** with a `changed_fields_json` diff.
- [ ] **Support-alert worklist** filters by type/status/owner and supports **assign/resolve** with a note;
**no `support_alerts` content appears in any non-admin surface**.
- [ ] **Global ticket queue** + **internal-note composer** exist (admin sees `is_internal` notes, styled
distinctly); the refund panel opens from here.
- [ ] **Partner-center portal** (separate scope) shows the center's onboarding/verification state,
sponsored nurses, sponsored bookings, and — **only when `is_merchant_of_record`** — the
settlement/invoice view (commission/VAT decomposition, signed-URL PDF, masked IBAN); the **admin-side
partner management** supports create/edit/verify/activate + assign-nurse, IBAN write-then-masked.
- [ ] Every admin route is **role-gated** (controls hidden/disabled per the current role) and the partner
portal is a **separate scope**; UI hiding never substitutes for the server's enforcement.
- [ ] `admin` + `partner` i18n namespaces in `en.json` **and** `fa.json` in sync (incl. پروانه تأسیس /
مسئول فنی / نماد اعتماد الکترونیکی / سامانه مودیان); RTL verified on the desktop shell.
- [ ] Shared composites (worklist row/table, document viewer, refund panel, config row, audit row,
support-alert card, partner settlement row) live at the shared level each with a co-located
`*.test.tsx`; **all money via the f0 util** (no float math); `npm run check` green; `npm run test:ci`
green.
- [ ] Any contract gap is a `REQ-NNN` 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 + mock registry.
- [ ] `client/CLAUDE.md` *Project Structure* updated for `services/admin`, `services/partnerCenter`, the
admin endpoint additions to existing domains, the `/admin/*` route segments, the `/partner/*` scope,
and the new shared admin components; the **frontend-designer skill was invoked** for the visual work.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` signed in as an **admin** (with the b1/b6/b11/b13/b14/b15 admin endpoints live, or the
seam mocks if not yet merged):
1. **Verify a nurse.** Open **/admin/verification** → pick a pending nurse → the per-nurse screen loads
each document via a **signed URL** (let one expire → it offers re-request). **Pass** the remaining
steps (reject one with a reason to see the reason captured), enter a **structured credential**, then
**Approve** → confirm dialog → the nurse leaves the queue and their trust badge flips. *Expected:*
Approve is disabled until all required steps are `passed`; the client never wrote `is_verified` itself.
2. **Process a refund.** From the **/admin/tickets** queue, open a ticket linked to a booking → open the
**refund panel** → see the **server-computed** tiered percentage + **fee/payout decomposition** + the
**channel selector**; for a BNPL booking see the **ETA banner**. **Initiate → Approve** → success; the
decomposition sums (`fee_refunded + payout_refunded` reconciles). If the nurse was already paid, a
**clawback notice** shows (read-only). *Expected:* the refund carries the `ticket_id`; a forced
provider-revert failure shows **retry**.
3. **Preview + run a payout batch.** Open **/admin/payouts** → **preview next batch** → the **eligibility
breakdown** lists only EVV-confirmed, dispute-window-closed bookings, the **clawback-netting** line, and
the **holiday-shifted** processing date. **Run batch** (confirmation requires an **idempotency key**) →
it goes **processing****completed** (or **partially-failed**). Open the batch detail → a **failed**
payout offers **retry**; reconcile a `transfer_reference`. *Expected:* clicking "run" twice does **not**
pay any booking twice; an amount still in its dispute window never appears as eligible.
4. **Moderate a review.** Open **/admin/reviews** → a `pending_moderation` review (with a low-rating flag)
**publish** it → it leaves the queue and the nurse's public reviews update (server-recomputed
aggregate); **hide**/**reject** behave likewise (reject captures a reason). *Expected:* pending content
is never shown publicly; the client didn't compute the aggregate.
5. **Edit a config value (audited + history).** Open **/admin/config** → edit `vat_rate` → the input
validates **01** (try `1.5` → blocked) → **save** → the confirmation states it's **audited, immediate,
non-retroactive** → open the **change-history drawer** → the old→new change, actor, and Shamsi
timestamp appear. *Expected:* the change is recorded; already-computed bookings are unaffected.
6. **Resolve a support alert.** Open **/admin/alerts** → filter to **open****assign** an alert to
yourself (status → assigned) → **resolve** it with a note (status → resolved). *Expected:* the alert is
internal-only — it appears in **no** customer/nurse/partner view anywhere.
7. **Holiday manager.** Open **/admin/holidays** → add a holiday with **`is_bank_closed` on** → it's
listed; (cross-check in §3 step 3 that the next payout preview's processing date shifts off it,
server-side). *Expected:* the client only maintains the calendar; it doesn't compute the shift.
8. **Audit viewer.** Open **/admin/audit** → filter by entity/actor/date → results paginate/virtualize;
expand a row to see the `changed_fields_json` diff. *Expected:* **no** edit/delete control exists.
9. **Partner portal (separate scope).** Sign in as a **partner-center admin****/partner** shows the
center's onboarding/verification state; **/partner/nurses** and **/partner/bookings** show **only that
center's** sponsored nurses/bookings; **/partner/settlement** renders the commission/VAT invoice view
**only when the center is merchant-of-record** (a non-MoR center shows the "settlement via Balinyaar"
state), with a signed-URL PDF and a **masked IBAN (last-4)**. Sign back in as a Balinyaar admin →
**/admin/partners** → create/verify/activate a center and **assign a nurse**. *Expected:* a center admin
cannot see another center's data; the IBAN is never shown in full.
10. **RBAC + i18n + RTL + caching.** A `support`-role admin **cannot** see the payout-run control; a
`moderator` **cannot** see the refund tool. Switch `fa``en` → every label (incl. the Persian legal
terms) translates and the desktop sidebar mirrors. Switch worklist filters / page the lists → React
Query Devtools shows **separate cache entries per filter/page** and **no refetch** of loaded data.
11. **Gate:** `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/admin`,
`services/partnerCenter`, the admin endpoint additions to the existing
`verification`/`refunds`/`payouts`/`reviews`/`tickets` domains, the `/admin/*` route segments + the
`/partner/*` scope, the new shared admin/partner components, and a one-line note on the
`useAdminCapabilities()` role-gating selector and the signed-URL on-demand-fetch pattern so they're
reused. If you discover/decide a business rule the `product/` docs don't capture (e.g. an admin-only
payout-preview field, a partner-center onboarding sub-state, a config validation bound), record it in
the relevant `product/**.md`**don't invent rules**; record decisions and flag uncertain ones in your
report.
- **Contracts to consume:** the primary
[`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md)
(b15 — admin tickets, support-alert worklist, partner centers, RBAC) **plus** the per-domain admin
endpoints in [`verification.md`](../../contracts/domains/verification.md) (b6),
[`refunds.md`](../../contracts/domains/refunds.md) (b11),
[`payouts.md`](../../contracts/domains/payouts.md) (b13),
[`reviews.md`](../../contracts/domains/reviews.md) (b14), and
[`config-reference.md`](../../contracts/domains/config-reference.md) (b1). Derive **all** types from
these — **never** guess shapes. Any missing field/filter/endpoint (e.g. the payout **preview**
eligibility breakdown, the refund decomposition preview, the config `data_type`, the partner settlement
rows, the admin `is_internal` message flag, the RBAC role endpoints) → append a `REQ-NNN` to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
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-15-report.md`](../../shared-working-context/reports/frontend-phase-15-report.md)
(operating-rules §7) — what was built, **what is now testable and exactly how** (the §7 steps), which
client-side mocks sit behind the `services/admin` / `services/partnerCenter` / extended-domain seams and
how each swaps to the real endpoint, the contracts consumed, the `REQ` gaps filed, and — since this is
the **final frontend phase** — a short **"MVP complete" closeout** noting any deferred-if-missing screen
(e.g. RBAC admin) and the modeled-but-inactive tables with no UI. Update the mock registry
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
for every client-side mock.
- **Memory:** save a `project` memory note (with a `MEMORY.md` pointer) for the non-obvious decisions this
phase locks in — the admin-shell route-gating + `useAdminCapabilities()` role selector, the separate
partner-center authz scope and its tenancy, the admin-vs-user ticket type split (`isInternal` admin-only),
the merchant-of-record-gated settlement view + commission-only/VAT-on-commission invoice rule, the
signed-URL on-demand-fetch pattern for documents/PDFs, the idempotency-keyed payout-run/retry, and the
"server is the only authority" boundary (no client-side `is_verified` flip / eligibility / decomposition /
aggregate / holiday-shift). Don't record what the code/docs already make obvious.
+335
View File
@@ -0,0 +1,335 @@
# Frontend Phase 2 — Onboarding & profiles (customer, patient, nurse, bank)
> **Mission:** with auth and roles in place, turn a freshly-logged-in user into a *usable account*.
> A family completes the "who is care for?" onboarding and registers their first **patient** (the
> care recipient, who is not the payer); a nurse bootstraps a public profile and adds the payout
> **bank account** that the verification pipeline and payouts later depend on. This phase implements
> the wireframe onboarding screens (A3, A4, E1) plus the customer profile and the nurse profile /
> bank settings, and stands up the `services/profiles`, `services/patients`, and `services/nurse`
> domains following the f0 data pattern. It is the gate that makes addresses (f3) and booking (f7)
> possible — a booking needs a patient with a known gender, and a payout needs a verified bank account.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-1-b2.md`](./frontend-phase-1-b2.md) (auth/OTP/roles, `AuthContext`) + the **backend-phase-3** contract ([`identity-profiles.md`](../../contracts/domains/identity-profiles.md)) · **Unlocks:** [`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md) (addresses & geo), [`frontend-phase-7-b8.md`](./frontend-phase-7-b8.md) (booking request — needs a patient)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
The user can now log in by phone-OTP and the app knows their role (f1-b2). What they *can't* yet do is
say **who they are caring for**. Balinyaar's whole model rests on the **customer ≠ patient** split: the
payer (an adult child, a spouse) is almost never the care recipient (an elderly parent, an infant, a
post-surgical adult). This phase captures that split for the customer side and, for the nurse side, the
two pieces of profile state that everything downstream hangs off — a public-facing profile and a payout
destination. No search, no booking, no money yet — just the identity/profile surface those later phases
read from.
**What already exists (do not rebuild):**
- **From [`frontend-phase-0.md`](./frontend-phase-0.md):** the three actor app shells + role-scoped
route groups under `[locale]`; the **customer 5-tab bottom nav** (خانه/Home · رزروها/Bookings ·
بیماران/Patients · کیف‌پول/Wallet · پروفایل/Profile); the `services/{domain}` reference pattern
(`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`) modelled on
`src/services/auth/*`; the TanStack-Query caching rules (per-domain `keys` factory, deliberate
`staleTime`/`gcTime`, **invalidate on mutation**); the money/format util in `src/utils/`; the
contracts→types pattern; the i18n namespace plan; and the shared composite components — the
**stepper / progress header**, the **status chip** (verified/pending/…), and the **phone-number
field** — which you **reuse here, not re-create**.
- **From [`frontend-phase-1-b2.md`](./frontend-phase-1-b2.md):** phone login (A1), OTP (A2), the
customer↔nurse login switch, the role router, and roles surfaced in `AuthContext` (read the current
user + role from there; don't re-fetch `/me` ad-hoc). The OTP-input and phone-field composites are
already wired — reuse them.
- **From the backend ([`identity-profiles.md`](../../contracts/domains/identity-profiles.md), b3):** the
live endpoints for customer profile, patients CRUD, nurse profile bootstrap, and nurse bank accounts
(with the IBAN-ownership inquiry). **Consume the contract; do not guess shapes.** If a shape you need
is missing or unclear, append it to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the `services/{domain}` seam meanwhile (operating-rules §6).
> **Scope fence:** this phase builds the **B7 *profile* part only** — avatar + short bio. The nurse
> **services-and-prices builder** on B7 (the "+ افزودن خدمت" list) is **(DEFERRED)** to
> [`frontend-phase-4-b5.md`](./frontend-phase-4-b5.md) (catalog & service builder). The nurse
> **available-days picker** on B7 is **(DEFERRED)** with catalog/availability. **Addresses** (the A4/E1
> sibling "address book") are **(DEFERRED)** to [`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md).
> The whole **nurse verification pipeline** (B3B6, identity/Shahkar/license) is
> **(DEFERRED)** to [`frontend-phase-5-b6.md`](./frontend-phase-5-b6.md) — here the nurse only gets an
> *unverified* profile and a bank account; surfacing the "not bookable until verified" banner is part of
> f5, not this phase (a simple placeholder is acceptable).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md).
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the RSC/client boundary, layouts, i18n, theme,
cookies, the `services/{domain}` fetch pattern, anti-patterns. Re-confirm the f0 *Project Structure*
additions so you place new folders correctly.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(teal `#1d4a40` / terracotta `#d98c6a` palette, `tokens.css`, typography, the `App*` library, the
layout shells, the hard UI rules). The onboarding stepper, gender toggle, condition chips, patient
cards, empty states, and the bank-status panel are all visual deliverables and **must** go through it.
- **Product — the business rules (the source of truth, not the code):**
- [`../../../product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— the customer/patient split, role-staged KYC, and what is MVP vs DEFERRED (customer national-ID
KYC is deferred; do **not** add it to any form).
- [`../../../product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
— the exact tables and columns behind these screens (`customer_profiles`, `patients`,
`nurse_profiles`, `nurse_bank_accounts`) and their constraints (single-primary bank account;
`iban_hash` uniqueness; `matched_national_id`; the guarded `is_verified`).
- **Product — the visual baseline:**
[`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — read screens **A3**
(برای چه کسی؟ — 2-step progress bar, single-select), **A4** (ثبت بیمار — name, age, gender toggle مرد/زن,
condition chips), **A5** (Home — the "complete patient record" nudge you land on), **E1** (لیست بیماران —
patient cards + "+ افزودن بیمار", Patients tab active), and the **B7** profile header (photo + short
bio). Match the RTL Persian layout, the brand colours, and the status legend (green = verified, amber =
pending, grey = manual/later).
- **Contracts:** [`../../contracts/domains/identity-profiles.md`](../../contracts/domains/identity-profiles.md)
(the b3 contract you consume — endpoints, request/response shapes, enums, masking, failure cases),
plus the cross-cutting conventions you already follow:
[`api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, snake_case routes,
pagination, status codes) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md)
(enums-as-codes, **`gender` = `male`/`female` is load-bearing**, masked PII like last-4 of an IBAN).
- **Code to mirror:** `src/services/auth/*` (the exact service skeleton every new domain copies) and the
three shared composites from f0 (`stepper/progress header`, `status chip`, `phone-number field`) — read
their props before reusing them.
- **The handoff you're handed:**
[`../../shared-working-context/backend/handoff/after-backend-phase-3.md`](../../shared-working-context/backend/handoff/after-backend-phase-3.md)
(what b3 shipped, which endpoints are live, what's mocked behind a seam — e.g. the IBAN-ownership
inquiry).
## 3. Scope — build this
Three new domain services, the customer onboarding flow, patient CRUD, the customer profile, and the
nurse profile + bank settings. Every user-visible string is an i18n key in **both** `en.json` and
`fa.json` (RTL-first); every list is cached and invalidated per the f0 pattern; every screen is built
through the **frontend-designer** skill.
### 3.1 The domain services (copy the f0 pattern)
Three services under `src/services/`, each with `types.ts` (from the b3 contract — never guessed),
`keys.ts` (a query-key factory), `apis/clientApi.ts` (wrapping `clientFetch`; `serverApi.ts` only if an
RSC needs prefetch), `hooks/use*.ts` (one hook per file), and `index.ts`:
- **`services/profiles`** — the customer & nurse *profile* domain.
- `useCustomerProfile()` (`useQuery`) → `GET …/me/customer_profile` (or the b3 route).
- `useUpsertCustomerProfile()` (`useMutation``PUT …/me/customer_profile`) — name, contact,
`default_emergency_contact_name`/`default_emergency_contact_phone`; **invalidates** the customer-profile
query and patches `AuthContext`'s cached `/me` if profile-completion changes.
- `useNurseProfile()` (`useQuery`) → the nurse's own profile.
- `useCreateNurseProfile()` / `useUpdateNurseProfile()` (`useMutation``POST/PUT …/me/nurse_profile`)
— bootstrap (`is_verified` stays `false`, server-owned — never sent by the client), then edit
`bio`/`avatar_url`/`years_experience`. Invalidate the nurse-profile query on success.
- **`services/patients`** — the care-recipient domain (customer-scoped).
- `usePatients()` (`useQuery`, paginated per `api-conventions.md`) → `GET …/patients``{ items, total }`.
- `useCreatePatient()` (`POST …/patients`), `useUpdatePatient()` (`PUT …/patients/{id}`),
`useArchivePatient()` (the archive/soft-delete route — sets `is_active=false`, **not** a hard delete).
- All three mutations **invalidate `patientKeys.list()`** (or `setQueryData` to splice the row) so the
E1 list never refetches needlessly; archive optimistically removes/greys the card then reconciles.
- **`services/nurse`** — the nurse payout **bank account** sub-domain (kept separate from the profile
because verification/payouts read it independently).
- `useNurseBankAccounts()` (`useQuery`) → list (usually one primary).
- `useAddNurseBankAccount()` (`POST …/me/nurse_bank_accounts`) — submit IBAN (Sheba) + account-holder
name; the server kicks off the ownership inquiry (mocked behind `IBankAccountOwnershipVerifier` in
b3) and returns the account in a **pending** state.
- `useSetPrimaryBankAccount()` (where the contract exposes it) — single-primary enforcement is
server-side; reflect it in cache.
- All mutations invalidate the bank-accounts query so the pending→verified/mismatch transition shows
on the next read (poll/refetch — see §3.5).
> Where a b3 endpoint isn't live when you build, ship a **mock `clientApi`** behind the same seam (a
> fixed in-memory patient list; a bank account that flips pending→verified after one refetch; a
> mismatch account for a known test IBAN) and record it in your report + the mock registry, so it swaps
> cleanly once the real endpoint lands.
### 3.2 Customer onboarding — A3 → A4 (the "who is care for" flow)
A two-step wizard, mounted in the customer route group, run once after first login (and re-enterable
from the patient list). **Reuse the f0 stepper/progress header** for the 2-step bar.
- **A3 · برای چه کسی؟ (Who is care for?)** — a single-select radio list of relations: **پدر/مادر**
(parent), **همسر** (spouse), **فرزند** (child), **خودم** (self). Selecting a relation carries forward
to pre-shape A4 (e.g. "خودم" pre-fills the patient as the customer). Primary CTA **ادامه** advances the
stepper; back is allowed. The relation is a stable enum code (`parent`/`spouse`/`child`/`self`), an
i18n-labelled chip — never a hardcoded Persian string in logic.
- **A4 · ثبت بیمار (Add patient)** — the patient form: **full name**, **age** (or birth date per the
contract — map to `birth_date`), **gender toggle (مرد/زن → `male`/`female`)**, and **condition chips**
(multi-select: سالمند/elderly, پس از جراحی/post-surgery, دیابت/diabetes, + بیشتر). On submit it calls
`useCreatePatient()`; the chosen relation is stored with the patient. CTA **ذخیره و ادامه** creates the
patient, invalidates the list, and **routes to Home (A5)** where the "complete patient record" nudge is
already shown.
Validation: name required; **gender required** (it drives same-gender matching downstream — see §5);
age/birth-date validated; conditions optional. Surface 400 field errors from the envelope inline.
### 3.3 Patient list & CRUD — E1
The **Patients** bottom-nav tab. Reuse the f0 cards/empty-state primitives where they exist.
- **E1 · لیست بیماران (Patients list)** — patient cards showing relation + name, age/gender, and condition
chips, with a prominent **+ افزودن بیمار** CTA. States to build: **loading skeleton**, **empty state**
(no patients → the A4 add flow as the prominent CTA), and the populated list. Patients tab active in the
bottom nav.
- **Add / Edit** — the same A4 form, reused for create and edit (`useCreatePatient` / `useUpdatePatient`).
Edit pre-fills from the cached row.
- **Archive** — a confirm dialog ("آرشیو بیمار؟") then `useArchivePatient()`; the card is removed/greyed.
**Never a hard delete** — archive only (`is_active=false`); a patient referenced by a past booking must
survive.
> The full **patient record viewer** (E2 — medications/routine/history/tasks tabs) and **nurse visit
> notes** (E3) are **(DEFERRED)** to [`frontend-phase-13-b14.md`](./frontend-phase-13-b14.md). E1 here is
> the list/CRUD shell only.
### 3.4 Customer profile + emergency contact
A profile screen under the customer **پروفایل/Profile** tab: editable `first_name`/`last_name`,
`preferred_language`, optional `avatar_url`, and the **emergency contact** (`default_emergency_contact_name`
+ `_phone`, the phone via the **reused f0 phone-field**). Saves through `useUpsertCustomerProfile()`,
invalidates the profile query, and reflects profile-completion back into the Home nudge. Do **not** add a
national-ID field — customer KYC is **(DEFERRED)** and the column stays unused at launch.
### 3.5 Nurse profile bootstrap + bank settings (the B7 *profile* part)
Mounted in the nurse route group.
- **Nurse profile bootstrap (B7 header)** — **avatar/profile photo** (upload via the contract's
avatar/object-storage route; if not live, mock behind the seam) + **short bio** (+ `years_experience`
if the contract carries it). `useCreateNurseProfile()` on first entry, then `useUpdateNurseProfile()`.
The nurse profile is created **unverified** (`is_verified=false`, server-owned) and **not bookable**
show a neutral "تکمیل احراز هویت برای فعال‌سازی" placeholder pointing at verification (the real banner is
f5). The **services-and-prices builder** and **available-days picker** on B7 are **(DEFERRED)** to f4.
- **Nurse bank-account settings (payout IBAN)** — an **IBAN (شبا) entry** form (Sheba format validation
client-side: `IR` + 24 digits) + account-holder name, submitted via `useAddNurseBankAccount()`. Render
the three ownership-inquiry states off the contract's status field, each a distinct UI state built with
the **reused f0 status chip**:
- **pending** (`matched_national_id` null / inquiry in flight) — amber "در حال استعلام مالکیت حساب" panel;
poll/refetch (`refetchInterval` while pending, then stop) so the transition appears without a manual
reload.
- **verified** (`matched_national_id=true`, `is_verified=true`) — green "حساب تاییدشد" + the **masked**
IBAN (last 4) per the money-and-types masking rule.
- **mismatch** (`matched_national_id=false`) — a clear, **non-accusatory** error state: "حساب باید به نام
خودتان باشد" with a re-enter CTA. Ownership mismatch is gated server-side; surface it as a friendly
domain error, never a raw 4xx toast.
## 4. Mocks & seams in this phase
This is a **frontend** phase; it owns no backend seam. It **consumes** the b3 contract and, where an
endpoint isn't live yet, mocks **behind the `services/{domain}` seam** (a mock `clientApi`) per
operating-rules §6 — the same pattern f0 established. Specifically you may need to mock:
- **Patients CRUD** — an in-memory list seeded with one patient, supporting create/edit/archive.
- **The IBAN-ownership inquiry result** — the real check is the backend's
`IBankAccountOwnershipVerifier` seam (introduced in **b3**, recorded in the
[mock registry](../../shared-working-context/reports/mocks-registry.md)); on the client just drive the
**pending → verified** transition (and a **mismatch** for a known test IBAN) so the three UI states are
demonstrable end-to-end.
- **Avatar/photo upload** — if the contract's storage route isn't live, accept the file and echo a fake
URL behind the seam.
Record every client-side mock in your phase report and in the mock registry with **how f-later (or the
b3 merge) swaps it out** — the swap must be implementation-only, no call-site changes.
## 5. Critical rules you must not get wrong
- **Customer ≠ patient — never collapse them.** The payer and the care recipient are distinct rows; a
patient is created under the signed-in customer and is **tenancy-scoped server-side**. Don't assume the
logged-in user is the patient (except the explicit "خودم" relation, which still creates a patient row).
- **Patient `gender` is REQUIRED.** It is load-bearing for **same-gender caregiver matching** (a near-hard
requirement) used by search (f6) and booking (f7). The gender toggle (`male`/`female`) must be a required
field — never default it, never let the form submit without it.
- **Tenancy is enforced server-side — surface friendly errors.** A `403`/`404` from acting on someone
else's patient/profile is the fetch layer's concern (it already toasts auth errors); your hooks add only
the **domain-specific** message ("این بیمار در دسترس شما نیست"). Never try to enforce tenancy on the
client or expose another customer's data.
- **No customer national-ID KYC.** It is DEFERRED; the column is unused at launch. Do not add a national-ID
field to the customer profile or gate browsing/booking on it.
- **`is_verified` is server-owned and guarded.** The client **never** sends or sets it; a freshly
bootstrapped nurse profile is unverified and not bookable. Reflect that read-only state; the flip happens
only inside the backend verification transaction (f5).
- **Bank account: three states, money-safe.** Render **pending-verification**, **ownership-mismatch**, and
**verified** distinctly; the IBAN is **masked** (last 4) once stored; one primary account per nurse is
server-enforced. **First payout is gated on `matched_national_id=true`** — never present a mismatched or
pending account as ready to pay. The mismatch copy must be **non-accusatory**.
- **Archive, don't delete.** Patient removal is soft (`is_active=false`) so historical bookings stay intact.
- **Caching is a feature.** Patient/profile/bank queries use deliberate `queryKey`/`staleTime`, and every
create/edit/archive **invalidates** (or `setQueryData`) — never re-fetch data already in cache. Keep the
bank `refetchInterval` only while pending; stop it once resolved. Minimise re-renders (colocate form
state, stable callbacks).
- **RSC/client boundary, RTL, both locales, tokens.** Forms and lists are client components (no
`next-intl/server`/`next/headers` in them); `fa` is default and **RTL** — design RTL-first and verify the
gender toggle, chips, and stepper mirror correctly; every string in **both** `en.json`/`fa.json`; colours
from `tokens.css`; MUI v9 API + the pre-built themes only. **MUI primitives stay MUI**; the stepper /
status-chip / phone-field are the **f0 shared composites — reuse, don't re-implement.** Any genuinely new
shareable composite (e.g. a `PatientCard`, a `GenderToggle`, a `ConditionChips`, a `BankStatusPanel`)
lives at the shared `src/components/…` level with a co-located `*.test.tsx`.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus phase-specific:
- [ ] **A3 → A4** runs end-to-end: a new customer picks a relation, fills the patient form (name, age,
**required** gender, optional conditions), and **lands on Home (A5)** with one patient created.
- [ ] **E1** patient list works: empty state with add CTA; create/edit reuse the A4 form; archive
(soft) with confirm; the list is cached and invalidated on every mutation (no needless refetch).
- [ ] **Customer profile + emergency contact** saves and reflects profile-completion; no national-ID field.
- [ ] **Nurse profile bootstrap** (avatar + bio) creates an unverified, not-bookable profile; the
services builder + availability picker are correctly **deferred** (not stubbed as working).
- [ ] **Nurse bank account** submits an IBAN and shows all three states — **pending → verified** (mock
transition) and **ownership-mismatch** — with a masked IBAN on verify and non-accusatory mismatch copy.
- [ ] `services/profiles`, `services/patients`, `services/nurse` follow the f0 pattern (keys factory,
one-hook-per-file, invalidation); types derive from the b3 contract (or a gap is filed in
`requests/for-backend.md` and mocked behind the seam).
- [ ] New shared composites each have a co-located test; the **f0 stepper/status-chip/phone-field are
reused** (not duplicated).
- [ ] `npm run check` green; `npm run test:ci` green for the shared components added; `en.json`/`fa.json`
in sync; `client/CLAUDE.md` *Project Structure* updated for the new services/route folders.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and the b3 server, or the seam mock).
- **Customer onboarding:** log in as a customer → land on **A3**, the 2-step bar shows step 1; pick a
relation → **A4**; try to submit without gender → blocked with a required-field error; fill it and
submit → you land on **Home (A5)** and the "complete patient record" nudge is present. *Expected:* one
patient exists and the flow doesn't re-trigger on next login.
- **Patient CRUD (E1):** open the **Patients** tab → see the patient as a card (relation, name,
age/gender, condition chips). Add a second patient → it appears without a full reload (cache spliced/
invalidated). Edit it → changes persist. Archive it (confirm) → the card disappears; it is **not**
hard-deleted. Open Patients on a fresh account → the **empty state** with the add CTA. Inspect React
Query Devtools: the list query is cached and mutations invalidate it.
- **Customer profile:** edit name + emergency contact → save → the Home nudge reflects completion. Confirm
there is **no** national-ID field.
- **Nurse profile + bank:** log in as a nurse → bootstrap the profile (set avatar + a short bio) → it
saves and shows an **unverified / not-bookable** state. Open bank settings → enter an IBAN → see the
**pending** "در حال استعلام" panel, then (after the mock resolves / a refetch) the **verified** green
state with a **masked** IBAN. Enter the known **mismatch** test IBAN → see the **ownership-mismatch**
error with re-enter CTA. *Expected:* the three states are visually distinct and the verified account
shows last-4 only.
- **i18n / RTL:** switch locale → strings flip `fa``en` and `dir` flips; the gender toggle, chips, and
stepper mirror correctly. `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project Structure** tree in [`client/CLAUDE.md`](../../../client/CLAUDE.md) for
the new `services/profiles`, `services/patients`, `services/nurse` domains, the new shared composites
(`PatientCard`, `GenderToggle`, `ConditionChips`, `BankStatusPanel`), and any new route segments under
the customer/nurse groups. If you discover/confirm a business rule the product docs don't capture
(e.g. a relation-enum decision), record it in
[`../../../product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— don't invent rules. Note any reusable pattern in `client/CLAUDE.md`.
- **Contract:** **consume** [`../../contracts/domains/identity-profiles.md`](../../contracts/domains/identity-profiles.md)
(b3) as the type source — do not guess shapes. Any gap (a missing field, an unclear enum, the
bank-status field, the avatar route) goes to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(append only — never edit backend files); mock behind the `services/{domain}` seam until b3 delivers it.
- **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-2-report.md`](../../shared-working-context/reports/README.md)
covering what was built, **what is now testable and exactly how** (the A3→A4→Home flow, patient CRUD,
the bank state transitions), what is **mocked client-side** (patients list, IBAN-inquiry transition,
avatar upload) and exactly how each swaps to the real b3 endpoint, and follow-ups for f3 (addresses
reuse this profile shell), f4 (the nurse services builder slots onto the B7 profile), and f5
(verification banner replaces the placeholder). Add/extend rows in
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
for every client-side mock.
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes (the
relation-enum + customer/patient split on the client, the three bank-account UI states and the
`matched_national_id` gating, the patients caching/invalidation strategy), with a one-line pointer in
`MEMORY.md`. Don't record what the code/contract already make obvious.
+292
View File
@@ -0,0 +1,292 @@
# Frontend Phase 3 — Addresses, map picker & nurse coverage areas
> **Mission:** give both actors their *place* on the map. Customers build an **address book** — add an
> address by dropping a **map pin** and choosing **province → city → district** from cascading
> dropdowns, with one address marked **primary** — so a later booking knows where the nurse goes.
> Nurses build a **coverage-area editor** — a list of cities (whole city) or city+district areas they
> will travel to — so search can fan them out geographically. This is pure geography: no money, no
> clinical data. It consumes the `geography-addresses` contract from **backend-phase-4** and unlocks the
> booking request flow (**f7**), which needs a chosen address and a matched coverage area.
>
> **Track:** frontend · **Depends on:** [frontend-phase-2-b3](./frontend-phase-2-b3.md) (profiles,
> patients, nurse bank account) + the **backend-phase-4** contract · **Unlocks:**
> [frontend-phase-7-b8](./frontend-phase-7-b8.md) (booking request flow)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
Balinyaar is a trust-first home-nursing marketplace: families book vetted nurses who travel to the
patient's home. A booking can't happen until the platform knows **two geographic facts** — *where the
patient is* (a customer address) and *which areas a nurse will travel to* (their service areas). This
phase builds the UI for both, on top of the geography hierarchy the backend seeds. Geography here is
**named regions, not GPS radii**: the dropdowns are `provinces``cities``districts`, and a map pin
only adds precise coordinates for later EVV distance checks — it does **not** replace the region choice.
**What already exists (do not rebuild) — built by prior frontend phases:**
- **f0 foundations** — the three actor app shells (customer mobile + 5-tab bottom nav, nurse, admin), the
`services/{domain}` + TanStack Query caching pattern (the `auth` service is the canonical template:
`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use{Action}.ts` / `index.ts`), the
contracts→types pattern, the money/format utils, the shared composite components (OTP input, phone
field, **stepper/progress header**, **status chip**), and the i18n namespace baseline in both
`messages/en.json` and `messages/fa.json`. See
[frontend-phase-0.md](./frontend-phase-0.md) and `reports/frontend-phase-0-report.md`.
- **f1-b2 auth** — phone-OTP login, the role router, roles in `AuthContext` (`customer` / `nurse` /
`admin`). You read the current role to decide which editor to mount. See
[frontend-phase-1-b2.md](./frontend-phase-1-b2.md).
- **f2-b3 profiles** — the customer profile + **patient** CRUD (the address book lives alongside patients
in the customer area), and the nurse profile + bank-account settings (the coverage-area editor lives
alongside them in the nurse area). The `services/patients` and `services/profiles` domains and their
settings screens are the layout siblings you slot next to. See
[frontend-phase-2-b3.md](./frontend-phase-2-b3.md) and `reports/frontend-phase-2-report.md`.
> **You build the geography UI only.** The geo *hierarchy* (provinces/cities/districts) is reference data
> the backend seeds and serves; you cache it and render dropdowns. You never seed or mutate it.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you
work, the gate, the handoff, and the tick-list (RSC boundary, `clientFetch`/services, Query caching,
minimal re-renders, MUI primitives, i18n both locales, tokens, RTL).
- **The contract you consume:** [`../../contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md)
(published by backend-phase-4) — the **source of truth** for the geo lookup endpoints
(`ListProvinces` / `ListCities` / `ListDistricts`), the `customer_addresses` CRUD + set-primary
endpoints, and the `nurse_service_areas` add/remove endpoints, with their exact routes, payload shapes,
enums, status codes, and which fields are masked (the encrypted street address). Plus the conventions it
assumes: [`api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, snake_case
routes, pagination, localisation header, `409` on conflict) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (`name_fa`/`name_en` reference
data, UTC timestamps, **coordinates are not money** — but treat lat/lng as the contract declares them).
> If the published contract is missing a shape you need (e.g. a `geocode/reverse` helper, a
> `set_primary` endpoint, or the coordinate field names), **do not guess** — append the request to
> [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
> and mock it behind the `services/{domain}` seam meanwhile (operating-rules §6).
- **Product docs (the business truth — read before designing the forms):**
- [`../../../product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) — the
hierarchy (`provinces` 1:N `cities` 1:N `districts`), why it's tables not static lists
(`sort_order`/`is_active` drive ordered, toggleable dropdowns), and why `nurse_service_areas` is a
named-district join (a `district_id = NULL` row = the **entire city**), `UNIQUE(nurse_id, city_id,
district_id)`.
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
— how coverage areas feed search (city required, district optional; a city-level row means whole city),
the white-space second-tier cities (Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom) the area model must
serve, and why districts are optional. (The same-gender filter lives in **f6**, not here — don't
build it.)
- The `customer_addresses` notes in
[`../../../product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md):
encrypted address + coordinates, **filtered `UNIQUE(customer_id) WHERE is_primary = 1`** (exactly one
primary).
- **Invoke the `frontend-designer` skill** — this is mandatory for every screen, form, dropdown, chip,
map panel, and empty/loading/error state you build. It is the design/brand contract (teal/terracotta
palette, tokens, typography, the `App*` library, the mobile RTL shell, the bottom-nav placement). All
visual work goes through it; do not hand-roll colours or spacing.
- **Code to mirror:** `client/src/services/auth/*` (the service template you copy for `geography` and
`addresses`), `client/src/services/patients/*` and the address-book/patient screens from **f2-b3** (the
same list → add/edit → set-default interaction pattern), and the shared composite components from **f0**
(`StatusChip`, the stepper/progress header) you reuse.
## 3. Scope — build this
Two services and three screen areas. Real names below — build them exactly.
### 3.1 `services/geography` — cached reference-data lookups (cascading dropdowns)
The geo hierarchy is **reference data that almost never changes**, so it is cached aggressively and shared
by *every* consumer (this phase's two editors, plus search in f6). Build it once, here.
- `services/geography/types.ts``Province`, `City`, `District` (each `{ id, name_fa, name_en, sort_order,
is_active }`; `City` carries `province_id`, `District` carries `city_id`) — derived from the contract.
- `services/geography/keys.ts` — a query-key factory: `geographyKeys.provinces()`,
`geographyKeys.cities(provinceId)`, `geographyKeys.districts(cityId)`.
- `services/geography/apis/clientApi.ts` — wraps `clientFetch` over the contract's `ListProvinces` /
`ListCities(province_id)` / `ListDistricts(city_id)` endpoints. **Active-only, `sort_order`-ordered**
(the server already filters/orders; don't re-sort client-side beyond what the contract guarantees).
- `services/geography/hooks/` — one hook per file: `useProvinces.ts`, `useCities.ts` (enabled only when a
province is selected), `useDistricts.ts` (enabled only when a city is selected). **Long `staleTime`**
(e.g. `Infinity` or hours) and a generous `gcTime` so a province/city's children are fetched **once**
per session and served from cache on every revisit and across both editors.
- A reusable `<CascadingRegionSelect>` composite (shared, `src/components/geography/`) that renders the
three dependent MUI `Select`s (province → city → district), driving the child queries, with per-level
**loading**, **disabled inactive regions**, and the **"whole city" affordance** when a city has no
districts (or the user leaves district empty). It exposes `{ provinceId, cityId, districtId }` and an
`onChange` — both the address form and the coverage editor reuse it. **City is required; district is
optional** — leaving district empty is a real choice, never an error.
### 3.2 Customer address book + map-pin add/edit (the customer area)
Mounted in the customer area next to patients (f2-b3). Build:
- `services/addresses` — full domain service mirroring `auth`/`patients`: `types.ts` (`CustomerAddress`
`{ id, title, city_id, district_id|null, address_line (masked/full per contract), latitude, longitude,
is_primary }`), `keys.ts` (`addressKeys.list()`, `addressKeys.detail(id)`), `apis/clientApi.ts`
(list / create / update / delete / **set-primary**), and hooks: `useAddresses.ts` (list),
`useCreateAddress.ts`, `useUpdateAddress.ts`, `useDeleteAddress.ts`, `useSetPrimaryAddress.ts`.
**Every mutation invalidates `addressKeys.list()`** (set-primary also flips the old primary, so
invalidate, don't hand-patch) so the list reflects reality without an over-fetch elsewhere.
- **Address book screen** — list of the customer's addresses as cards: title, city/district label
(`name_fa`/`name_en` by locale), a **primary badge** (reuse the f0 `StatusChip`), and per-card
edit / delete / "set as primary" actions. **Empty state** ("no addresses yet → add your first") and a
loading skeleton. Deleting/setting-primary confirms inline.
- **Add / Edit address form** (a dialog or routed sub-screen — match the f2 patient-edit pattern):
- The `<CascadingRegionSelect>` from §3.1 (province → city → district).
- A **map pin picker** `<AddressMapPicker>` (shared, `src/components/geography/`): a map component
(or the lightweight stand-in described in §4) where the user drags/taps to drop a pin; it emits
`{ latitude, longitude }`. Center it on the chosen city when possible. The picked coordinates are
sent with the create/update request.
- A `title` (e.g. "خانه"/"محل کار") and a free-text `address_line` (the street detail — note it is the
**encrypted** field per the contract; render the masked value the contract returns on read, full only
where the contract allows on the owner's own edit).
- A **"set as primary"** toggle. Validation: **city required**, a **pin required** (surface the
"map-pin-required" error inline if missing), district optional.
- **Single-primary rule on the client:** the UI presents primary as a single-select; the server enforces
the filtered unique index, but the client must never show two primaries — after a `setPrimary`,
invalidate the list so exactly one card shows the badge.
### 3.3 Nurse coverage-area editor (the nurse area)
Mounted in the nurse area next to the profile/bank settings (f2-b3). Build:
- `services/serviceAreas` — domain service mirroring the template: `types.ts` (`NurseServiceArea`
`{ id, city_id, district_id|null }`), `keys.ts` (`serviceAreaKeys.list()`), `apis/clientApi.ts`
(list / add / remove), hooks `useServiceAreas.ts`, `useAddServiceArea.ts`, `useRemoveServiceArea.ts`.
Add/remove **invalidate `serviceAreaKeys.list()`**.
- **Coverage-area editor screen** — a chip/list of the nurse's areas, each chip labelled by city, and by
city + district when a district is set ("whole city" shown explicitly when `district_id` is null). An
**add-area control** built from the §3.1 `<CascadingRegionSelect>` plus a **"whole city" vs "specific
districts" toggle**: choosing "whole city" sends `district_id: null`; choosing "specific districts"
requires picking a district (and lets the nurse add several district rows under the same city).
- **Duplicate prevented inline:** before calling the add mutation, check the in-cache list — if the
`(city_id, district_id)` pair already exists (treating `null` district as a real value), show an inline
"you already cover this area" message and don't fire the request. The server also returns `409` on the
`UNIQUE(nurse_id, city_id, district_id)` violation — handle that `409` as the same inline message
(belt-and-braces; the client check is the fast path, the server is the source of truth).
- **Empty state** — no areas yet → a warning that the nurse **won't appear in search** until they add at
least one coverage area (per the search business doc). Remove-area confirms inline.
### 3.4 i18n + tokens
Every user-visible string (titles, field labels, "whole city", "specific districts", "set as primary",
the empty/error/duplicate messages, the map "drop a pin" helper) is a key in **both** `en.json` and
`fa.json`, in sync, under sensible namespaces (e.g. `address`, `coverage`, `geo`, reusing `common`). `fa`
is default and RTL — verify the dropdowns, chips, and map controls mirror correctly. Colours come from
`tokens.css`; no hardcoded hex in `sx`.
### (DEFERRED) — out of scope, do not build
- **Same-gender filter / search filters** — built in [frontend-phase-6-b7](./frontend-phase-6-b7.md).
- **Map-based *discovery*** (browsing nurses on a map) — DEFERRED per the search doc; this phase's map is
only a pin-picker for one address.
- **Reverse-geocoding "find my city from the pin"** — only if the b4 contract ships a `geocode/reverse`
helper; otherwise the region is chosen by dropdown and the pin only refines coordinates. If you want it
and the shape is missing, request it (don't invent it).
- **Admin geo management** (CRUD of provinces/cities/districts) — admin console, f15.
## 4. Mocks & seams in this phase
This is a **frontend** phase: the only seam you own is the client-side `services/{domain}` boundary.
- **Geocoding / maps** is mocked **server-side** behind the **`IGeocoder`** seam introduced in
backend-phase-4 — you do **not** introduce or own it. The client just sends the picked
`{ latitude, longitude }`; the server (mock today) does any address↔coordinate work. **Reuse it via the
contract**, don't re-create it.
- **The map component itself:** use a real map widget if one is already in the client; otherwise build a
**lightweight stand-in** `<AddressMapPicker>` — a static map image / simple draggable-marker panel that
still emits real `{ latitude, longitude }` — behind a small component boundary so a real Neshan/Google
map drops in later without touching the form. Record this stand-in in your frontend report so it's
swapped cleanly.
- **If backend-phase-4 isn't merged when you start:** build all three services (`geography`, `addresses`,
`serviceAreas`) against a **mock `clientApi`** behind the same `services/{domain}` seam (canned
provinces/cities/districts incl. Tehran's 22 districts and a couple of white-space cities; in-memory
address & area lists honouring single-primary and the duplicate rule), append any contract gap to
`for-backend.md`, and add a row to the **mock registry**
([`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md))
so the swap to the real endpoints is a one-file change per service.
## 5. Critical rules you must not get wrong
- **District is optional, "whole city" is a real choice — not missing data.** A `district_id = null` area
or address means *the entire city*; it is a deliberate selection, never an empty/invalid field. The
duplicate check and the chip label must treat `null` as a real value.
- **City is required.** No address and no coverage area may be saved without a city; surface the error
inline. (And a pin is required on an address — surface "map-pin-required" inline.)
- **Inactive regions disappear.** Only `is_active` provinces/cities/districts appear in dropdowns, ordered
by `sort_order`. Don't render toggled-off regions (the server filters; don't reintroduce them).
- **Exactly one primary address per customer.** The UI presents primary as single-select and invalidates
the list after `setPrimary` so exactly one badge shows; never display two primaries. The server's
filtered unique index is the source of truth — surface its outcome, don't fight it.
- **Duplicate coverage areas are blocked.** Enforce `(city_id, district_id)` uniqueness inline before
firing the add, and map the server's `409` to the same inline message.
- **Cache the geo hierarchy aggressively.** Provinces/cities/districts use a long `staleTime` so dropdowns
are served from cache on revisit and shared across both editors (and later search) — refetching
reference data on every dropdown open is a defect this phase exists to prevent. Address/area lists, by
contrast, invalidate on every mutation.
- **Named regions, not radii.** Don't model coverage as a GPS radius; the pin is *only* extra precision on
an address for later EVV — the bookable geography is the city/district choice.
- **RSC/client boundary & re-renders.** The forms, map, and dropdowns are client components; keep state
colocated low (the form owns its `{provinceId, cityId, districtId, lat, lng}`), use stable `onChange`
refs, and let TanStack Query own server state — no needless re-renders as the user clicks through the
cascade.
- **MUI primitives stay MUI; composites stay shared.** `Select`, `TextField`, `Chip`, `Dialog`, `Button`
are MUI/`App*`; `<CascadingRegionSelect>` and `<AddressMapPicker>` are shared composites in
`src/components/geography/` (both reused by ≥1 screen), each with a co-located `*.test.tsx`.
- **i18n both locales, RTL-first, tokens for colour.** Every string in `en.json` **and** `fa.json`;
verify RTL mirroring of the cascade and chips; colours from `tokens.css`.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/geography`, `services/addresses`, `services/serviceAreas` exist following the `auth`
service template (types from the contract, `keys.ts`, `apis/clientApi.ts`, one hook per file),
with the documented caching: long `staleTime` for geo, mutation-invalidation for addresses & areas.
- [ ] The customer **address book** lists addresses, adds/edits one via the **cascading dropdowns + map
pin**, and **sets a primary** (exactly one badge), with empty/loading/error states.
- [ ] The nurse **coverage-area editor** adds whole-city and city+district areas, shows them as chips,
**blocks a duplicate inline** (and on `409`), and warns when empty that the nurse won't appear in
search.
- [ ] `<CascadingRegionSelect>` and `<AddressMapPicker>` are shared composites with co-located tests;
MUI primitives are reused, not re-implemented.
- [ ] `npm run check` is green; `npm run test:ci` is green (shared components added); `en.json` and
`fa.json` are in sync and RTL-correct.
- [ ] Any contract gap is in `for-backend.md`; the map stand-in and any pre-merge mock `clientApi` are in
the **mock registry**; `client/CLAUDE.md` *Project Structure* is updated for the new
`services/{geography,addresses,serviceAreas}` and `src/components/geography/` folders.
## 7. How to test (what a human can verify after this phase)
Run the client: `cd client && npm run dev`, sign in (f1-b2 OTP), and:
1. **Cascading dropdowns + caching.** As a customer, open *Add address*. Select a province → the city
dropdown loads its cities; select a city → the district dropdown loads (or shows "whole city" when the
city has none). Open *Add address* again — the province/city/district lists come **from cache** (no
refetch; confirm in React Query Devtools). Inactive regions never appear.
2. **Add an address with a map pin + set primary.** Pick city + district, **drop a pin** on the map (the
form sends real lat/lng), enter a title + street, toggle "set as primary", save. It appears in the
address book with a **primary badge**. Add a second address, set *it* primary → exactly one badge moves;
the first is no longer primary. Try saving without a city or without a pin → inline errors.
3. **Nurse coverage areas + duplicate block.** Switch to the nurse area → *Coverage*. With no areas, see
the "won't appear in search" warning. Add a **whole-city** area (district empty) → a chip appears. Add a
**city + district** area → another chip. Try adding the **same** `(city, district)` again → an inline
"already covered" message, no request fired; if you force it past the client (or the server is hit),
the `409` shows the same message. Remove an area → it disappears.
4. **i18n / RTL.** Flip locale to `en` and back to `fa`: every label/empty/error/duplicate string
translates, the cascade and chips mirror correctly in RTL, and colours match the brand tokens.
5. `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project Structure** tree in [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md)
for the new `services/geography`, `services/addresses`, `services/serviceAreas` domains and the
`src/components/geography/` composites; note the **aggressively-cached geo reference-data pattern**
(long `staleTime`, shared key factory) as a reusable convention so f6 search reuses it, not reinvents
it. Fix any doc drift you touch.
- **Contract:** **consume** [`../../contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md)
— derive every type from it; do **not** guess shapes. Any missing/ambiguous shape (coordinate field
names, masking of `address_line`, a `set_primary` route, a `geocode/reverse` helper) is appended to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
— you never edit backend files.
- **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-3-report.md`](../../shared-working-context/reports/frontend-phase-3-report.md)
— what shipped (the two editors + three services + two composites), **what is now testable and exactly
how** (the steps in §7), what is mocked client-side (the map stand-in; the pre-merge mock `clientApi`s if
used) and how f-next swaps each, the contract consumed, and follow-ups (the address chosen here feeds the
**f7** booking request; coverage areas feed **f6** search). Update the **mock registry**
([`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md))
for the map stand-in and any mocked client API.
- **Memory:** save a `project` memory note for the non-obvious decisions — the **geo cache strategy**
(long `staleTime`, shared `geographyKeys` factory reused by addresses, coverage, and search), the
**`<CascadingRegionSelect>` / `<AddressMapPicker>` composites**, and the **"`district_id = null` = whole
city"** rule the booking and search phases must honour — with a one-line pointer in `MEMORY.md`.
+331
View File
@@ -0,0 +1,331 @@
# Frontend Phase 4 — Catalog browse & nurse service builder
> **Mission:** light up the two faces of the configurable service catalog. For the **family/customer**
> this is the **Home (A5)** screen — greeting, the search bar, the four-tile service-category grid
> (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی), and the complete-patient-record nudge —
> the front door of the whole app. For the **nurse** this is the **"add a service" builder (B7 services)**:
> a stepper that walks pick-category → answer required/optional option groups → set price + price unit,
> producing a priced **variant** (the atomic bookable unit), plus the list/edit/deactivate surface for a
> nurse's own offerings. This is what makes nurses *appear* in search (f6) and what a customer browses, so
> it must be exactly right about money, required-option validation, and reference-data caching.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md) (addresses & geo, cascading province/city/district, nurse coverage editor) + backend **phase b5** contract (`catalog`) · **Unlocks:** search & discovery ([`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))
> **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 hinge between *identity/geo* (f1f3, done) and *discovery/booking* (f6+). A nurse cannot
be found until she has at least one **active, priced variant**; a customer has nowhere to start until the
**Home category grid** exists. This phase builds both, against the backend's **Catalog & pricing** domain
(b5): admin-seeded `service_categories``service_option_groups``service_option_values`, and the nurse
layers `nurse_service_variants``nurse_service_variant_options`. The bookable unit is the **variant**, never
the nurse and never the category — search, booking, and pricing all operate on variants downstream.
The product framing: transparent, **nurse-set** pricing per variant is a deliberate differentiator versus the
opaque "توافقی/negotiable" incumbents — so the price the nurse enters and the way we *display* it (price +
unit, with session count) is brand-load-bearing, not a detail.
**What already exists (do not rebuild) — built by prior frontend phases:**
- **f0** ([`frontend-phase-0.md`](./frontend-phase-0.md)) — the three actor app **shells** and route groups
(customer mobile shell with the **5-tab bottom nav** خانه/رزروها/بیماران/کیف‌پول/پروفایل · nurse shell ·
admin shell); the **`services/{domain}` + TanStack Query** reference pattern (`types.ts` / `keys.ts` /
`apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`); the **types-from-contract** convention; the
**shared composite components** including the **stepper/progress header**, **status chip**, OTP input,
phone field; the **money/format util** (`formatIrrToToman`, integer-safe IRR parse, Shamsi date display)
in `src/utils/`; the i18n namespace conventions in both `messages/en.json` and `messages/fa.json`; the
RTL baseline and `tokens.css` brand colours. **Reuse all of it — do not re-create the shell, the stepper,
the money util, or the services pattern.**
- **f1-b2** — phone-OTP login, the role router, and roles in `AuthContext`. You read the current role to
decide *customer Home* vs *nurse builder* chrome; you do **not** touch auth.
- **f2-b3** — onboarding, patient CRUD, customer & nurse profiles, nurse bank-account settings. The
**patient list/record state** is what the Home "complete patient record" nudge points at; the **nurse
profile** is the parent the variant builder hangs off (B7 = "تکمیل پروفایل و خدمات"; this phase owns the
**services part**, the profile-bio/photo part is already in f2).
- **f3-b4** — address book + map picker + cascading **province → city → district** dropdowns, and the **nurse
coverage-area editor** (`nurse_service_areas`). You do **not** rebuild geo; the *service areas* a nurse
declares there are what fan the variant into search (f6) — out of scope here, just don't regress it.
> The variant builder produces *pricing*; the coverage editor (f3) produces *geography*; **search (f6)**
> joins them. This phase ships neither search nor the index — wiring the Home search bar to results is f6.
## 2. Required reading (do this first)
**Product / domain (business truth — read before designing any screen):**
- [`../../../product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
— the catalog model in plain language: admin defines categories + configurable option groups/values; each
**nurse defines variants** (category + chosen option values + own `price` + `price_unit`); the five price
units (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`); `display_name` auto-generates from
option labels but is nurse-editable; **deactivate not delete**; catalog snapshotted onto bookings. This is
the *why* behind every validation rule below.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline.
Study **A5 (خانه / Home)**: greeting + avatar, search bar (`جستجوی خدمت یا پرستار…`), the **service-category
grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی), the **complete-patient-record nudge card**,
and the **bottom tab nav** (Home active). And **B7 (تکمیل پروفایل و خدمات)**: the services-and-prices list
(`مراقبت سالمند — ساعتی ۲۸۰٬۰۰۰ تومان/ساعت`, `+ افزودن خدمت`) — this phase builds the *services* half of B7.
Note the legend (green=verified, amber=pending) and the deep-green brand / cream surface / Vazirmatn font.
**Contract to consume (the source of truth for shapes — do not guess):**
- [`../../contracts/domains/catalog.md`](../../contracts/domains/catalog.md) — written by **backend-phase-5**.
This is where the real routes, request/response payloads, enums, and pagination live. Read it end-to-end
before writing a single type. If it is not yet published or a needed shape is missing, follow the seam
procedure in §4 (mock behind `services/catalog` and append a request for backend).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
envelope (`OperationResult`/`ApiResult`, already unwrapped by `clientFetch`), `snake_case` routes/JSON,
status codes, **mandatory pagination** (`page`/`page_size``items`+`total`) on the variant list,
`name_fa`/`name_en` reference-data localisation.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
**money is IRR Rials as an integer string on the wire**, parsed integer-safe and rendered via the f0
money util; **Toman is display-only**; enums cross as stable string codes (`per_hour`/`per_session`/
`per_half_day`/`per_day`/`per_24h`) mirrored as string-literal unions with **i18n labels, never a label
hardcoded off the code**.
**Engineering & design rules:**
- [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) and
[`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, `services/{domain}` +
Query caching + invalidate-on-mutation, one-hook-per-file, minimal re-renders, MUI v9 primitives reused,
both locales in sync, tokens-based colours, RTL.
- **Invoke the `frontend-designer` skill** for *all* visual work here (Home, the category grid/tiles, the
builder stepper UI, the variant list/cards, every empty/loading/error/success state). It is the brand/
design contract — palette, tokens, typography, the `App*` library, layout shells, the hard UI rules. Do
not hand-craft visuals outside it.
- The existing **f0 `services/auth/*`** and the **f3 `services/geo` + `services/address`** services — copy
their exact structure for the new `services/catalog`. Reuse the f0 **stepper** and **status chip**; do not
fork them.
## 3. Scope — build this
Two surfaces (customer Home, nurse builder) + the supporting `services/catalog` domain. Build every state
(loading / empty / error / validation / success) the digest's "Notes for UI" calls for.
### 3.1 `services/catalog` domain (the data layer the screens consume)
Create `client/src/services/catalog/` mirroring the f0 pattern:
- `types.ts` — string-literal unions + DTOs derived from [`catalog.md`](../../contracts/domains/catalog.md):
`PriceUnit = 'per_hour' | 'per_session' | 'per_half_day' | 'per_day' | 'per_24h'`; `ServiceCategory`
(`id`, `name_fa`, `name_en`, `sort_order`, optional `icon`/`slug`); `ServiceOptionGroup` (`id`,
`service_category_id` nullable = cross-category, `name_fa`/`name_en`, **`is_required`**, `sort_order`);
`ServiceOptionValue` (`id`, `option_group_id`, `name_fa`/`name_en`, `sort_order`); `NurseServiceVariant`
(`id`, `service_category_id`, `price` as **IRR digit-string**, `price_unit`, `session_count` nullable,
`display_name`, `is_active`, the chosen `options: { option_group_id, option_value_id }[]`). Mirror the
exact casing/nullability from the published swagger — do not invent.
- `keys.ts` — a query-key factory: `catalogKeys.categories()`, `catalogKeys.categoryOptionGroups(categoryId)`,
`catalogKeys.myVariants()` (nurse), `catalogKeys.variant(id)`.
- `apis/clientApi.ts` — wrappers over `clientFetch` for each route in the contract (see §3.4). Add
`apis/serverApi.ts` (`serverFetch`) **only** if the Home category grid is prefetched in an RSC (see 3.2).
- `hooks/` (one hook per file): `useServiceCategories.ts`, `useCategoryOptionGroups.ts` (query),
`useMyVariants.ts` (paginated nurse list), `useCreateVariant.ts`, `useUpdateVariant.ts`,
`useDeactivateVariant.ts` (mutations). **Reference data (categories, option groups/values) is cached with
a long `staleTime`/`gcTime`** — it changes rarely; do not refetch it on every screen. Variant mutations
**invalidate `catalogKeys.myVariants()`** (and `setQueryData` the edited row where it avoids a refetch).
- `index.ts` — barrel export.
### 3.2 Customer **Home (A5)** — `app/[locale]/(private-routes)/<customer>/home`
The primary landing screen, inside the customer shell with the **bottom tab nav (Home active)**:
- **Greeting + avatar** — `سلام، {firstName}` from `AuthContext`/`useCurrentUser` (f1); avatar from the
customer profile (f2). RSC where it cleanly removes a round-trip; no `next/headers` in client components.
- **Search bar** — `جستجوی خدمت یا پرستار…`. **Render the input here, but search execution is f6** — tapping
it navigates toward the (future) search route / sets the query; do **not** implement results, the index,
or filters in this phase. Tag results/filters **(DEFERRED → [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))**.
- **Service-category grid** — a tile per `service_category` from `useServiceCategories()`, ordered by
`sort_order`, label by locale (`name_fa`/`name_en`), seed-matching the wireframe's four
(مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) but **data-driven** (never hardcode the category
list — EAV configurability is load-bearing). Tapping a tile carries the chosen `service_category_id` into
the (future) search flow. Build **loading skeleton tiles**, **empty** (no categories seeded → friendly
message) and **error/retry** states.
- **Complete-patient-record nudge card** — shown when the signed-in customer has no patient *or* an incomplete
record (derive from the f2 patient state already in cache — do **not** add a new fetch if f2's query
already holds it); CTA routes to add/complete a patient (f2 screens). Hide when the record is complete.
- A small reusable **`CategoryTile`** and **`HomeNudgeCard`** composite live at the shared level if reused;
page-only composition stays in the page.
### 3.3 Nurse **"add a service" builder + offerings list** — `app/[locale]/(private-routes)/<nurse>/services`
The services half of **B7**, inside the nurse shell:
**(a) Offerings list (`ListMyVariants`)** — `useMyVariants()` (paginated):
- Each row/card shows `display_name`, the **price rendered via the f0 money util** as
`{formatIrrToToman(price)} تومان {unitLabel}` (unit label is an i18n key off `price_unit`), and an
**active vs deactivated** visual distinction (reuse the f0 **status chip**) with a "deactivated can't be
booked" hint on inactive rows. Row actions: **Edit**, **Deactivate** (with a confirm).
- States: **loading** (skeleton rows), **empty** (no offerings yet → prominent `+ افزودن خدمت` CTA), populated.
**(b) Create/Edit variant builder (`CreateVariant` / `UpdateVariant`)** — a **stepper** (reuse the f0
stepper/progress header), launched by `+ افزودن خدمت` or Edit:
1. **Step 1 — category.** Single-select from `useServiceCategories()`. On select, fetch that category's
option groups via `useCategoryOptionGroups(categoryId)` (cached). (On *edit*, category is fixed — changing
it would change identity; lock it and explain.)
2. **Step 2 — options.** Render each `service_option_group` for the category (plus any cross-category group
where `service_category_id` is null) as a single-select of its `service_option_value`s. **Mark required
groups** (`is_required`) and **block advancing until every required group is answered** (one value per
group — `UNIQUE(variant_id, option_group_id)` is enforced server-side; the UI enforces single-select).
Optional groups may be left unanswered.
3. **Step 3 — price + unit (+ duration).** A **price** field (Toman input → store/submit as IRR digit-string
via the f0 integer-safe parse; **never a float**), a **`price_unit`** select (the five units, i18n
labels), and an optional **`session_count`/duration**. Show a **live estimated total** computed from
**price + unit + session_count together** — e.g. for `per_hour` with a duration, surface the
`formatIrrToToman(price × hours)` estimate — **do not compute or display a total from price alone**. Show
the auto-generated **`display_name`** (from the chosen option labels) as an editable field.
- **Submit:** `useCreateVariant()` / `useUpdateVariant()`. On the **duplicate-listing conflict** (server `409`
on `(nurse_id, category, option-set)`), show a **friendly inline warning** ("شما قبلاً خدمتی با همین مشخصات
دارید") and let the nurse adjust — don't silently fail or generic-toast it. Success → invalidate
`myVariants`, route back to the list with the new/edited row visible.
- States to build: loading (fetching catalog), per-step validation errors (missing required option, missing/
invalid price), the duplicate warning, success.
**(c) Deactivate (`DeactivateVariant`)** — `useDeactivateVariant()`; a confirm dialog explaining the variant
becomes **unbookable and drops out of search**; **soft only — never a hard delete**. On success, flip the
row to the deactivated visual state (`setQueryData`/invalidate).
### 3.4 Catalog browse (categories) — the read surface
The category-browse query the Home grid and the (future) search filters both consume: `ListCategories` and
`GetCategoryOptionGroups` via `services/catalog`. Build the **catalog-browse view** as the data-driven grid
in 3.2 (Home) reusing `CategoryTile`; a standalone "all categories" browse screen is optional and may be
**(DEFERRED → f6)** if the Home grid + search cover it — your call, but if you build it, reuse the same
hooks/components.
**Endpoints consumed (final names from [`catalog.md`](../../contracts/domains/catalog.md) — these mirror the
b5 capabilities; use the contract's exact `snake_case` routes):**
- `GET api/v1/catalog/categories` → categories (reference data, cached).
- `GET api/v1/catalog/categories/{id}/option_groups` (with values) → the skeleton the builder renders.
- `POST api/v1/nurse/variants` → CreateVariant.
- `PUT api/v1/nurse/variants/{id}` → UpdateVariant / EditDisplayName.
- `POST api/v1/nurse/variants/{id}/deactivate` → Deactivate (soft).
- `GET api/v1/nurse/variants` (paginated) → ListMyVariants (active + inactive).
**Out of scope (tag explicitly):**
- Search results / filters / the nurse-result cards / the search index — **(DEFERRED → [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))**.
- The **admin catalog manager** (category/option-group/value CRUD) — **(DEFERRED → admin console [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md))**; the admin seeds the catalog server-side for now.
- Nurse **availability** slots/calendar — **(DEFERRED**, soft-constraint, not on this path).
- The **public nurse profile** services rows a customer sees — **(DEFERRED → f6 C3)**.
- Holiday/surge pricing, companionship tier, per-category commission — **(DEFERRED** per product doc).
## 4. Mocks & seams in this phase
This is a **frontend** phase: the only seam is the data seam behind `services/catalog`.
- **Reuse the `services/{domain}` seam pattern from f0.** Every catalog call goes through
`services/catalog/apis/clientApi.ts` (over `clientFetch`) — never a raw `fetch()`.
- **If the b5 `catalog` contract is published and merged**, derive `types.ts` from it and call the real
endpoints — no mock.
- **If a needed shape is missing or the contract isn't live yet**, build a **mock `clientApi`** behind the
same `services/catalog` seam (returning realistically-shaped data: a few seeded categories, an option
group with `is_required` true/false, a couple of variants across price units, and a `409` path to exercise
the duplicate warning), **and**:
- append the gap to [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(per operating-rules §6 — you request, backend delivers; never edit backend files), and
- record the mock in [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
+ your phase report (per operating-rules §7) with exactly how f-next swaps it for the real endpoint.
- No new external-service seam is introduced here (Elasticsearch / `INurseSearch` belongs to **search**,
f6/b7 — do not pull it forward).
## 5. Critical rules you must not get wrong
- **Money correctness — IRR is integer, never a float.** Money is **IRR Rials as an integer string on the
wire**; parse it integer-safe and render it **only** through the f0 money util (`formatIrrToToman`). Toman
is **display-only**; convert Toman input → IRR digit-string at the field boundary. **No floating-point**
anywhere on the price path (input, state, or submit) — float coercion is a defect.
- **price + unit + session_count drive the displayed total — never compute the total from price alone.** The
estimated total = `price` interpreted by `price_unit` combined with `session_count`/duration. A bare
`price` is a unit rate, not an engagement total. Render the unit label from an **i18n key off the
`price_unit` code**, never a label hardcoded in the component.
- **Reference data is cached.** Categories and option groups/values are admin-seeded reference data — fetch
once with a long `staleTime`/`gcTime` and reuse from cache; do not refetch them per screen or per step.
Variant mutations invalidate `myVariants` (and `setQueryData` the edited row) so you never needlessly
refetch the list.
- **Validate every required option group.** The builder must not submit until **every `is_required` group has
exactly one value chosen**; optional groups may be empty; one value per group (single-select mirrors the
server's `UNIQUE(variant_id, option_group_id)`).
- **Deactivate is soft — never hard-delete.** A deactivated variant must read as unbookable and is understood
to drop out of search; there is no delete affordance.
- **Duplicate-listing is a friendly conflict, not a crash.** Surface the server `409` on
`(nurse_id, category, option-set)` as inline, actionable copy.
- **Data-driven catalog (no hardcoded enums).** Categories, option groups, and option values come from the
API and render by `sort_order` + locale label — **never** hardcode the category/option list as constants.
The **only** closed enum is `price_unit` (the five units).
- **RTL-first, both locales.** `fa` is default and RTL; every user-visible string is a key in **both**
`en.json` and `fa.json`, in sync. Persian unit labels (ساعتی / روزانه / شبانه‌روزی, …) must read correctly.
- **RSC/client boundary & re-renders.** No `next/headers`/`next-intl/server` in client components; keep
builder step state colocated (low) so typing in the price field doesn't re-render the whole stepper;
stable references where it pays.
- **MUI primitives stay MUI; reuse the shared stepper & status chip** from f0 — do not fork a new root
primitive or a second stepper.
- **Tenancy is server-enforced, but don't leak it in UI:** the nurse only ever sees/edits *her own* variants
(`GET api/v1/nurse/variants` is self-scoped) — never build a UI that lists or edits another nurse's
offerings.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus these phase specifics:
- [ ] `services/catalog` exists (types/keys/apis/hooks/index) mirroring the f0 pattern; reference data
cached with deliberate `staleTime`/`gcTime`; variant mutations invalidate/`setQueryData` `myVariants`.
- [ ] **Home (A5)** renders inside the customer shell with the bottom nav: greeting + avatar, the search bar
(navigates toward f6, results not built here), the **data-driven category grid** (loading/empty/error
states), and the complete-patient-record nudge (derived from cached f2 state, hidden when complete).
- [ ] The **nurse builder** (stepper category → required/optional options → price+unit+duration) enforces all
required groups, shows the live unit-aware estimated total, auto-generates an editable `display_name`,
submits as IRR digit-string, and handles the **duplicate `409`** with friendly inline copy.
- [ ] The **offerings list** shows active vs deactivated distinctly, supports **edit** and **soft
deactivate** (confirm dialog), with empty/loading states.
- [ ] All money rendered via the f0 money util; the **`price_unit`** labels are i18n keys in **both** locales,
RTL-correct; `en.json`/`fa.json` in sync.
- [ ] Types derive from [`catalog.md`](../../contracts/domains/catalog.md); any gap is logged in
`for-backend.md` and mocked behind the `services/catalog` seam (recorded in the mock registry).
- [ ] `npm run check` green; `npm run test:ci` green for any shared component added (e.g. `CategoryTile`,
a price-unit display, the variant card) with co-located `*.test.tsx`.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new route segments (customer `home`, nurse
`services`) and the `services/catalog` domain + any new shared component.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and ensure the b5 endpoints are reachable, or the `services/catalog` mock is active).
1. **Home renders.** Sign in as a customer → the Home screen shows the greeting, avatar, search bar, and the
**category grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) ordered by `sort_order`. With
no patient on file, the **complete-patient-record nudge** is visible; after completing a patient (f2) it
disappears (no extra refetch — verify in React Query Devtools that the patient query is reused).
2. **Locale + RTL.** Switch `fa``en` → labels translate, `dir` flips, the grid and tiles mirror correctly;
Persian unit labels read right.
3. **Build a variant.** Sign in as a nurse → `+ افزودن خدمت` → step through: pick a category; in the options
step, try to advance **without** answering a required group → **blocked** with a clear message; answer it →
advance; set a **price (in Toman)** and a **unit** (e.g. ساعتی) with a duration → the **estimated total**
updates from price × duration (not from price alone); edit the auto-generated `display_name`; submit →
the new variant appears in the offerings list, price shown as `… تومان ساعتی`.
4. **Duplicate warning.** Create a second variant with the **same category + same option set** → the builder
shows the friendly duplicate-listing warning (server `409`) and lets you change it, without a crash or a
generic error toast.
5. **Edit + deactivate.** Edit a variant's price/`display_name` → list reflects it without a full refetch
(Devtools: `setQueryData`/single invalidation). Deactivate a variant → it flips to the deactivated visual
state with the "can't be booked" hint; there is **no delete** option.
6. **Caching.** In React Query Devtools confirm `catalogKeys.categories()` / `categoryOptionGroups` are
served from cache across Home and the builder (no repeated network calls per step); a variant mutation
invalidates only `myVariants`.
7. **Gate.** `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):**
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) *Project Structure* — add the customer `home` and nurse
`services` route segments, the `services/catalog` domain, and any new shared component (`CategoryTile`,
price-unit display, variant card). Note the reference-data caching convention if it's the first long-lived
cached domain.
- If you discover or decide a catalog/pricing rule the product docs don't capture (e.g. how the estimated
total is presented for each unit), record it in
[`../../../product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
— don't invent rules; record decisions, and flag any open question in your report.
- **Contract to consume:** [`../../contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (b5) —
types/services derive from it; do not guess shapes. Any missing/ambiguous shape → append to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(you request; backend delivers — never edit backend files). The frontend produces no contract.
- **Handoff & report (per operating-rules §6–§7):**
- Append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md).
- Write `dev/shared-working-context/reports/frontend-phase-4-report.md`: what shipped (Home A5, the nurse
variant builder + offerings list, `services/catalog`), **what is now testable and exactly how** (the §7
steps), what is mocked vs live behind the catalog seam and **how f6 swaps it**, the contract consumed,
and the follow-ups handed to **f6** (the Home search bar now hands a `service_category_id` to search; the
variant builder is what populates the index f6 reads).
- Update [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
if you mocked any catalog endpoint (seam, what's faked, config, how to make it real).
- **Memory (per operating-rules §8):** save a `project`-type memory note for the non-obvious decisions —
the price/unit/session_count display rule (total never from price alone), the IRR-string-in/Toman-display
money handling on the builder, and the reference-data caching choice — with a one-line `MEMORY.md` pointer.
Don't record what the code/docs already make obvious.
+339
View File
@@ -0,0 +1,339 @@
# Frontend Phase 5 — Nurse verification flow (mocked vendors)
> **Mission:** build the trust engine's front end — the staged, platform-owned verification flow a nurse
> walks through before any of their services can go live. A nurse lands on a **status checklist** (B3),
> submits **identity** (B4: national-ID + card image + liveness selfie), submits **professional
> credentials** (B5: نظام پرستاری number + license + education + specialties), and waits on the
> **under-review** screen (B6) until an admin decides. Documents upload to the object-storage-backed
> endpoint with type/size validation and progress; each step shows its own status (and its rejection
> reason); and a **trust badge** (verified / unverified / expired) renders on the nurse profile. Verified
> trust is the entire brand — a nurse is **not bookable and cannot publish until verified**, and the UI
> must say so honestly and never advertise a check the platform doesn't actually perform.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-4-b5.md`](./frontend-phase-4-b5.md) (catalog browse + nurse service builder) and the **b6** verification contract · **Unlocks:** a bookable verified nurse + the search **trust badge** consumed in [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the fifth feature slice of the customer/nurse front end and the **nurse side's gating step**.
By f4 a nurse can build service variants, but those variants are **inert** — they cannot surface in
search or be booked until `nurse_profiles.is_verified` is true. This phase builds the screens that flip
that switch: the data-driven verification pipeline (six step types, all vendor calls **mocked** server-side
behind DI seams in b6) rendered as a nurse-facing checklist, the two submission forms, the waiting state,
the document uploader, and the public trust badge. When this lands, a nurse can go from "registered" to
"verified and publishable", which is the prerequisite for everything downstream — search (f6), booking
(f7), and ultimately payout (f12, gated on the bank-account verification step).
**What already exists (do not rebuild) — built by prior phases:**
- **f0 foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the three actor app shells +
route groups, the **nurse shell** ("نمای پرستار"), the `services/{domain}` + TanStack Query caching
pattern (`keys.ts` factory, `apis/clientApi.ts`, one-hook-per-file), the contracts→`types.ts` pattern,
the money/format utils, and the **shared composite components** — most importantly the **stepper/
progress header** and the **status chip** (verified/pending/…). **Reuse both here; do not re-implement
a stepper or a status chip.** The `verification` i18n namespace was reserved in f0 — fill it.
- **f1-b2 auth** ([`frontend-phase-1-b2.md`](./frontend-phase-1-b2.md)): phone-OTP login, the role router,
roles in `AuthContext`. The nurse arrives here already authenticated with the `nurse` role. **Step 1 of
the checklist (شماره موبایل — mobile verified) is already satisfied at login** — render it as `passed`,
don't ask for it again.
- **f2-b3 onboarding** ([`frontend-phase-2-b3.md`](./frontend-phase-2-b3.md)): the nurse profile bootstrap
(`CreateNurseProfileCommand` → an unverified `nurse_profiles` row, `is_verified=false`) and the nurse
**bank-account settings** screen (IBAN entry → ownership inquiry). The verification pipeline's
`bank_account_verification` step couples to that bank account — **link to the existing bank-account
screen for that step; do not rebuild the IBAN form here.**
- **f3-b4 addresses/geo** ([`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md)): the nurse coverage-area
editor and the cascading geo dropdowns (not used here, but the nurse shell nav points to them).
- **f4-b5 catalog** ([`frontend-phase-4-b5.md`](./frontend-phase-4-b5.md)): the nurse "add a service"
builder (B7) and `services/catalog`. **The "انتشار پروفایل / go live" action that f4 stubs must be
gated on verification by this phase** — wire the blocked-until-verified state into the publish CTA.
> **Honesty constraint (load-bearing, from the product doc and GTM notes):** vetting is platform-owned and
> performed at the authoritative source. **Never word the UI to advertise a check that isn't performed.**
> The MoH/INO license and criminal-record steps are *manual admin review of an uploaded document* at
> launch — copy must say "در حال بررسی" (under review), not "تاییدشده توسط نظام پرستاری" until an admin
> actually passes it. The civil-registry/Shahkar/liveness checks are real (vendor-mocked) — those may say
> "استعلام خودکار".
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you
work, the gate, the contract/handoff lanes, the mock-then-swap rule (§6).
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary,
layouts, the `services/{domain}` shape, i18n, theme, cookies, the fetch services). Non-negotiable.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(palette, tokens, typography, the `App*` library, layout shells, the hard UI rules). Every screen,
chip, uploader, and badge in this phase goes through it. The wireframe's status legend is **green =
automatic/verified, amber = pending, grey = manual/next, terracotta = financial** — encode those as
token-driven chip variants.
- **Product — the source of truth for the rules:**
- [`../../../product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) —
the six steps, what each verifies, why it's manual vs automated, the structured-credential-registry
rationale, continuous re-verification, and **the "never advertise a check you don't perform" rule**.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — **Section B,
screens B3B6** are the visual baseline you implement: B3 status meter "۲ از ۵" + stepped checklist
with status badges; B4 identity (کد ملی field, upload national-ID card, liveness selfie, "استعلام
خودکار از ثبت احوال" note); B5 professional credentials (شماره نظام پرستاری, license upload, education
cert shown uploaded ✓, specialty chips سالمندان/ICU/+افزودن); B6 "در حال بررسی" (2448h, mini-checklist).
Also note B7's "انتشار پروفایل" — the publish gate this phase enforces, and C2/C3's "✓ تاییدشده" badge
this phase's trust-badge component feeds.
- **The contract you consume (the authoritative server shapes):**
- [`../../contracts/domains/verification.md`](../../contracts/domains/verification.md) — written by
**backend-phase-6**. The exact request/response shapes, routes, status enums, and the per-step status
codes. **Do not guess shapes** — derive `types.ts` from this doc + the published `swagger.json`. If a
shape you need is missing, 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/verification` seam meanwhile (operating-rules §6).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — the envelope (`OperationResult`
`ApiResult`, already unwrapped by `clientFetch`), `snake_case` routes/properties, pagination, **enums
as stable string codes** (mirror them as string-literal unions; labels are i18n keys, never derived
from the code), UTC timestamps with **Shamsi display client-side** (credential issue/expiry dates).
- **Code to mirror:**
- The `services/catalog` and `services/onboarding` domains from f4/f2 — the exact `types.ts`/`keys.ts`/
`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts` layout your `services/verification` copies.
- The shared **stepper/progress header** and **status chip** from f0 (`src/components/…`) — extend, don't
fork. The nurse-profile bank-account screen from f2 — you deep-link into it for the bank step.
- The existing document/image handling, if any, in `App*` (`AppImage`); the `clientFetch` multipart path.
## 3. Scope — build this
A new **`services/verification`** domain, the **nurse verification route subtree** under the nurse shell,
a reusable **document-upload** component, a **trust-badge** component, and the publish-gate wiring. Build
RTL-first, both locales, query-cached, minimal re-renders.
### 3.1 `services/verification` domain (the data layer)
Copy the f0/f4 service shape exactly. Types come from
[`../../contracts/domains/verification.md`](../../contracts/domains/verification.md) — do not invent.
- **`types.ts`** — string-literal unions mirroring the contract enums: the **aggregate status**
(`not_started` | `pending` | `in_review` | `approved` | `rejected` | `suspended`), the **per-step
status** (`not_started` | `pending` | `in_review` | `passed` | `rejected`), the **step `code`s**
(`identity_kyc` · `shahkar_match` · `moh_competency_license` · `ino_membership` · `criminal_record` ·
`bank_account_verification`), `verification_method` (`manual` | `portal` | `api`), and the
badge state (`verified` | `unverified` | `expired`). Plus the DTOs the contract returns:
`VerificationStatus` (aggregate + ordered `steps[]` each with `code`, `status`, `is_automated`,
`rejection_reason?`, `expires_at?`), `VerificationStep`, `VerificationDocument` (storage key, file name,
uploaded-at — **metadata only, never bytes**), `NurseCredential` (type, masked number, holder name,
issuing authority, `issued_at`/`expires_at`).
- **`keys.ts`** — a query-key factory: `verificationKeys.status()`, `.documents(stepCode)`,
`.badge(nurseId)`. Deliberate `staleTime` (status is moderately fresh — e.g. 30s — because submitting a
step changes it; the badge is longer-lived).
- **`apis/clientApi.ts`** wrapping `clientFetch` — one function per contract endpoint (see §3.2). The
document upload uses the multipart path against the `IObjectStorage`-backed endpoint.
- **`hooks/` — one hook per file:**
`useVerificationStatus` (query), `useStartVerification`, `useSubmitIdentity`, `useSubmitCredentials`
(or per-credential submit if the contract splits MoH/INO/criminal), `useUploadVerificationDocument`
(mutation with progress), `useNurseTrustBadge` (query). **Every mutation invalidates
`verificationKeys.status()`** (and the badge where relevant) so the checklist re-renders from cache
without a manual refetch. Don't toast 401/403/5xx (the fetch layer does) — only domain 4xx (e.g.
"ownership mismatch", "shared-SIM", "national-ID format").
### 3.2 Endpoints consumed (per the b6 contract — confirm exact routes/shapes there)
Wire each via `clientApi`; the names below are the expected commands/queries from the digest — bind to
whatever the contract publishes:
- `GET .../nurse/verification``GetVerificationStatusQuery` — the aggregate + per-step list driving B3/B6.
- `POST .../nurse/verification/start``StartNurseVerificationCommand` — seeds the steps (call from the
B3 "start / continue" CTA when status is `not_started`).
- `POST .../nurse/verification/identity``RunIdentityKycCommand` — national-ID + card image + liveness
selfie; drives B4. (Server also runs `shahkar_match` off the bound national-ID — surface both steps.)
- `POST .../nurse/verification/moh-license` (+ `.../ino`, `.../criminal-record` if split) →
the credential-submit commands behind B5.
- `POST .../nurse/verification/document` (or a presigned-URL flow) → the `IObjectStorage`-backed upload
used by every document step.
- The **bank step** (`bank_account_verification`) is submitted on the **existing f2 bank-account screen**
this phase only renders its status in the checklist and **deep-links** to that screen.
- `GET .../nurse/{id}/badge` (public) → `GetVerifiedBadgeQuery` — feeds the trust badge.
### 3.3 Screens (under the nurse shell `(private-routes)` nurse subtree)
Invoke `frontend-designer` for each. RTL-first; the screens live under the nurse route group from f0.
- **B3 · وضعیت احراز هویت — status checklist** (the hub).
- Overall progress meter **"X از Y"** (X = passed required steps, Y = total required) — reuse the f0
stepper/progress header.
- An **ordered, stepped checklist**, one row per step from `steps[]`, each with a **status chip** (reuse
the f0 status chip) in the five per-step states: `not_started`/locked-next (grey "بعدی"), `pending`
(amber "در انتظار"), `in_review` (amber "در حال بررسی"), `passed` (green "تاییدشده"), `rejected`
(red "رد شد" + the reason). Render **step 1 (mobile) as `passed`** from auth state.
- A **"what's blocking go-live" summary** + a single **continue CTA** ("ادامه مرحله N") that routes to
the next actionable step. When `not_started`, the CTA calls `useStartVerification` first.
- States: loading skeleton, error, and the terminal **`approved` state** (all passed → "احراز هویت
تکمیل شد، می‌توانید پروفایل را منتشر کنید" with a link to publish).
- **B4 · تایید هویت — identity submit.**
- **کد ملی (national-ID)** field with format validation (10-digit, checksum); **upload national-ID card
image** (camera/gallery via the document uploader §3.4); **liveness selfie** capture (camera; handle
permission-denied / retry / vendor-timeout states honestly).
- The honest auto note "استعلام خودکار از ثبت احوال" (auto civil-registry query) — this check *is*
performed (vendor-mocked), so the copy is allowed.
- Submit → `useSubmitIdentity`; on success the `identity_kyc` (and server-run `shahkar_match`) steps move
to `pending`/`in_review`, the cache invalidates, and the user returns to B3. Surface the **shared-SIM**
Shahkar failure as a clear, **non-accusatory** message, and the national-ID-mismatch failure on its step.
- **B5 · مدارک حرفه‌ای — professional credentials.**
- **شماره نظام پرستاری (INO number)** field; **upload پروانه/کارت نظام پرستاری** (the MoH competency
license — the single most important credential) via the uploader; **education certificate** upload
(shown with the uploaded-✓ state when present); **specialty chips** (multi-select: سالمندان, ICU, +
افزودن — an add-your-own chip input).
- Structured fields the registry needs where the contract asks for them (license number, issuing
authority, holder name as printed, issue/expiry date — **Shamsi date picker, stored UTC**).
- Submit → `useSubmitCredentials`; the credential steps move to `in_review` (manual admin review). Copy
must reflect manual review, not an automated authority confirmation.
- **B6 · در حال بررسی — under review.**
- The waiting state: "مدارک شما در حال بررسی است" + the **2448h** expectation + a **mini-checklist**
summarizing which steps are passed vs in-review (identity verified / professional docs in review /
bank account pending). Reuses the same per-step chips as B3, condensed. CTA "مشاهده وضعیت" → B3.
- This is the post-submission resting screen; B3 is the canonical source — B6 is a focused view of the
same `VerificationStatus`, not a second fetch with different state.
- **Trust badge on the nurse profile.** Render the **verified / unverified / expired** badge (the
"✓ تاییدشده" mark) on the **nurse's own profile** (f2) and export a small `<TrustBadge status=…/>`
shared component so **search results (C2) and the public nurse profile (C3) in f6 reuse it**. Source it
from `GetVerifiedBadgeQuery`. `expired` (a required credential lapsed) shows distinctly from `unverified`.
- **Publish gate.** Wire the f4 "انتشار پروفایل / go-live" action: when the aggregate status is not
`approved`, the publish CTA is **disabled with a blocked-until-verified explanation** ("برای انتشار،
احراز هویت را کامل کنید") that links to B3. This is the front-end enforcement of "not bookable until
verified" — the server enforces it too; the UI must not let a nurse *believe* they're live when they're not.
### 3.4 Document uploader (shared composite component)
Build a reusable **`<DocumentUpload>`** at the shared level (`src/components/…`, co-located `*.test.tsx`)
composed from MUI/`App*` primitives — used by every document step (national-ID card, license, education,
criminal record):
- **Client-side validation** before upload: file **type** (jpg/png/pdf per the contract) and **size cap**;
reject with a clear field error.
- **Upload states:** idle → selecting → **uploading (progress %)** → processing/hashing → success
(thumbnail/✓ + file name) → **error (retry)**. Wire progress from the upload mutation.
- **Re-upload on reject:** when a step is `rejected`, the uploader shows the prior file's status and a
re-upload affordance.
- Returns the server's stored **document metadata** (storage key, name) — never holds or displays raw
bytes beyond a local preview.
### 3.5 i18n namespace `verification` (both locales)
Fill the f0-reserved `verification` namespace in **both** `messages/en.json` and `messages/fa.json`, in
sync: step labels and per-step status labels (one key per `code` and per status — **never** derive a label
from the enum string), the B3/B4/B5/B6 screen copy, the uploader states, the honesty-sensitive copy
(manual-review vs auto-query), the blocked-until-verified message, the trust-badge labels, and the
shared-SIM / mismatch / format error messages. `fa` is default and RTL.
### Deferred (do not build here)
- The **admin verification review queue** (pass/reject a manual step, the doc viewer, credential-entry
form) → **(DEFERRED to [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md))**, the admin backoffice.
This phase only triggers the mock approval to *observe* the state change (see §7); it does not build the
admin UI.
- **Credential renewal/expiry prompts** beyond rendering the `expired` badge state → the nurse renewal
prompt UI is **(DEFERRED)**; the server's `CredentialExpiryScannerJob` (b6) raises the alert.
- **Video interview** (B6 mentions it) — not a built step at MVP; reflect it only as a grey "next" row if
the contract returns it; otherwise omit. Do not invent an interview-scheduling flow **(DEFERRED)**.
- Customer national-ID KYC — out of scope (the product gates only nurses) **(DEFERRED)**.
## 4. Mocks & seams in this phase
This phase **introduces no new front-end seam of its own** beyond the standard `services/{domain}` data
seam — the verification *vendors* (identity KYC, Shahkar, MoH/INO license, criminal record, IBAN ownership,
object storage) are **mocked server-side** behind DI seams owned by **backend-phase-6** (`IIdentityKycProvider`,
`IShahkarVerifier`, `ICredentialVerifier`, `IBankAccountOwnershipVerifier`, `IObjectStorage`) — the front
end consumes them only through the b6 contract, exactly as if they were real. **Reuse the `services/{domain}`
mock-`clientApi` pattern from f0:** if the b6 contract or a specific shape isn't published when you start,
build a **mock `clientApi`** behind the `services/verification` seam that returns realistic, contract-shaped
`VerificationStatus` (e.g. identity → `in_review`, then flippable to `passed`), record it in your phase
report + the [mock registry](../../shared-working-context/reports/mocks-registry.md), and **append the gap
to [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)**. The swap to the real
endpoint must be implementation-only (same hook signatures, same `queryKey`s) — no call-site changes.
## 5. Critical rules you must not get wrong
- **A nurse is NOT bookable and cannot publish until verified.** When the aggregate status is not
`approved`, the publish/go-live CTA is disabled with the blocked-until-complete explanation, and every
service variant stays inert. This front-end gate mirrors the server's guarded `is_verified` flip — never
let the UI imply a nurse is live before verification completes.
- **Honest copy — never advertise a check that isn't performed.** Manual steps (MoH/INO license, criminal
record) say "در حال بررسی / آپلود شد"; only the genuinely-performed automated checks (identity liveness,
civil-registry, Shahkar) may say "استعلام خودکار". A step is "تاییدشده" only when its status is `passed`.
This is a product constraint, not a style preference.
- **Per-step rejected-with-reason.** A `rejected` step renders its `rejection_reason` and a clear
re-submit/re-upload path — never a dead end. The shared-SIM Shahkar failure is an explicit, handled,
**non-accusatory** state, not a generic error.
- **The data is data-driven — render `steps[]` from the contract, don't hardcode the six steps.** The
pipeline is rows server-side; the UI iterates the returned ordered list and maps each `code`/`status` to
a label + chip. A new step type appearing in the response must render without a code change.
- **Reuse the shared stepper and status chip from f0** — do not re-implement them. Concrete MUI primitives
stay MUI; the new shared pieces (`<DocumentUpload>`, `<TrustBadge>`) live at the shared level with tests.
- **Caching is a feature.** B3 and B6 read the *same* `VerificationStatus` query — one cached source, two
views; every submit/upload mutation **invalidates** the status (and badge) so the checklist updates from
cache, never an extra manual refetch. Minimise re-renders (stable refs, `select` for slices).
- **RSC/client boundary + RTL + both locales + tokens.** No `next/headers`/`next-intl/server` in client
components; design RTL-first (`fa` default); every string a key in both locale files; colours from
`tokens.css` (the chip variants map to the wireframe's green/amber/grey/red legend); MUI v9 API only.
- **Document bytes are never shown from the API** — uploads return metadata only; a local preview before
upload is fine, but the rendered "uploaded" state is driven by server metadata, not retained bytes. The
national-ID and credential numbers are sensitive — show **masked** values where the contract masks them.
- **Dates:** credential issue/expiry are UTC on the wire, **Shamsi for display** (use the f0 date util).
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/verification` exists with the f0/f4 shape; types derived from
[`verification.md`](../../contracts/domains/verification.md) (not guessed); mutations invalidate the
status/badge query; one hook per file; no raw `fetch()`.
- [ ] B3 (status checklist), B4 (identity), B5 (credentials), B6 (under review) render under the nurse
shell, RTL, both locales in sync, reusing the f0 stepper + status chip.
- [ ] `<DocumentUpload>` and `<TrustBadge>` are shared components with co-located `*.test.tsx`; uploader
enforces type/size, shows progress, and supports re-upload on reject.
- [ ] The publish/go-live CTA from f4 is gated: disabled + blocked-until-verified message until `approved`.
- [ ] Honesty copy verified: manual steps never claim an automated authority check; a step shows
"تاییدشده" only when `passed`.
- [ ] Rejected steps show their reason and a re-submit path; shared-SIM is a clear non-accusatory state.
- [ ] `npm run check` green; `npm run test:ci` green (shared components added/touched); `en.json`/`fa.json`
in sync.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new route subtree + `services/verification`
+ the new shared components.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and the b6 server, or the mock `clientApi` if b6 isn't merged). Sign in as a `nurse`.
- **Checklist hub:** open the nurse verification screen → **B3** shows the "X از Y" meter, step 1 (mobile)
already `passed`/green, the rest `not_started`. The continue CTA routes to the next step (calling
`start` first when `not_started`).
- **Submit identity:** **B4** → enter a valid کد ملی, upload a national-ID card image (watch the
uploader's progress → success), capture a liveness selfie, submit → the `identity_kyc` and
`shahkar_match` steps move to **`pending`/`in_review`** on B3 **without a manual refresh** (cache
invalidation). Try an invalid national-ID → field error; trigger the mock shared-SIM number → a clear
non-accusatory Shahkar message.
- **Submit credentials:** **B5** → enter the نظام پرستاری number, upload the license + education cert, add
specialty chips, submit → the credential steps move to **`in_review`**; the UI lands on **B6** showing
"در حال بررسی", the 2448h note, and the mini-checklist.
- **Approval flips verified:** trigger the b6 mock admin approval (the mock provider/test endpoint, or set
the mock `clientApi` to return `approved`) → re-open B3: aggregate is **`approved`**, all required steps
`passed`, the **trust badge** shows **verified** on the nurse profile, and the **publish CTA is now
enabled** (was blocked-until-verified before).
- **Rejected step:** make a step return `rejected` (mock) → its row shows **the rejection reason** and a
working re-upload/re-submit path; the publish CTA stays blocked.
- **Quality:** `npm run check` and `npm run test:ci` pass; toggle locale → RTL/strings flip correctly;
dark mode renders; React Query Devtools shows one `verification.status` query feeding both B3 and B6 and
invalidating on each mutation.
## 8. Hand off & document (close the phase)
- **Docs:** update the *Project Structure* tree in [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md)
for the nurse verification route subtree, `services/verification`, and the new shared components
(`<DocumentUpload>`, `<TrustBadge>`); add a one-line note that the trust badge is the reusable component
f6 consumes. If you discover a verification rule the product docs don't capture, record it in
[`../../../product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md)
(don't invent — record decisions).
- **Contracts:** **consume** [`../../contracts/domains/verification.md`](../../contracts/domains/verification.md)
(frontend produces none). Any missing/ambiguous shape (e.g. whether credential submit is one endpoint or
split, the exact document-upload flow, the badge payload) → append to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md);
do **not** edit backend files.
- **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-5-report.md`](../../shared-working-context/reports/frontend-phase-5-report.md)
— what was built, **what is now testable and exactly how** (the §7 steps), what is mocked client-side
(the `services/verification` mock `clientApi`, if used) and how it swaps to the real b6 endpoint, the
contract consumed + gaps filed, and the follow-up that f6 reuses the `<TrustBadge>`. Update the
[mock registry](../../shared-working-context/reports/mocks-registry.md) for any client-side mock.
- **Memory:** save a `project` memory note for the non-obvious decisions — the data-driven step rendering,
the single-status-query-two-views (B3/B6) caching choice, the reusable `<TrustBadge>`/`<DocumentUpload>`
seams, and the publish-gate wiring — with a one-line pointer in `MEMORY.md`.
+310
View File
@@ -0,0 +1,310 @@
# Frontend Phase 6 — Search & discovery (find a verified, same-gender nurse)
> **Mission:** build the family-facing **discovery** experience — the heart of the marketplace. A
> customer picks a care category, narrows by city / gender / price, and gets a rating-sorted list of
> **only verified, accepting** nurses; opening one shows their trust badges, attribute chips, priced
> services, and latest review, ending in the **"درخواست رزرو"** call-to-action that hands off to the
> booking flow. This phase implements wireframe screens **C1 (search & filter)**, **C2 (results)**,
> and **C3 (nurse profile)** against the `search` domain (backend phase b7), and establishes the shared
> **nurse-result card** + **price-row** components that the booking flow will reuse.
>
> **Track:** frontend · **Depends on:** [frontend-phase-4-b5](./frontend-phase-4-b5.md) (catalog browse
> & service builder) · [frontend-phase-5-b6](./frontend-phase-5-b6.md) (verified-nurse / trust badge) ·
> backend **b7** contract ([`dev/contracts/domains/search.md`](../../contracts/domains/search.md)) ·
> **Unlocks:** [frontend-phase-7-b8](./frontend-phase-7-b8.md) (booking request flow)
> **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 pivot of the customer journey: everything before this phase let a family *enter* the app
(auth f1), describe *who needs care* (onboarding f2), say *where* (addresses f3), and *browse the catalog*
(f4). This phase is the first time a family sees **real nurses** and chooses one. It is the screen the
product calls the trust funnel: discovery surfaces **only platform-vetted, same-gender-filterable**
caregivers, which is Balinyaar's entire differentiation versus opaque incumbents. The output of this
phase — a selected nurse + the carried filter intent (especially `required_caregiver_gender`) — is the
input to the booking request (f7).
**What already exists (do not rebuild) — link, extend, never re-create:**
- **f0 foundations** ([frontend-phase-0](./frontend-phase-0.md)): the customer mobile shell with the
5-tab bottom nav (خانه/رزروها/بیماران/کیف‌پول/پروفایل), the `services/{domain}` + TanStack Query
caching pattern (`keys.ts` factory, `apis/clientApi.ts`, one-hook-per-file, mutation invalidation),
the **money/format util** (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display) in
`src/utils/`, the i18n namespace conventions (incl. the `search` namespace), the RTL baseline, and the
shared composite primitives (status chip, stepper, etc.). **Copy the `auth` service shape — do not
invent a new data pattern.**
- **f4 catalog** ([frontend-phase-4-b5](./frontend-phase-4-b5.md)): the `catalog` service + the
**category grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) and category cards used on
the customer Home (A5). C1 **reuses that category grid component** and the catalog query — do not
rebuild category fetching here.
- **f5 verified-nurses** ([frontend-phase-5-b6](./frontend-phase-5-b6.md)): the **trust badge**
component(s) — ✓ تاییدشده (verified) and نظام پرستاری (INO membership) — and the verification-status
vocabulary. C2/C3 **reuse those badges**; do not re-implement the verified mark.
- **Money/format:** prices render through the f0 `formatIrrToToman` util (IRR Rials string → Toman
display). Never format money inline.
> **Data note:** b7's `GET /search/nurses` reads the denormalized `nurse_search_index`, which by
> invariant contains a row **only** when the nurse is `is_verified` AND not suspended AND
> `is_accepting_bookings` AND the variant `is_active`. So *every* result you receive is already
> bookable — the UI must not need to re-filter for verification. (See §5.)
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md).
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client
boundary, the `[locale]` layout rule, i18n, theme/tokens, `clientFetch`/`serverFetch`, the
`services/{domain}` layout, TanStack Query setup, the cookie manager). Don't break the boundary.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(palette: teal `#1d4a40`, terracotta `#d98c6a`, cream; tokens; typography; the `App*` library; the
mobile shell; the hard UI rules). C1/C2/C3 must come out branded, RTL, dark-mode-ready. **All visual
work goes through it.**
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — Section **C**
(screens **C1**, **C2**, **C3**): the exact layout, copy, and controls you implement. C1 = selected
category + filter pills (تهران/location, تاریخ/date, جنسیت/gender) + category grid + "مشاهده ۲۴ پرستار"
results CTA; C2 = result count + "مرتب‌سازی: امتیاز" sort + nurse cards (photo, name, ✓ تاییدشده,
rating/review count, distance km, "from X تومان/ساعت"); C3 = avatar, name, rating, verified + نظام
پرستاری badges, attribute chips, services-and-prices rows, latest review snippet, "درخواست رزرو".
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
— the business rules: category + city/(optional district) geo search, rating sort, and the
**same-gender caregiver** near-hard requirement (`nurse_gender` filter + the requested
`required_caregiver_gender` carried *before* booking, not after).
- **The contract** [`../../contracts/domains/search.md`](../../contracts/domains/search.md) (b7) +
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) and
[`api-conventions.md`](../../contracts/conventions/api-conventions.md) — the envelope, the
IRR-as-string money rule, `gender` = `male`/`female`/`any`, `price_unit` =
`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`, enums-as-codes (labels are i18n keys,
never derived from the code), pagination params, and the exact request/response shapes for
`GET /search/nurses` and the nurse-profile/variant payloads. **Types come from this doc — do not guess
server shapes** (if a shape is missing, follow §4 + operating-rules §6).
- The backend handoff [`../../shared-working-context/backend/handoff/after-backend-phase-7.md`](../../shared-working-context/backend/handoff/after-backend-phase-7.md)
— which endpoints are live, what's mocked, what to consume.
- The existing **`src/services/auth/*`** (the template) and the **f4 `catalog`** + **f5 trust-badge**
code — the patterns you copy and the components you reuse.
## 3. Scope — build this
A vertical slice: `services/search` (types/keys/apis/hooks) → the three screens (C1, C2, C3) → the two
shared components (nurse-result card, price-row), all RTL/i18n/cache-correct.
### 3.1 The `search` domain service (`src/services/search/`)
Copy the f0/`auth` service shape exactly:
- **`types.ts`** — mirror the b7 contract as string-literal unions / interfaces (don't guess):
- `NurseSearchFilters``serviceCategoryId: number`, `cityId: number`, `districtId?: number`,
`nurseGender?: 'male' | 'female'` (omit = any), `priceMin?: string`, `priceMax?: string` (IRR
strings), `sort: 'rating'` (the only MVP sort), `page`, `pageSize`.
- `NurseSearchResult` — the C2 card row: `nurseId`, `variantId`, `displayName`/`nurseName`,
`avatarUrl`, `isVerified` (always true by invariant), `averageRating`, `totalReviews`,
`distanceKm?`, `priceFromIrr` (string), `priceUnit` (the `per_*` union), `nurseGender`.
- `NurseProfile` — the C3 payload: identity (`nurseName`, `avatarUrl`, `bio`, `yearsExperience`),
`averageRating`/`totalReviews`/`totalCompletedBookings`, `isVerified`, `inoMembership` (نظام پرستاری
badge flag), `attributeChips` (specialties/تخصص‌ها labels), `services: NurseProfileServiceRow[]`
(each = `variantId`, `displayName`, `priceIrr` string, `priceUnit`, optional `sessionCount`), and a
`latestReview?` snippet (`rating`, `body`, `authorMasked`, `createdAt`).
- `Paged<T>` — reuse the f0 paginated envelope type.
- **`keys.ts`** — the query-key factory: `searchKeys.results(filters)` keyed on the **full filter
object** (this is the cache contract — see §5), `searchKeys.profile(nurseId)`.
- **`apis/clientApi.ts`** — wraps `clientFetch` (never raw `fetch`):
- `searchNurses(filters): Promise<Paged<NurseSearchResult>>``GET /search/nurses` (filters as query
params; omit absent optional filters so the key/URL stay stable).
- `getNurseProfile(nurseId): Promise<NurseProfile>` → the b7 nurse-profile endpoint (consume the exact
route from the contract).
- Add a **`serverApi.ts`** only if you prefetch C2/C3 from an RSC to remove a client round-trip
(optional, see §5).
- **`hooks/` (one hook per file):**
- `useNurseSearch.ts``useQuery({ queryKey: searchKeys.results(filters), queryFn })` with a
deliberate `staleTime` (results are read-heavy and change slowly) and `keepPreviousData` so the list
doesn't flash empty while a new filter loads.
- `useNurseProfile.ts``useQuery` on `searchKeys.profile(nurseId)`; `enabled` only when an id is
present.
- `useDebouncedSearchFilters.ts` (or fold the debounce into the C1 controller) — **debounce the
free-text/quick-filter input** so keystrokes don't fan out one request per character.
- **`index.ts`** — barrel.
### 3.2 C1 — Search & filter screen (`جستجو و فیلتر`)
The entry screen (reachable from Home/A5 search bar and category tap). Build:
- **Selected-category** header + the **category grid** (reuse the f4 catalog category grid;
selecting a category sets `serviceCategoryId`). Categories shown per wireframe: مراقبت سالمند، پرستار
کودک، تزریقات و سرم، مراقبت زخم (driven by the live catalog, not hardcoded labels).
- **Filter pills** (the C1 row): **city** (تهران ▾ — required; the cascading province→city→district
picker reused from f3 geo — district optional, "leaving district empty searches the whole city" helper
copy), **date** (تاریخ ▾ — capture intent only; availability is **soft** at MVP and is *not* a hard
search filter — pass it through to booking, don't filter results on it), **gender** (جنسیت ▾ — خانم /
آقا / فرقی ندارد → `male`/`female`/omit). Make **gender a prominent, early, first-class control** with
one line of copy on why same-gender matters for bodily care.
- An optional **price-range** control (min/max → IRR strings) and the **results CTA** that mirrors the
wireframe's "مشاهده ۲۴ پرستار" — i.e. show the live result **count** and navigate to C2. (Wire the
count off a lightweight `useNurseSearch` head/`totalCount`, or navigate and show the count on C2 — your
call, but the number must be real, not hardcoded.)
- Hold filter state in a small **colocated controller** (a `useSearchFilters` hook or local reducer) —
*not* in a high context provider (it changes fast). The filter object is what becomes the query key.
### 3.3 C2 — Results screen (`نتایج جستجو`)
- **Header:** result **count** ("۲۴ پرستار") + **sort control** "مرتب‌سازی: امتیاز ▾" (rating is the only
MVP sort — render it as a control but it has one option; tag other sorts **(DEFERRED)**).
- **List** of **`NurseResultCard`** (§3.5) rendered from `useNurseSearch(filters)`, paginated (infinite
scroll or a "load more" — reuse the f0 paginated pattern). The customer **bottom tab nav** stays
visible (this is a shell screen).
- **States (all required):** **loading** = skeleton cards (not a spinner); **empty** = the "no nurses
match → relax your filters" state with concrete suggestions (remove the gender filter / widen to whole
city by clearing district / try a nearby city — lean on the white-space cities Mashhad/Isfahan/Shiraz);
**error** = retry; **populated** = rating-sorted cards. Tapping a card → C3.
- Changing a filter on C1 and returning re-queries with the new key; **reverting to a prior filter set
reuses the cached result** (no refetch) — this is an acceptance criterion (§5, §7).
### 3.4 C3 — Nurse profile screen (`پروفایل پرستار`)
From `useNurseProfile(nurseId)`:
- **Header:** avatar, name, **rating** (+ review count), and **badges** — ✓ تاییدشده (reuse f5 verified
badge) and **نظام پرستاری** (INO membership; render only when `inoMembership` is true).
- **Attribute chips:** specialties / experience (سالمندان، تزریقات، ۸ سال سابقه) from `attributeChips` /
`yearsExperience`.
- **Services & prices:** a list of **`ServicePriceRow`** (§3.5) — one row per offered variant
(`displayName` + price rendered via `formatIrrToToman` + the localized `price_unit` label, e.g.
"۲۸۰٬۰۰۰ تومان/ساعت", and a 12h night-shift variant). These are the bookable units.
- **Latest review snippet** (`latestReview`) — rating + masked author + truncated body; "no reviews yet"
empty state. (The full reviews tab is **(DEFERRED)** → [frontend-phase-13-b14](./frontend-phase-13-b14.md).)
- **Primary CTA: "درخواست رزرو"** — navigates to the booking request form (f7), **carrying the selected
nurse + variant + the filter intent** (especially `required_caregiver_gender` derived from the C1
gender filter, and the city/category). f7 owns the form; this phase only hands off the intent — pass it
via route params / a small handoff, do **not** build the request form here. Tag the form itself
**(DEFERRED → f7)**.
- States: loading skeleton, not-found (404 → "this nurse is no longer available"), error/retry.
### 3.5 Shared components (built once, reused by f7+)
At the shared level (`src/components/…`), composed from MUI/`App*` primitives (never re-implement a root
primitive), each with a **co-located `*.test.tsx`** and i18n in both locales:
- **`NurseResultCard`** — the C2 card: `Avatar` (photo), name, the reused **verified badge**, rating +
review count, distance chip (km, only when `distanceKm` present), and the **"from X تومان/ساعت"**
price line (via `formatIrrToToman` + localized unit). Pure/presentational, memoized, stable props so a
list of N cards doesn't re-render on unrelated state.
- **`ServicePriceRow`** — the C3 service line and a reusable price row: localized service name + the
money formatting + the `price_unit` label. Reused on C3 now and by the booking summary later.
> These two are the load-bearing reusable pieces. The category grid (f4), trust badges (f5), geo picker
> (f3), and status chip (f0) are **reused**, not rebuilt.
### 3.6 i18n
Fill the **`search`** namespace (seeded in f0) in **both** `messages/en.json` and `messages/fa.json`,
in sync, RTL-first: filter labels (شهر/تاریخ/جنسیت/خانم/آقا/فرقی ندارد), sort label, result-count
pluralization, every empty/error/loading copy, badge labels (تاییدشده/نظام پرستاری), price-unit labels
(ساعتی/per_hour … per_24h), and the "درخواست رزرو" CTA. **No display label is derived from an enum code**
— each `price_unit`/`gender`/sort code maps to an i18n key.
## 4. Mocks & seams in this phase
This is a **frontend** phase — it introduces **no backend seam**; it **consumes** the b7 contract.
- **Reuse the `services/{domain}` seam pattern from f0.** All data goes through `clientFetch` inside
`services/search/apis/`.
- **If b7 is not yet merged (or a needed shape is missing):** build a **mock `clientApi`** behind the
*same* `services/search` seam (real-shaped fixtures: a handful of verified nurses with ratings,
distances, prices, badges; one profile with services + a review) so C1/C2/C3 are fully demoable, **and**
(a) append the exact missing/mismatched shape to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(operating-rules §6 — you never edit backend files), and (b) record the mock in your frontend report so
it's swapped out cleanly when the real endpoint lands. Selection between mock and real `clientApi` is by
the seam (one import swap), never an `if (mock)` scattered through components.
No new entry is needed in the backend `mocks-registry.md` (that registry is for backend DI seams); the
client-side mock is recorded in your **frontend report** instead.
## 5. Critical rules you must not get wrong
- **Only verified + accepting nurses appear — and the UI must not have to enforce it.** The b7
`nurse_search_index` invariant guarantees every returned row is verified, not suspended, accepting, and
on an active variant. **Never** add client logic that *re-includes* hidden nurses, and never display an
unverified/paused nurse. If a result somehow lacks the verified flag, treat it as a data defect and file
it via `for-backend.md` — do not paper over it.
- **The filter object IS the query key — identical filters reuse cache, never refetch.** `queryKey =
searchKeys.results(filters)` must be a *stable, canonical* serialization (sorted keys, omitted optional
filters rather than `undefined`, IRR as strings). Changing a filter and **reverting** to a previous set
must hit the React Query cache with **zero** network calls (verify in Devtools — §7). Use
`keepPreviousData` so the list doesn't flash. This is the whole point of the phase's caching design.
- **Debounce input.** Free-text / quick-filter typing must **not** fire one request per keystroke —
debounce before it becomes part of the query key.
- **Same-gender is first-class and carried *before* booking.** The gender filter
(`male`/`female`/`any`) is a prominent, early control with explanatory copy; the chosen value is carried
into the booking handoff as `required_caregiver_gender` (f7) — surfaced *before* booking, never
discovered after. Never default or silently drop gender.
- **Geography semantics:** city is required; **district is optional and "empty district = whole city"** —
the picker copy must say so; don't send a bogus district. (Backend matches city-only + district rows;
the client just leaves `districtId` absent.)
- **Availability is soft at MVP — never a hard search filter.** The date pill captures intent for the
booking flow; it must not remove nurses from results. Tag availability-window filtering **(DEFERRED)**.
- **Money renders through the f0 util only — IRR Rials are integer strings, no floats, Toman is
display-only.** Format with `formatIrrToToman`; never parse IRR into a JS number for math, never compute
a "from" price client-side beyond picking the min the server sent.
- **Enums are codes; labels are i18n keys.** `price_unit`, `gender`, and sort never render their raw
code; each maps to a localized label in both `en.json`/`fa.json`.
- **RSC/client boundary + caching discipline:** prefetch C2/C3 from an RSC with `initialData` only if it
removes a round-trip; otherwise client-fetch. No `next/headers`/`next-intl/server` in client components.
- **Minimal re-renders:** `NurseResultCard` is presentational/memoized; keep fast-changing filter state
colocated (not in a high provider); use `select` to subscribe to slices where it helps.
- **MUI primitives stay MUI; the two new composites live shared** — not inline in a page.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/search` exists (`types`/`keys`/`apis`/`hooks`/`index`), copying the f0 pattern; types
derive from the b7 contract (or a mock behind the seam + a `for-backend.md` request if b7 isn't
ready).
- [ ] **C1, C2, C3** are built per the wireframe, RTL, with category grid (reused), city/gender/(optional
district)/price filters, rating sort, and the prominent same-gender control.
- [ ] C2 has **all four states** (loading skeletons / empty "relax filters" / error-retry / populated);
C3 has loading / not-found / error states.
- [ ] **Caching proven:** the filter object is the query key; reverting a filter reuses cache with no
network call; input is debounced; `keepPreviousData` set. (Demonstrable in Devtools — §7.)
- [ ] `NurseResultCard` + `ServicePriceRow` are **shared** components with co-located tests; the verified
badge (f5), category grid (f4), geo picker (f3) are reused, not rebuilt.
- [ ] Prices render via the f0 money util; every string is an i18n key in **both** locales, in sync; no
label derived from an enum code.
- [ ] "درخواست رزرو" hands off the selected nurse + variant + `required_caregiver_gender` + city/category
to the f7 route (form itself deferred to f7).
- [ ] `npm run check` green; `npm run test:ci` green (the two new shared components are covered);
`client/CLAUDE.md` *Project Structure* updated for the new `services/search`, the two shared
components, and the C1/C2/C3 routes.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (point `NEXT_PUBLIC_API_URL` at a b7-enabled server, or use the seam mock if b7 isn't
merged):
- **End-to-end discovery:** from Home, open **C1** → pick a category (e.g. مراقبت سالمند), set city
(تهران), set gender (خانم) → the results CTA shows a real count → tap it → **C2** lists **only
verified** nurses, rating-sorted, each card showing photo, name, ✓ تاییدشده badge, rating + review
count, distance, and "from X تومان/ساعت". Confirm no unverified/paused nurse ever appears.
- **Profile:** tap a card → **C3** shows avatar, rating, ✓ تاییدشده + نظام پرستاری badges, attribute
chips, the services-and-prices rows (correct Toman formatting + Persian unit labels), and the latest
review snippet; **"درخواست رزرو"** navigates to the f7 route carrying nurse + variant + gender intent.
- **Empty state:** search a white-space city/category/gender combo with no matches → the "no nurses match
→ relax your filters" state with concrete suggestions (clear district / drop gender / try Mashhad).
- **Caching (the headline check):** open React Query Devtools → apply filter set A (cache entry A) →
change to set B (entry B, one fetch) → **revert to A** → the list shows instantly with **zero new
network requests** (cache hit on key A). Type quickly in the search input → confirm **one** debounced
request, not one per keystroke.
- **i18n/RTL:** flip locale fa↔en → all C1/C2/C3 labels, badges, unit labels, and empty/error copy
translate and mirror correctly; dark mode still renders.
- **Gate:** `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project Structure** tree in [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md)
for the new `services/search`, the C1/C2/C3 routes/segments, and the two new shared components
(`NurseResultCard`, `ServicePriceRow`); note the "filter-object-as-query-key" caching pattern as a
reusable convention. Fix any doc drift you touch.
- **Contracts:** **consume** [`../../contracts/domains/search.md`](../../contracts/domains/search.md)
(b7) — derive `services/search/types.ts` from it; **do not** edit it. Any missing/ambiguous shape (e.g.
the nurse-profile services array, the `latestReview` shape, distance units, or the count head) goes to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
as an append — the backend delivers it in a later change; you never edit backend files.
- **Handoff & report:** append your summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`../../shared-working-context/reports/frontend-phase-6-report.md`](../../shared-working-context/reports/frontend-phase-6-report.md)
— what was built (C1/C2/C3 + `services/search` + the two shared components), **what is now testable and
exactly how** (the steps in §7), what (if anything) is **mocked behind the `services/search` seam** and
how f-next swaps it for the real b7 endpoint, the contract consumed + any `for-backend` requests filed,
and the follow-ups (the f7 booking handoff contract for the carried intent; the C3 reviews tab deferred
to f13).
- **Memory:** save a `project`-type memory note for the non-obvious decisions this phase locks in — the
**filter-object-as-query-key** caching contract, the verified-only invariant the UI relies on (so a
future agent doesn't add a client-side verification re-filter), and the **`required_caregiver_gender`
carried-before-booking** handoff to f7 — with a one-line pointer added to `MEMORY.md`.
+337
View File
@@ -0,0 +1,337 @@
# Frontend Phase 7 — Booking request flow (customer request + nurse inbox)
> **Mission:** turn a nurse profile into a *sent request* and close the request loop on both sides. The
> customer fills the **request form** (C4) — patient, service variant, address, date/time, and the
> first-class **caregiver-gender preference** — and lands on the **awaiting-acceptance** screen (C5) with
> a live countdown to the nurse's response deadline and a 3-step status tracker. The nurse opens an
> **incoming-requests inbox**, sees a request showing *only* the customer's notes (two-stage clinical
> disclosure), and accepts or rejects it. On accept, the customer flips to a 30-minute **payment-deadline**
> countdown that hands off to checkout (f9). This is the money-free request phase — no payment, no booking
> row yet — and it is where the platform's trust contract (same-gender match, deadlines, terminal states)
> becomes visible to both actors.
>
> **Track:** frontend · **Depends on:** [frontend-phase-6-b7](./frontend-phase-6-b7.md) (discovery: search/results/nurse profile) · [frontend-phase-3-b4](./frontend-phase-3-b4.md) (addresses & map picker) · backend **b8** contract ([booking-requests.md](../../contracts/domains/booking-requests.md)) · **Unlocks:** [frontend-phase-8-b9](./frontend-phase-8-b9.md) (booking detail · sessions · EVV)
> **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 hinge of the customer journey: discovery is done (f6), the customer is looking at a nurse
profile (C3) and taps **درخواست رزرو**. This phase builds the *request phase* of the booking lifecycle —
the deliberately money-free intent that lives in `booking_requests` and **only** becomes a `bookings` row
later, after the nurse accepts *and* payment captures (f9 / b9). Nothing here touches money or creates a
booking. The product framing: a family requests a specific nurse for a specific patient at a specific
address and time, the nurse retains accept/reject autonomy (a deliberate worker-classification stance),
and both sides see frozen deadlines so the engagement can't hang forever.
**What already exists (do not rebuild):**
- **f0 foundations** ([frontend-phase-0](./frontend-phase-0.md)): the three actor shells (customer
mobile + 5-tab bottom nav, nurse shell, admin), the `services/{domain}` + TanStack Query caching
pattern (template = `src/services/auth/*`: `types.ts` / `keys.ts` / `apis/clientApi.ts` /
`hooks/use*.ts` / `index.ts`), `clientFetch`/`serverFetch` + `ApiError` (`@/lib/api`), the
contracts→types convention, the money/Shamsi format utils in `src/utils/`, and the shared composites
(stepper/progress header, status chip, OTP/phone inputs). **Reuse these — do not re-create the
pattern.**
- **f3 addresses** ([frontend-phase-3-b4](./frontend-phase-3-b4.md)): the customer **address book**,
the **map picker**, and the cascading province/city/district selectors, all behind
`services/addresses` (or the f3 domain name). The request form **reuses** the address picker/list — it
does not build a new one. Patient `customer_addresses` already carry coordinates from f3's geocode.
- **f2 onboarding** ([frontend-phase-2-b3](./frontend-phase-2-b3.md)): the **patient** list/CRUD behind
`services/patients`. The request form's patient selector **reads** that list; it does not add a new
patient-creation path (link out to the f2 "add patient" flow for the empty case).
- **f4 catalog** ([frontend-phase-4-b5](./frontend-phase-4-b5.md)): a nurse's **service variants**
(`nurse_service_variants` — name, price unit `per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`,
price). The service-type selector **reads** the chosen nurse's published variants.
- **f6 discovery** ([frontend-phase-6-b7](./frontend-phase-6-b7.md)): search (C1), results (C2), and the
**nurse profile (C3)** with its **درخواست رزرو** CTA. This phase is the destination of that CTA — wire
the navigation from C3 into the request form, passing the `nurse_id` (and optionally a pre-selected
variant).
> **Money/booking note:** there is **no payment and no `bookings` row** in this phase. The "pay & confirm"
> step (C6 summary, escrow notice, card/BNPL) is **(DEFERRED → [f9](./frontend-phase-9-b10.md))**. Booking
> detail, sessions, and EVV are **(DEFERRED → [f8](./frontend-phase-8-b9.md))**. Build only up to the two
> countdowns (response deadline, then payment deadline) and the hand-off CTA into checkout.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md).
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) in full — the RSC/client boundary, layouts (never above
`[locale]`), i18n, theme/tokens, cookies, the `services/{domain}` fetch pattern, anti-patterns. Mirror
the **`auth`** service exactly when you create `services/bookingRequests`.
- **Invoke the `frontend-designer` skill** — mandatory for all visual work in this phase (C4 form, C5
awaiting screen + tracker, the nurse inbox list + detail). It is the brand/design contract: palette
(teal `#1d4a40`, terracotta `#d98c6a`, cream), tokens, typography, the `App*` library, the layout
shells, and the hard RTL/dark-mode rules. Do not hand-pick colours in `sx`.
- [`product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline. Study
**C3 → C4 → C5** and the nurse "نمای پرستار" framing. The screens this phase implements:
- **C4 · فرم درخواست** — patient selector (dropdown), service-type selector, address (map block, منزل),
date + time pickers, nurse-gender preference (**خانم / آقا / فرقی ندارد**). CTA: **ارسال درخواست**.
- **C5 · در انتظار تایید پرستار** — ⏳ status, "درخواست برای پرستار ارسال شد"; a summary card (nurse +
time); the 3-step tracker **درخواست ثبت شد → در انتظار تایید پرستار → پرداخت و تایید نهایی**; a
countdown to `nurse_response_deadline_at`. No CTA in the waiting state; on accept it shows the
payment-deadline countdown + a "continue to payment" CTA.
- **Nurse request inbox** — there is no dedicated wireframe panel, so design it consistently with the
nurse shell: a list of pending requests each with a per-request countdown, and a request-detail
showing **only** `customer_notes`, with accept / reject (reason) actions.
- [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
— the request→accept→pay→confirm lifecycle, the frozen deadlines, the two-table split, same-gender
matching, and the two-stage clinical-disclosure rule. **These are decisions, not guesses — read them.**
- **The contract you consume:** [`../../contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md)
(from **backend-phase-8**) — the exact request/response shapes, routes, status codes, and enums.
Plus the shared conventions [`api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (envelope, snake_case routes,
pagination, enums-as-codes, UTC timestamps + Shamsi display, IRR-as-string, gender as load-bearing).
- The latest backend handoff `dev/shared-working-context/backend/handoff/after-backend-phase-8.md` — what
b8 shipped, which endpoints are live, and what (if anything) is still mocked server-side.
- The f6/f3 frontend reports in `dev/shared-working-context/reports/` — to reuse the patient/address/
variant query keys and the discovery navigation rather than re-fetching or re-deriving them.
## 3. Scope — build this
### 3.1 The `services/bookingRequests` domain (consume b8)
Create `src/services/bookingRequests/` by copying the `auth` template structure exactly:
- **`types.ts`** — string-literal union types mirroring the **booking-requests.md** contract (do **not**
guess shapes). At minimum:
- `RequiredCaregiverGender = 'male' | 'female' | 'any'` (the wire codes behind خانم/آقا/فرقی ندارد).
- `BookingRequestStatus = 'pending_nurse_response' | 'accepted_awaiting_payment' | 'rejected_by_nurse'
| 'expired_no_response' | 'payment_deadline_expired' | 'converted'`.
- `BookingRequestDto` (id, `nurse_id`, `patient_id`, `nurse_service_variant_id`, `customer_address_id`,
`scheduled_start_at` (UTC), `required_caregiver_gender`, `customer_notes`, `status`,
`nurse_response_deadline_at` (UTC), `payment_deadline_at` (UTC, nullable until accept),
`nurse_rejection_reason` (nullable), plus the display fields the contract returns — nurse name/avatar,
variant name + price-unit, patient name, address label). Money values (variant price) are **IRR digit
strings**, parsed via the f0 money util — never floats.
- `CreateBookingRequestPayload`, `RejectBookingRequestPayload` (reason).
- **`keys.ts`** — a query-key factory: `bookingRequestKeys.lists(role, statusFilter)`,
`bookingRequestKeys.detail(id)`, and the **nurse inbox** list key `bookingRequestKeys.nurseInbox(filter)`.
- **`apis/clientApi.ts`** — wrap `clientFetch` for each endpoint the contract defines (names from
**booking-requests.md** — expected, snake_cased per api-conventions):
- `POST .../booking_requests/create_booking_request` → create (customer).
- `GET .../booking_requests/list_booking_requests` → customer's requests, paginated, `status` filter.
- `GET .../booking_requests/get_booking_request` → one request (polled on C5).
- `GET .../booking_requests/list_nurse_requests` (or the contract's nurse-inbox route) → nurse's
incoming requests, paginated, default `status=pending_nurse_response`.
- `POST .../booking_requests/accept_booking_request` → nurse accept.
- `POST .../booking_requests/reject_booking_request` → nurse reject (with `nurse_rejection_reason`).
- **`hooks/` — one hook per file:**
- `useCreateBookingRequest.ts` (`useMutation`) — on success, navigate to C5 with the new id and
`setQueryData`/invalidate the customer list.
- `useBookingRequest.ts` (`useQuery`) — the C5 detail; **polls** while status is non-terminal
(`refetchInterval` ~ every 1530s while `pending_nurse_response` / `accepted_awaiting_payment`, and
**stops** on a terminal/`converted` status via a `select`/enabled guard) so the customer sees the
accept/reject/expire transition without a refresh.
- `useNurseRequestInbox.ts` (`useQuery`) — the nurse list, with light polling for new requests.
- `useAcceptBookingRequest.ts` / `useRejectBookingRequest.ts` (`useMutation`) — **invalidate the nurse
inbox list and the request detail** on success so the request leaves the pending list immediately.
- **`index.ts`** — barrel.
> If any shape the screens need is **missing** from booking-requests.md (e.g. the contract doesn't return
> the nurse's display name on the request DTO, or omits the price-unit), **append the gap** to
> `dev/shared-working-context/frontend/requests/for-backend.md` and **mock that field behind the
> `services/bookingRequests` clientApi seam** meanwhile (operating-rules §6). Record the mock in your
> report so it swaps out cleanly when b8 fills the gap. Never edit backend files.
### 3.2 C4 — the request form (customer)
A page under the customer shell (e.g. `(private-routes)/<customer-segment>/booking-requests/new`,
reachable from the C3 **درخواست رزرو** CTA with the `nurse_id`). RTL-first, mobile. Fields:
- **Patient selector** — a dropdown reading `services/patients` (f2). Empty state → a CTA linking to the
f2 "add patient" flow (don't inline patient creation here). The selected `patient_id` is sent.
- **Service-type selector** — reads the chosen nurse's `nurse_service_variants` (f4). Each option shows
the variant name + formatted price + price-unit label (i18n key off the `per_*` code, **not** a
hardcoded label). Sends `nurse_service_variant_id`.
- **Address block** — **reuse the f3 address picker / map block** (منزل/home), selecting a
`customer_address_id` from the address book (with the map preview). Do not rebuild the picker.
- **Date + time pickers** — produce a single UTC `scheduled_start_at` on the wire; **display** Shamsi via
the f0 date util. (Recurring/multi-session scheduling UI is **(DEFERRED → later)** — one start time here;
`session_count` is a server/booking concern.)
- **Nurse-gender preference** — a 3-option segmented control: **خانم (female) / آقا (male) / فرقی ندارد
(any)** → `required_caregiver_gender`. This is a **first-class field**, never a hidden default; if the
nurse's profile already fixes a gender, still send the explicit code the customer chose.
- **Request-stage notes** — a free-text field mapped to `customer_notes`. Copy must make clear this is the
*only* thing the nurse sees before accepting (it is **not** the clinical care record, which is
post-confirmation and **(DEFERRED → [f8](./frontend-phase-8-b9.md))**).
- **CTA: ارسال درخواست** — fires `useCreateBookingRequest`; loading state while the server computes/freezes
the deadline; on success navigate to C5. Surface domain `400`s (e.g. tenancy: patient/address not owned;
same-gender mismatch; variant not bookable) as field/form errors — but do **not** toast `401/403/5xx`
(the fetch layer already does).
Validate client-side at the boundary (all required fields chosen, future date) before enabling the CTA;
the server re-validates and is authoritative.
### 3.3 C5 — awaiting nurse acceptance + status tracker (customer)
A page keyed by the request id (e.g. `.../booking-requests/[id]`). Uses `useBookingRequest` (polling).
- **Header:** ⏳ "درخواست برای پرستار ارسال شد".
- **Summary card** — nurse (avatar + name), patient, service variant + price, address label, requested
time (Shamsi). Compose this as a **shared composite** (`src/components/...`) so the booking-detail screen
in f8 can reuse it (a "BookingRequestSummaryCard"); co-locate a `*.test.tsx`.
- **3-step status tracker** — reuse the **f0 stepper/progress header** composite:
1. **درخواست ثبت شد** (done as soon as the request exists),
2. **در انتظار تایید پرستار** (active while `pending_nurse_response`),
3. **پرداخت و تایید نهایی** (future; becomes active on `accepted_awaiting_payment`).
- **Countdown** — a `CountdownTimer` shared composite (`src/components/...`, co-located test) ticking down
to `nurse_response_deadline_at` (computed from the **server-supplied UTC instant** vs `Date.now()` — the
client never computes the deadline, only renders it). It is a pure presentational countdown; when it hits
zero, the UI shows "in expectation of server confirmation" and the poll resolves the real terminal
status. Use a single interval, cleaned up on unmount; do **not** re-render the whole page each tick
(isolate the ticking state in the timer component).
- **State transitions (driven by polled `status`):**
- `accepted_awaiting_payment` → swap step 2 to done, step 3 active; show **"✓ پرستار تایید کرد"**, a
prominent **30-minute payment countdown** to `payment_deadline_at`, and a CTA **"ادامه پرداخت ←"** that
navigates to checkout (**the checkout screen itself is [f9](./frontend-phase-9-b10.md) — wire the
route, stub the destination if f9 isn't merged**).
- `rejected_by_nurse` → terminal state card with the `nurse_rejection_reason` and a "request another
nurse" CTA back into discovery (f6).
- `expired_no_response` → terminal "no response in time" card + re-request CTA.
- `payment_deadline_expired` → terminal "payment window lapsed" card + re-request CTA.
- `converted` → the request became a booking → route to booking detail (**[f8](./frontend-phase-8-b9.md)**;
stub if not merged).
### 3.4 Nurse request inbox + detail (nurse)
Under the **nurse shell** (the wireframe's "نمای پرستار"), e.g. `(private-routes)/<nurse-segment>/requests`.
- **Inbox list** (`useNurseRequestInbox`) — pending requests, each row a card: patient first name/age,
service variant, requested time (Shamsi), the **required-caregiver-gender** chip, and a **per-request
countdown** to that request's `nurse_response_deadline_at`. Empty state: "درخواست جدیدی ندارید". Paginated
(page/page_size per api-conventions). Light polling so new requests appear.
- **Request detail** — shows the request summary **and only `customer_notes`** as the clinical context.
**It must never render `booking_care_instructions` or any encrypted clinical field** — those don't exist
pre-accept and are out of this contract; rendering them would break two-stage disclosure. Actions:
- **Accept** (`useAcceptBookingRequest`) — on success the request moves to `accepted_awaiting_payment`,
a `payment_deadline_at` is set server-side, and the customer's C5 (via its poll) starts the 30-min
payment countdown. Invalidate the inbox list + this detail.
- **Reject** (`useRejectBookingRequest`) — a small reason dialog capturing `nurse_rejection_reason`;
on success the request leaves the inbox. Invalidate the inbox list + detail.
- Both actions are disabled / show a terminal banner if the request already expired (the server returns
`409`/`400` for a stale accept/reject — surface it gracefully, then refetch).
### 3.5 i18n + tokens
Add a **`booking`** (and/or `bookingRequests`) namespace to **both** `messages/en.json` and
`messages/fa.json`, in sync, RTL-first. Every visible string is a key — the gender labels (خانم/آقا/فرقی
ندارد), the three tracker steps, the price-unit labels (off the `per_*` codes), all terminal-state copy,
countdown labels, and the empty states. Colours from `tokens.css` only; financial/terracotta accent (e.g.
the payment-deadline countdown) uses the brand terracotta token, not a literal.
## 4. Mocks & seams in this phase
This phase **introduces no new external seam** — booking requests carry **no money** and call no third
party. It only consumes the b8 HTTP contract.
- **Backend-not-ready / contract-gap fallback:** if `after-backend-phase-8.md` shows b8 isn't merged, or
booking-requests.md is missing a shape, build a **mock `clientApi`** behind the `services/bookingRequests`
seam (same function signatures the real one will have), driving a small in-memory state machine so the
whole flow is demoable: create → (timer or manual) accept/reject/expire. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) and your report; swapping
to the real `clientApi` must be a one-file change. File any contract gap in
`dev/shared-working-context/frontend/requests/for-backend.md` (never edit backend files).
- **Reused seams:** the patient list (`services/patients`, f2), the address picker (`services/addresses`,
f3), and the nurse variants (`services/catalog` or f4's name) — **reuse**, do not redefine.
## 5. Critical rules you must not get wrong
- **Two-stage clinical disclosure.** The nurse sees **only** `customer_notes` before accepting — never any
encrypted `booking_care_instructions` or other clinical detail. That data isn't in this contract and must
not appear anywhere in the inbox/detail UI. Full care instructions are post-confirmation and belong to
[f8](./frontend-phase-8-b9.md).
- **`required_caregiver_gender` is a first-class field.** Always sent explicitly (`male`/`female`/`any`),
never defaulted or dropped — it drives same-gender bodily-care matching. The server re-validates; surface
a mismatch `400` clearly.
- **No money, no booking row here.** This is the request phase. Do not render a price-breakdown/escrow/pay
step (that's C6 / [f9](./frontend-phase-9-b10.md)) and do not assume a booking exists until `converted`.
- **Deadlines come from the server, frozen.** Render countdowns from the server-supplied UTC instants
(`nurse_response_deadline_at`, `payment_deadline_at`) against `Date.now()`; the client **never computes
or recomputes** a deadline. Show the **response** countdown pre-accept and the **30-minute payment**
countdown post-accept; show the correct **terminal** state (`rejected_by_nurse` / `expired_no_response`
/ `payment_deadline_expired`) when the poll resolves it.
- **Invalidate on accept/reject.** A nurse action must invalidate the inbox list + the request detail so
the request leaves the pending list immediately and the customer's polled C5 reflects it — never leave
stale cache. Equally, don't over-poll: stop polling once a terminal/`converted` status is reached.
- **Minimal re-renders.** The ticking countdown state is isolated in the timer component (not lifted to a
page that would re-render the form/summary every second). Stable query keys, `select` for slices.
- **RTL + both locales + tokens + MUI primitives.** `fa` default & RTL; every string in `en.json` and
`fa.json` in sync; colours from `tokens.css`; MUI v9 primitives/`App*` reused, shared composites
(summary card, countdown) at the shared level with co-located tests — never re-implement a root primitive
and never bury a reusable composite in a page.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/bookingRequests` exists (types/keys/apis/hooks/index), typed **from** booking-requests.md
(gaps filed + mocked behind the seam, not guessed).
- [ ] **C4 request form** submits a valid request (patient + variant + address + date/time + gender +
notes) and navigates to C5; client-side validation gates the CTA; domain `400`s surface as
form/field errors.
- [ ] **C5 awaiting screen** shows the summary card, the 3-step tracker, and a live countdown to
`nurse_response_deadline_at`; it transitions (via poll) through accept (→ 30-min payment countdown +
checkout CTA), reject, and both expiry terminal states.
- [ ] **Nurse inbox** lists pending requests (with per-request countdown + gender chip), the detail shows
**only `customer_notes`**, and accept/reject work and **invalidate the inbox + detail**.
- [ ] Polling stops on terminal/`converted` status; no needless refetch; the ticking countdown doesn't
re-render the whole page.
- [ ] `booking`/`bookingRequests` i18n keys added to **both** locales in sync; colours from tokens; RTL
verified.
- [ ] `npm run check` green; `npm run test:ci` green for the new shared composites (summary card,
countdown timer) and any touched shared component.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new route segments + the `services/bookingRequests`
domain and any new shared components.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (point `NEXT_PUBLIC_API_URL` at a b8 server, or use the mock `clientApi` seam if b8
isn't merged). Then:
1. **Submit a request.** From a nurse profile (C3) tap **درخواست رزرو** → on C4 pick a patient, a service
variant, an address (map block), a future date/time, and a gender preference (خانم/آقا/فرقی ندارد),
add a note → **ارسال درخواست**. *Expected:* land on **C5** showing the summary card, the 3-step tracker
(step 2 active), and a countdown ticking down to the nurse's response deadline.
2. **Nurse sees it.** In the nurse shell open **requests** (inbox). *Expected:* the new request appears
with the patient/variant/time, the **required-gender chip**, and a per-request countdown; opening the
detail shows **only the `customer_notes`** — no clinical/care fields anywhere.
3. **Nurse accepts.** Tap accept. *Expected:* the request leaves the pending inbox immediately
(cache invalidated); on the customer's **C5** (without a manual refresh, via the poll) step 2 flips to
done, step 3 activates, a **✓ پرستار تایید کرد** badge appears, the **30-minute payment countdown**
starts, and the **ادامه پرداخت ←** CTA appears (routing toward checkout/f9).
4. **Reject path.** On a different request, nurse rejects with a reason. *Expected:* customer's C5 shows the
terminal **rejected** card with the reason + a re-request CTA back into discovery.
5. **Expiry paths.** Let (or simulate via the mock) the response deadline lapse → C5 shows
**expired_no_response**; let the payment window lapse after accept → C5 shows **payment_deadline_expired**.
Both are terminal with a re-request CTA.
6. **Quality:** locale switch flips `dir` + strings on every screen; dark mode holds; `npm run check` and
`npm run test:ci` pass; React Query Devtools shows the inbox/detail invalidating on accept/reject and the
poll stopping at a terminal status.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* (the new customer + nurse route segments, the
`services/bookingRequests` domain, the new shared composites — `BookingRequestSummaryCard`,
`CountdownTimer`). Fix any doc drift you touch. If you discover/decide a request-flow rule the
`product/` docs don't capture (e.g. the exact tracker wording, the re-request UX), note it in
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
(don't invent rules — record decisions) and regenerate the HTML view per `product/CLAUDE.md` if you
edited Markdown.
- **Contracts:** this phase **consumes** [`../../contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md)
— derive `services/bookingRequests/types.ts` from it; produce no contract. Append any missing/ambiguous
shape to `dev/shared-working-context/frontend/requests/for-backend.md`.
- **Handoff & report:** append your phase summary to
`dev/shared-working-context/frontend/STATUS.md`; write
`dev/shared-working-context/reports/frontend-phase-7-report.md` covering *what was built* (C4/C5/nurse
inbox + the domain service), *what is now testable and exactly how* (the steps in §7), *what is mocked*
(any contract-gap field behind the `services/bookingRequests` seam + how it swaps to real), *contracts
consumed* (booking-requests.md) and any gaps filed, and *follow-ups* for f8/f9 (the `converted` → booking
detail handoff, the payment CTA → checkout handoff). Update
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) only if you added a
client-side mock seam.
- **Memory:** save a `project` memory note for the non-obvious decisions here — the dual-countdown design
(server-frozen deadlines, client renders only), the polling-until-terminal pattern for request status,
and the two-stage-disclosure boundary in the nurse inbox — with a one-line pointer in `MEMORY.md`.
+343
View File
@@ -0,0 +1,343 @@
# Frontend Phase 8 — Booking detail, sessions & nurse EVV
> **Mission:** turn an accepted-and-paid request into a living engagement on screen. Build the
> **booking-detail** view both actors share — a server-truth **status timeline**
> (`pending_payment → confirmed → in_progress → completed → disputed/closed/cancelled`) and the
> per-visit **session schedule** — plus the nurse's day-one operational surface: **EVV check-in/out**
> (capture GPS, post it, show the "ورود ثبت شد · موقعیت تایید شد (EVV)" banner) and the **care-instructions**
> read that is unlocked **only post-confirmation to the assigned nurse**. This is the screen where the
> trust promise (proof of service, two-stage clinical disclosure, escrow-then-release) becomes visible,
> and it is the launch pad for checkout (f9) and reviews/records (f13).
>
> **Track:** frontend · **Depends on:** [`frontend-phase-7-b8.md`](./frontend-phase-7-b8.md) (request flow + booking lists/status tracker) and the **b9** contract ([bookings-evv.md](../../contracts/domains/bookings-evv.md)) · **Unlocks:** checkout & payment (`frontend-phase-9-b10.md`), reviews & patient records (`frontend-phase-13-b14.md`)
> **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 **f8** of the frontend chain. The customer can now search, open a nurse, send a request, and
watch it move `pending_nurse_response → accepted_awaiting_payment` (built in f7); the nurse has an inbox
to accept/reject. The backend's **b9** phase has just landed the *booking* phase: a request that was
accepted **and** paid converts to a `bookings` row with `booking_sessions`, encrypted
`booking_care_instructions`, and per-session `visit_verifications` for EVV. This phase puts a face on all
of that. It is the hinge between "I asked for a nurse" and "a nurse is actually delivering care" — the
booking detail is where the customer watches the engagement progress and the nurse does their job.
Two product truths drive every decision here, and both are non-negotiable:
- **Two-stage clinical disclosure** (Principle 6): full care instructions are encrypted and revealed
**only after confirmation, only to the assigned nurse + admin**. The UI must *gate* this, never leak it.
- **EVV is proof of service** ([06-evv-and-service-delivery.md](../../../product/business/06-evv-and-service-delivery.md)):
the nurse clocks in/out **per session** with GPS; a location mismatch is **advisory** (a banner, never a
block); EVV check-out is what eventually makes a session payout-eligible (after the dispute window — that
gating lives server-side; we just render its result).
**What already exists (do not rebuild) — link and extend, never re-create:**
- **f0 foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the three actor app shells +
route groups, the `services/{domain}` + TanStack Query caching pattern (the `auth` service is the
template — `types.ts`/`keys.ts`/`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts`), the money/format util
(`formatIrrToToman`, Shamsi date display), and shared composites (status chip, stepper/progress header,
cards). **Reuse the status-chip and the stepper/progress-header composites here** — do not build new ones.
- **f7 booking-request flow** ([`frontend-phase-7-b8.md`](./frontend-phase-7-b8.md)): the
`services/bookings` domain seam already exists with the **request** half (`CreateBookingRequest`,
request list/detail, the C5 awaiting-acceptance status tracker, the nurse request inbox). **This phase
extends the same `services/bookings` domain** with the booking/session/EVV half — same `keys.ts`
factory, same `clientApi.ts`, same hook conventions. Do **not** create a parallel domain.
- **The app chrome**: the customer mobile shell with the 5-tab bottom nav (the **رزروها/Bookings** tab is
the customer entry to booking detail), the nurse shell (where EVV and the day's schedule live), the
RSC root layout, themes/tokens, `clientFetch`/`serverFetch`, the cookie manager, `AuthContext` (roles),
the toast bridge. None of this is rebuilt.
> **Reminder:** backend phases own the contracts. If a shape you need (e.g. a session's `payout_eligible_at`,
> the care-instructions decrypt response, the GPS-match result) is missing from
> [bookings-evv.md](../../contracts/domains/bookings-evv.md), you **append a request** to
> `dev/shared-working-context/frontend/requests/for-backend.md` and **mock behind the `services/bookings`
> seam** meanwhile (operating-rules §6). You never edit backend files.
## 2. Required reading (do this first)
- [`../_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 (RSC boundary, `clientFetch`/services-only, Query caching + invalidation, minimal
re-renders, MUI primitives reused, i18n both locales, tokens, RTL).
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) in full — the engineering contract (layouts,
RSC/client boundary, i18n, theme, cookies, fetch services, anti-patterns). Trust the code over any stale
doc note (the f0 audit found small drift); fix the doc if you touch that area.
- **Invoke the `frontend-designer` skill** — this phase is heavy on visual surfaces (status timeline, a
session list with per-session state, the EVV check-in/out screen, the EVV success banner, the care
instructions card). **All visual work goes through the skill** (palette, tokens, typography, the `App*`
library, RTL rules). Brand: teal `#1d4a40`, terracotta `#d98c6a` for financial/EVV affordances — the
wireframe gives the nurse view a **terracotta border** ("نمای پرستار").
- [`../../../product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
— the EVV business rules: per-session GPS check-in/out, advisory address-match tolerance
(`evv_location_tolerance_meters`), no-show alerting (server-side), payout gated on EVV completion **and**
closed dispute window. **Read this before building the EVV screen** — the rules are decisions, not guesses.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline.
The screens this phase implements:
- **Booking detail + status timeline** — the both-roles view (C5 tracker grows into a full timeline;
states `pending_payment/confirmed/in_progress/completed/disputed/cancelled`), session list with
per-session schedule/status, money summary (gross/commission/tax labels).
- **E3 (top half)** — the **nurse EVV** surface: header "ویزیت امروز", the **check-in banner**
"ورود ثبت شد ۰۹:۰۲ · موقعیت تایید شد (EVV)", today's-task checklist awareness, and the **"ثبت خروج
(EVV)"** check-out action. *(The bottom half of E3 — the free-text visit-note authoring — and the full
E2 patient-record viewer are **(DEFERRED)** to [`frontend-phase-13-b14.md`](./frontend-phase-13-b14.md).)*
- **Care-instructions (post-confirm)** — the decrypted clinical/logistical context the assigned nurse
sees only after confirmation; in the customer flow this is the "Care details" surface authored after pay.
- [bookings-evv.md](../../contracts/domains/bookings-evv.md) — **the contract you consume.** It is the
source of truth for: the booking-detail response (status enum, the three money amounts, `session_count`,
`dispute_window_ends_at`), the session shape (`booking_session_id`, schedule, per-session `status`,
`visit_payout_amount`, `payout_eligible_at`), the EVV check-in/out request+response
(`check_in_address_match` advisory result, timestamps, status), and the gated care-instructions read.
**Do not guess these shapes** — derive `types.ts` from this doc; file any gap to the for-backend request
log and mock meanwhile.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) +
[`api-conventions.md`](../../contracts/conventions/api-conventions.md) — the envelope, **IRR-as-string +
Toman display**, enums-as-codes, **UTC timestamps + Shamsi display**. The session schedule and EVV
timestamps are UTC; render them Shamsi. Money renders through the f0 `formatIrrToToman` util.
- The existing `services/auth/*` and the **f7 `services/bookings/*`** — copy their exact structure; you are
extending the latter, not starting fresh.
## 3. Scope — build this
Everything below lives under the **customer mobile shell** (booking detail, opened from the رزروها tab) and
the **nurse shell** (booking detail + the EVV/today surface). All server access goes through the
**`services/bookings`** domain (extended from f7). All money via `formatIrrToToman`; all timestamps Shamsi.
### 3.1 `services/bookings` — extend the domain (data layer)
Extend, do not replace, the f7 `services/bookings` files:
- **`types.ts`** — add, derived from [bookings-evv.md](../../contracts/domains/bookings-evv.md):
`BookingDetailDto` (id, status enum `pending_payment|confirmed|in_progress|completed|disputed|closed|cancelled`,
`gross_price_irr`/`balinyaar_commission_irr`/`nurse_payout_amount` as **IRR strings**, `session_count`,
`dispute_window_ends_at`, nurse/patient/address snapshot fields), `BookingSessionDto`
(`booking_session_id`, scheduled start/end, per-session `status` `scheduled|in_progress|completed|missed|cancelled`,
`visit_payout_amount`, `payout_eligible_at`), `VisitVerificationDto` (check-in/out timestamps,
`check_in_address_match` advisory boolean/score, `status`), `CareInstructionsDto` (decrypted conditions /
meds / allergies / instructions / emergency contact — **only present in the gated response**), and the
EVV command DTOs `CheckInVisitInput`/`CheckOutVisitInput` (`booking_session_id`, `latitude`, `longitude`,
client `captured_at`). Status enums are **codes**, mapped to i18n labels in the UI — never display the raw code.
- **`keys.ts`** — add to the existing factory: `bookingDetail(id)`, `bookingSessions(bookingId)`,
`sessionEvv(sessionId)`, `careInstructions(bookingId)`. Keep the f7 request keys.
- **`apis/clientApi.ts`** — add the calls (names follow the contract routes; b9 owns the exact paths):
`getBookingDetail(id)`, `listBookingSessions(bookingId)`, `getCareInstructions(bookingId)` (the gated
read), `checkInVisit(input)`, `checkOutVisit(input)`, `getSessionEvv(sessionId)`. Each wraps
`clientFetch`**never raw `fetch`**. Add a `serverApi.ts` `getBookingDetail` **only if** you prefetch
the detail in an RSC (recommended — see 3.5).
- **Hooks (one per file)** under `hooks/`:
- `useBookingDetail(id)``useQuery` on `bookingDetail(id)`; deliberate `staleTime` (detail changes on
status transitions — keep modest, e.g. 30s, and **invalidate on EVV mutations** so the timeline reflects
server truth immediately).
- `useBookingSessions(bookingId)``useQuery` on `bookingSessions(bookingId)`.
- `useCareInstructions(bookingId, { enabled })``useQuery`, **`enabled` gated** by `status === 'confirmed'
|| beyond` **and** the viewer being the assigned nurse or admin (see 3.4). When disabled it never fires.
- `useCheckInVisit()` / `useCheckOutVisit()` — `useMutation`; on success **invalidate** `bookingDetail`,
`bookingSessions`, and `sessionEvv` (so the timeline, the session row, and the EVV banner all reflect
the new server state — no manual optimistic money/status math). Surface only domain-specific 4xx
messages (e.g. "no open check-in" on check-out); let the fetch layer handle 401/403/5xx toasts.
### 3.2 Booking detail + status timeline (both roles) — `app` route + components
- **Route:** a booking-detail page under the existing `(private-routes)` structure, reachable from the
customer **رزروها/Bookings** list (built in f7) and from the nurse booking list. Keep it role-aware via
`AuthContext` — same page, role-conditioned sections (the EVV actions and care-instructions card render
for the **assigned nurse**; the customer sees the read-only timeline + sessions + money summary).
- **Status timeline** — a shared composite `BookingStatusTimeline` (in `src/components/booking/`,
co-located test) that renders the canonical order `pending_payment → confirmed → in_progress →
completed`, with terminal branches `disputed/closed/cancelled` shown distinctly. **It reflects server
truth** (`BookingDetailDto.status`) — never advance a step client-side. Reuse the f0 stepper/progress
header primitive underneath; this is the grown-up version of the f7 C5 3-step tracker.
- **Session schedule list** — a `SessionList` composite rendering `BookingSessionDto[]`, each row a
`SessionCard` showing Shamsi schedule, a per-session **status chip** (reuse the f0 status chip — map
`scheduled/in_progress/completed/missed/cancelled` to labelled, tokenised colours) and, for the assigned
nurse, the EVV CTA state (see 3.3). **A single-visit booking still renders exactly one session row** —
do not special-case it away; the data always has ≥1 session.
- **Money summary** — a small `BookingMoneySummary` showing gross / Balinyaar commission (کارمزد) / and the
nurse-payout split, each via `formatIrrToToman`. (Tax/مالیات line + the escrow notice are the **checkout**
surface — **(DEFERRED)** to [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md); here just show the
confirmed split. If the detail response omits a line, label what you have and don't fabricate.)
- **States:** loading (skeleton), the per-status content (confirmed shows upcoming sessions; in_progress
highlights the active session; completed shows the dispute-window note from `dispute_window_ends_at`;
cancelled/disputed terminal copy), and an empty/not-found guard if the id isn't the viewer's booking.
### 3.3 Nurse EVV check-in / check-out (E3 top) — nurse shell
- **Today / session-aware surface** — within the nurse booking detail (and reachable from the nurse "today"
context), render the **per-session EVV control**. CTA state machine driven by session + EVV data:
`scheduled` → **"ثبت ورود (EVV)"** (check-in); `in_progress` (checked-in, no check-out) → **"ثبت خروج
(EVV)"** (check-out); `completed` → done (show elapsed duration); `missed` → missed state. Today's-task
awareness: show the session's task context (the **task checklist body is read-only here** — authoring is
f13).
- **GPS capture** — a small client util `captureGeoPosition()` behind a DI-style seam
(`src/services/bookings/evv/locationProvider.ts` exposing `ILocationProvider`) wrapping the browser
Geolocation API. On check-in/out: acquire position (loading spinner "در حال دریافت موقعیت…"), then call
`useCheckInVisit`/`useCheckOutVisit` with `{ booking_session_id, latitude, longitude, captured_at }`.
**Permission-denied / unavailable is not a hard stop** — the product rule says mismatch is advisory, so
allow the nurse to proceed (submit without coords or with a flagged value per the contract) and show the
advisory banner; never block the visit on GPS.
- **The EVV check-in banner** — on a successful check-in (and whenever an open check-in exists), render the
wireframe banner verbatim in spirit: **"ورود ثبت شد {{time}} · موقعیت تایید شد (EVV)"** when
`check_in_address_match` is true, and the **advisory** variant **"ورود ثبت شد {{time}} · موقعیت خارج از
محدوده (در حال بررسی)"** when it is false — a tokenised warning banner, **not** an error, and it does
**not** block check-out. Build it as a shared composite `EvvStatusBanner` (co-located test) since both the
session card and the day surface use it. Time renders Shamsi/clock from the server `checked_in_at`.
- **Check-out** — requires an open check-in; if none, show the domain message (don't toast the fetch layer's
generic). On success the session row flips to `completed` (via the invalidations in 3.1) and shows elapsed
duration. **Do not compute or display payout-eligibility client-side** — `payout_eligible_at` and the
dispute-window gate are server truth; render whatever the detail/session response gives you.
### 3.4 Care-instructions read (two-stage disclosure) — gated card
- A shared `CareInstructionsCard` (in `src/components/booking/`, co-located test) that renders the decrypted
`CareInstructionsDto` (conditions / medications / allergies / instructions / emergency contact).
- **Gate the UI, hard:** the card and its query (`useCareInstructions`) render/fire **only when** the booking
is `confirmed` or beyond **and** the current user is the **assigned nurse** (or admin). For anyone else —
the customer, an unassigned nurse, a pre-confirmation viewer — the card is **not rendered and the query
never fires**. This is the client mirror of the server's gated `GetCareInstructions`; the server is the
real boundary, but **the UI must not even request** instructions it has no right to (a 403 from the server
is a defect path, not the design). Show a neutral "visible to your assigned nurse and support only"
affordance to the customer instead.
- The customer-side authoring of care details (the post-confirmation "Care details" form,
`SubmitCareInstructions`) is the **write** path — if b9's contract exposes it, build a minimal read-back
here; the full encrypted authoring form is owned by the booking write flow and may be **(DEFERRED)** to
f13 if the contract doesn't surface it in b9. Read the contract; if absent, mock the read and log the gap.
### 3.5 RSC prefetch (remove a client round-trip)
Where the booking detail is the first paint, prefetch `getBookingDetail(id)` in the RSC and hand it to the
client tree via `initialData` / a hydrated query (the f0 pattern). Keep the EVV mutations + care-instructions
on the client. Respect the RSC/client boundary (no `@/lib/cookies/client` in an RSC; no
`next-intl/server`/`next/headers` in a client component).
### 3.6 i18n + tokens
- Add the user-visible strings under the existing **`booking`** namespace (and reuse `common`/`nav`) in
**both** `messages/en.json` and `messages/fa.json`, in sync — status labels, session statuses, the EVV
banner strings (in-range + advisory out-of-range), check-in/out CTAs, GPS-acquiring text, the
care-instructions section labels + the customer "visible to your nurse only" copy, the dispute-window note.
`fa` is default + RTL — design RTL-first and verify mirroring (the timeline and session rows must read
right-to-left correctly).
- All colours from `tokens.css` (`var(--bal-…)`); the EVV/financial terracotta affordance via the brand
token, the advisory banner via the warning token, status chips via the status-chip tokens. No hardcoded
colours in `sx`. MUI **v9** API only; reuse `APP_THEME_LTR/RTL`.
**Out of scope (DEFERRED — pointers, do not build here):**
- Full **patient-record viewer (E2)** and **nurse visit-NOTE authoring (E3 bottom)** →
[`frontend-phase-13-b14.md`](./frontend-phase-13-b14.md).
- **Checkout / pay / escrow notice / invoice / tax line** → [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md).
- **Cancellation flow + refund/policy-fee disclosure** → [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md).
- **Admin EVV-review queue** (location-mismatch / no-show worklist) → admin console
[`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md). This phase raises no alerts client-side; no-show
detection is a server job.
## 4. Mocks & seams in this phase
- **This phase introduces one client seam: `ILocationProvider`** (`src/services/bookings/evv/locationProvider.ts`)
— wraps the browser Geolocation API for EVV GPS capture. The real implementation calls
`navigator.geolocation.getCurrentPosition`; a **mock** returns canned coordinates so the EVV flow is
testable without a device and so a denied/unavailable path can be exercised. Record it in
`dev/shared-working-context/reports/mocks-registry.md` with the seam, what it fakes, the config key (e.g.
`NEXT_PUBLIC_EVV_MOCK_GPS`), and how to make it real. **Server-side** GPS/address-match math lives behind
the backend's geocoding/geo-distance seam — **reuse it from b9**, do not introduce a server seam here.
- **`services/bookings` data, if b9 isn't merged yet:** build the booking/session/EVV/care calls behind the
same `services/bookings` `clientApi` as a **mock `clientApi`** (real-shaped per the contract), select it
by config exactly as f0 established, and record it in your frontend report so it swaps cleanly when the
real endpoints land. The EVV mutations' mock should flip the mocked session/EVV state so the banner +
timeline transitions are demonstrable.
- **Everything else is reused:** `clientFetch`/`serverFetch`, the Query client, the cookie/auth manager, the
toast bridge, the f0 composites (status chip, stepper). Introduce no new cross-cutting seam beyond
`ILocationProvider`.
## 5. Critical rules you must not get wrong
- **Two-stage clinical disclosure is a UI gate, not just a server check.** Care instructions render and are
**queried only** when the booking is `confirmed`+ **and** the viewer is the assigned nurse or admin. An
unassigned user — including the customer and any other nurse — must **never** see the instructions and the
client must **never even fire the request**. Treat a 403 as a defect path, not the intended flow.
- **A GPS / address mismatch is advisory — a banner, never a block.** Out-of-range check-in still succeeds;
show the warning-tokened "موقعیت خارج از محدوده (در حال بررسی)" banner and let check-out proceed. Never
auto-cancel, never withhold the visit, never gate check-out on the match. Permission-denied/unavailable
GPS likewise must not block the nurse.
- **The status timeline reflects server truth.** `BookingDetailDto.status` is the single source — never
advance, infer, or optimistically jump a timeline step on the client. After an EVV mutation, **invalidate**
the detail/session queries and re-render from the server response.
- **Money is display-only here and never computed.** Render `gross_price_irr` / `balinyaar_commission_irr` /
`nurse_payout_amount` exactly as the server sends them (IRR **strings**, integer-safe), through
`formatIrrToToman`. Do not sum, derive, or re-split amounts on the client; the booking money identity
(`gross = commission + payout`) and per-session `visit_payout_amount` are computed and guaranteed
server-side. **Do not compute payout-eligibility** — `payout_eligible_at` is gated by the dispute window
server-side; one payout per booking is enforced server-side; render, don't recompute.
- **A single-visit booking still has exactly one session.** Render the one `booking_sessions` row through the
same `SessionCard` path; no "0 sessions" or single-visit special case.
- **Caching is a feature.** Set deliberate `queryKey`/`staleTime`; **invalidate on every EVV mutation** so
the timeline, session row, and EVV banner update without a full refetch storm. Don't refetch data already
in cache; prefer the RSC prefetch for first paint.
- **Boundaries & primitives:** respect the RSC/client boundary; fetch only through `services/bookings` →
`clientFetch`/`serverFetch`; MUI primitives stay MUI; the shared composites
(`BookingStatusTimeline`/`SessionCard`/`EvvStatusBanner`/`CareInstructionsCard`) live at the shared level
with co-located tests, not buried in a page. Minimise re-renders (stable refs, `select`, colocated state).
- **i18n + RTL + tokens:** every string in both locale files, in sync; `fa` default & RTL-correct; colours
from `tokens.css`; MUI v9 only. Invoke the **frontend-designer** skill for all visual work.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] Booking detail renders for both roles from `useBookingDetail`, with a server-truth
`BookingStatusTimeline` covering all seven statuses and a `SessionList` (≥1 session, single-visit
included), money summary via `formatIrrToToman`.
- [ ] Nurse EVV: check-in captures GPS through `ILocationProvider`, posts via `useCheckInVisit`, and shows
the in-range **"ورود ثبت شد … موقعیت تایید شد (EVV)"** banner or the advisory out-of-range variant;
check-out via `useCheckOutVisit` requires an open check-in and flips the session to `completed`
(server-driven, via invalidation). GPS denial does not block.
- [ ] Care-instructions card + query are **gated**: visible/fired only for the assigned nurse (or admin) on a
`confirmed`+ booking; never rendered/requested for the customer or an unassigned user (verified by test).
- [ ] All new strings in `en.json` **and** `fa.json`, in sync; RTL verified; colours from tokens; MUI v9.
- [ ] `services/bookings` extended (not duplicated); hooks invalidate on EVV mutation; types derived from
[bookings-evv.md](../../contracts/domains/bookings-evv.md) (gaps logged to for-backend + mocked).
- [ ] The shared composites (`BookingStatusTimeline`, `SessionCard`, `EvvStatusBanner`, `CareInstructionsCard`)
each have a co-located `*.test.tsx`; `npm run check` green; `npm run test:ci` green.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new `src/components/booking/` folder, the
`services/bookings` extension, and the `ILocationProvider` evv seam.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (with the b9 endpoints live, or the mock `clientApi` selected by config). Walk these:
1. **Open a confirmed booking** from the customer رزروها/Bookings tab → the page shows the **status timeline**
sitting at `confirmed`, the **session schedule** (single-visit shows exactly one session; multi-day shows
N), and the **money summary** in Toman. Switch locale → strings + `dir` flip correctly, timeline reads RTL.
2. **As the assigned nurse**, open the same booking → the **care-instructions card** is visible (conditions/
meds/allergies/instructions/emergency contact). **As the customer or an unassigned nurse**, the card is
absent and the network tab shows the care-instructions request **was never made**.
3. **Nurse check-in** on today's session → "در حال دریافت موقعیت…" spinner, then the **EVV banner** "ورود ثبت
شد ۰۹:۰۲ · موقعیت تایید شد (EVV)"; session chip → `in_progress`; timeline → `in_progress`. With the mock
GPS forced out-of-range, the banner shows the **advisory** out-of-range variant and check-in **still
succeeds**. Deny GPS permission → the nurse can still proceed.
4. **Nurse check-out** → session chip → `completed` with elapsed duration; the booking timeline advances to
`completed` from the server response (no client-side step jump). Check-out before any check-in → the
domain "no open check-in" message, no generic toast.
5. **Caching:** in React Query Devtools, the EVV mutation **invalidates** `bookingDetail`/`bookingSessions`/
`sessionEvv` and the UI re-renders from the refetch; revisiting the page within `staleTime` does not
refetch.
6. `npm run check` and `npm run test:ci` pass; the gating test proves the customer/unassigned path never
renders or requests care instructions.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project Structure** tree in [`client/CLAUDE.md`](../../../client/CLAUDE.md) for
`src/components/booking/`, the extended `services/bookings`, and the `services/bookings/evv` location
seam; add a short note that the booking-detail/EVV pattern (timeline + sessions + gated care + EVV banner)
is the template f9/f13 extend. Fix any doc drift you touch. If you discover/confirm a business rule the
`product/` docs don't capture (e.g. the exact advisory-banner copy or the post-confirm disclosure timing),
record it in [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
(don't invent rules) and regenerate the HTML per `product/CLAUDE.md`.
- **Contract (consume):** types/services derive from
[`dev/contracts/domains/bookings-evv.md`](../../contracts/domains/bookings-evv.md) (b9). Any missing or
ambiguous shape — care-instructions decrypt response, `check_in_address_match` representation,
`payout_eligible_at` exposure, the care-details write-back — is **appended** to
`dev/shared-working-context/frontend/requests/for-backend.md` (you never edit backend files), and mocked
behind `services/bookings` meanwhile.
- **Handoff & report:** append your phase summary to
`dev/shared-working-context/frontend/STATUS.md`; write
`dev/shared-working-context/reports/frontend-phase-8-report.md` — what was built (booking detail/timeline,
sessions, nurse EVV, gated care), **what is now testable and exactly how** (the §7 steps), what is mocked
(`ILocationProvider`, any mocked `services/bookings` calls) and how to make it real, the contract consumed
+ gaps filed, follow-ups for f9/f13. Update
`dev/shared-working-context/reports/mocks-registry.md` for the `ILocationProvider` seam and any mocked
endpoint.
- **Memory:** save a `project` memory note for the non-obvious decisions — the two-stage care-instructions
**UI gate** (don't-even-fetch), the **advisory-not-blocking** EVV mismatch handling, the
`ILocationProvider` GPS seam, and that the status timeline is strictly server-truth — with a one-line
pointer added to `MEMORY.md`.
+342
View File
@@ -0,0 +1,342 @@
# Frontend Phase 9 — Checkout, card payment & invoice
> **Mission:** turn an accepted booking request into paid, confirmed money on the rails. Build the
> **C6 خلاصه و پرداخت** summary screen (the "✓ پرستار تایید کرد" badge, the reconciling
> service-cost / commission / tax / total breakdown, and the load-bearing escrow trust notice), then
> the **card payment** flow — initiate → mock gateway redirect → return → pending callback →
> succeeded → booking flips to **confirmed** — followed by the **confirmation** screen and a
> downloadable **invoice** with the VAT-on-commission line. This is the family's first real money
> moment in Balinyaar; the breakdown must reconcile to the rial and the escrow copy must build trust,
> because the whole anti-disintermediation thesis rests on payment happening *on-platform*.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) (booking
> detail / sessions / EVV) + backend phase **b10** (Payments core — the contract you consume) ·
> **Unlocks:** [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md) (refunds & cancellation),
> [`frontend-phase-11-b12.md`](./frontend-phase-11-b12.md) (BNPL checkout)
> **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 payment seam of the customer journey. By the end of **f7-b8** the family can send a
booking request and a nurse can accept it; by the end of **f8-b9** there is a booking-detail screen,
session list, and EVV. What's missing is the bridge the wireframe calls **C6**: once the nurse has
accepted (`accepted_awaiting_payment`), the family sees the price, pays by card, and the
`booking_request` converts into a money-bearing `booking` that reaches **confirmed**. Backend **b10**
just shipped the money core (ledger, transactions, webhook idempotency, card capture → confirm →
convert) behind the `IPaymentProvider`/`IWebhookVerifier` seams; this phase is its frontend
counterpart. After this, **f10** can cancel/refund and **f11** can offer BNPL as an alternative to
the full-card path you build here.
**What already exists (do not rebuild) — from prior phases:**
- **The foundation (f0)** — the three actor shells + route groups, the `services/{domain}` + TanStack
Query caching pattern (copy the `auth` service shape), the contracts→types pattern, the **money/format
util** in `src/utils/` (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display) that
**every** price in this phase renders through, the shared composite **price-breakdown** and
**status-chip** components, the i18n namespaces (including `payment`), and the RTL baseline. See
[`frontend-phase-0.md`](./frontend-phase-0.md).
- **Auth & role routing (f1-b2)** — `AuthContext` with roles; the customer app shell + 5-tab bottom nav.
- **Booking request flow (f7-b8)** — the request form (C4) and **awaiting-acceptance (C5)** status
tracker. C6 is the *next* node after that tracker's "در انتظار تایید پرستار" step resolves to
accepted; reuse C5's status-tracker component and the `booking`/`booking_requests` service shapes.
- **Booking detail & sessions (f8-b9)** — the `services/booking` domain (detail/list queries, status
timeline, the three-amount split already present on the booking payload), the booking status chip,
and the booking-detail route this phase links *back to* after confirmation. **Reuse this service**;
do not create a parallel booking service.
> **Out of scope here (DEFERRED):** the **BNPL** method/plan/eligibility/contract screens (D1D5) — that
> is [`frontend-phase-11-b12.md`](./frontend-phase-11-b12.md); this phase builds **only** the full-card
> path and must leave a clean "یا پرداخت اقساطی" seam on C6/D1 for f11 to attach to. **Cancellation &
> refund** (policy fee disclosure, refund status, BNPL ETA) is
> [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md). The admin-side refund console is **f15**. The
> مودیان (e-invoicing) registration state is a backend concern — surface it read-only if the contract
> exposes it, never drive it.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you
work, the gate, the contract/handoff lanes.
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary,
`services/{domain}` + Query caching, the toast contract: do **not** toast 401/403/5xx in hooks, only
domain 4xx; cookies/constants rules; MUI v9 only). Non-negotiable.
- **Invoke the `frontend-designer` skill** — the design/brand contract (palette, the **terracotta**
financial accent vs **teal** brand, tokens, typography, the `App*` library, the mobile RTL shell, the
hard UI rules). **All visual work on C6, the redirect/pending/confirmation states, and the invoice goes
through it.** Do not hand-style money screens off-token.
- **The contract you consume:** [`../../contracts/domains/payments.md`](../../contracts/domains/payments.md)
(produced by **b10**) — the request/response shapes, routes, status codes, and the **payment status
enum** for initiate / verify / confirm / get-transaction. If the **invoice** shape lives in a separate
doc, also read `../../contracts/domains/refunds-invoices.md` (from **b11**) — if that file is not yet
published, mock the invoice behind the `services/payment` seam and file the gap (see §4 and §8).
Always cross-check against the published `../../contracts/openapi/README.md` / `swagger.json` snapshot
for exact casing — **derive types from the contract, never guess shapes.**
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) — **money
is IRR rials as a string of digits on the wire**; parse with the f0 integer-safe helper, format to Toman
for display, never do float math; `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
enums are stable string codes (map to i18n labels, never hardcode a display label off the code).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
`OperationResult`/`ApiResult` envelope (`clientFetch` already unwraps it), `snake_case` URL segments,
status codes (`409` = idempotency/state-machine conflict — handle it as "already paid / in progress",
not an error toast), and the **idempotency key** requirement on the money-path POST.
- **Product truth (read before designing the breakdown/escrow copy):**
- [`../../../product/business/08-payments-and-escrow.md`](../../../product/business/08-payments-and-escrow.md) —
merchant-of-record, the three-amount split, **escrow is an internal ledger state, not held cash**,
VAT applies to **commission only**. This is *why* the C6 copy reads the way it does.
- [`../../../product/payments/index.md`](../../../product/payments/index.md) — the fintech overview;
card → PSP → Shaparak → IBAN rails, the تسهیم split, the "PSP received ≠ cash in bank" timing reality
that justifies a **pending-callback** UI state rather than assuming instant success.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — **C6** is the exact
screen you implement (the badge, the breakdown rows, the escrow notice, the "ادامه پرداخت ←" CTA);
confirm the labels and RTL layout against it before building.
- **Code to mirror:** `client/src/services/auth/*` (the `types.ts`/`keys.ts`/`apis/clientApi.ts`/
`hooks/use*.ts`/`index.ts` shape every domain copies) and the f8 `services/booking` service; the f0
**price-breakdown** and **status-chip** components; the f0 money util in `src/utils/`.
## 3. Scope — build this
Everything below lives under `client/` (the customer app shell). Build a `services/payment` domain, the
hooks, the C6 + payment-state + confirmation + invoice screens, and the small composites they need —
each visual surface produced via the **frontend-designer** skill, RTL-first, both locales.
### 3.1 `services/payment` domain (the data layer)
Mirror the `auth`/`booking` service shape exactly. Types come from
[`payments.md`](../../contracts/domains/payments.md) — do not invent fields.
- **`types.ts`** — string-literal unions + DTOs derived from the contract. Expect (confirm exact names
against the published swagger):
- `PaymentTransactionStatus` = `initiated` | `pending` | `succeeded` | `failed` | `cancelled` (the
contract's `payment_transactions.status` enum).
- `BookingStatus` reused from `services/booking` — the value that flips `pending_payment``confirmed`.
- `CheckoutSummary` — the C6 payload: `booking_id`/`booking_request_id`, `nurse` mini-info, the
**three amounts** (`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount` as IRR
digit-strings), a derived/served **service cost** and **tax (VAT) line** (`vat_irr` +
`vat_rate`), `total_irr` (= `gross_price_irr`), and the escrow flag/copy keys. If the breakdown
rows aren't all served, compute display rows **only** from served amounts via the f0 integer-safe
helper — never float-derive tax client-side; if a needed line is missing, request it (§8).
- `InitiatePaymentRequest` (`booking_id` / `booking_request_id`, `gateway` selector, an
**idempotency key**) and `InitiatePaymentResult` (`transaction_id`, `redirect_url`,
`gateway_reference_code`).
- `VerifyPaymentResult` / `PaymentTransaction` (status, `gateway_reference_code`, amount, the
`booking_id` it confirmed).
- `Invoice``invoice_number`, issued-at, the line items including the **VAT-on-commission** line,
`download_url`/`pdf_url` if served (else a client-rendered receipt).
- **`keys.ts`** — a key factory: `paymentKeys.all`, `paymentKeys.summary(bookingRequestId)`,
`paymentKeys.transaction(transactionId)`, `paymentKeys.invoice(bookingId)`.
- **`apis/clientApi.ts`** — a `PaymentClientApi` namespace wrapping `clientFetch` (one call per
endpoint). Map to the b10 routes from the contract (illustrative `snake_case` slugs — use the
published ones):
- `getCheckoutSummary(bookingRequestId)``GET .../payment/get_checkout_summary`.
- `initiatePayment(req)``POST .../payment/initiate_payment` (sends the **idempotency key**).
- `verifyPayment({ gateway_reference_code, transaction_id })``POST .../payment/verify_payment`
(the **server-side re-check** on gateway return — never trust the redirect query alone).
- `getTransaction(transactionId)``GET .../payment/get_transaction` (the callback-poll target).
- `getInvoice(bookingId)``GET .../payment/get_invoice` (or the refunds-invoices route).
- **Only** add a `serverApi.ts` if an RSC needs to prefetch the summary; otherwise client-only.
- **`hooks/` — one hook per file:**
- `useCheckoutSummary(bookingRequestId)``useQuery`, `staleTime` short (prices can change before
pay), keyed `paymentKeys.summary(...)`.
- `useInitiatePayment()``useMutation`; **generates and reuses one idempotency key** for the
attempt (stable across retries — see §5); on success returns the `redirect_url`.
- `useVerifyPayment()``useMutation`; called on gateway return; on `succeeded`
**`invalidateQueries`** for the booking detail/list (so the booking flips to confirmed without a
refetch storm) and for the transaction key.
- `usePaymentTransaction(transactionId, { enabled })``useQuery` for the **pending-callback poll**;
use `refetchInterval` with **backoff** and **stop on terminal status** (`succeeded`/`failed`/
`cancelled`) — see §5. Do **not** aggressive-poll.
- `useInvoice(bookingId)``useQuery`, longer `staleTime` (an issued invoice is immutable).
- `index.ts` re-exports the **hooks only** (per `client/CLAUDE.md` — no `types`/`keys`/`apis` in the
barrel).
### 3.2 Screens & routes (under the customer shell, inside `[locale]/(private-routes)`)
Decide route segments that read cleanly under the existing role-scoped customer group (e.g. a
`checkout` segment keyed by the booking-request id). No layout above `[locale]`; respect the RSC/client
boundary.
- **C6 · خلاصه و پرداخت (Summary & pay)** — the checkout screen. Composes:
- the **"✓ پرستار تایید کرد"** acceptance badge (reuse the f0 **status-chip**, success token),
- the nurse mini-summary (name, service, schedule) pulled from the booking,
- the **price breakdown** via the f0 **price-breakdown** composite — rows: **هزینه خدمت** (service
cost, e.g. "۸ ساعت"), **کارمزد بالین‌یار** (platform commission), **مالیات (VAT)** (tax), and the
bold **مبلغ کل** (total). Every amount rendered through the f0 money util; the visible rows must
**reconcile to the total** (§5).
- the **escrow notice** — a distinct trust callout (reuse `AppAlert`/an info surface, teal/info
token, **not** an error color): the load-bearing copy
**«مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود»** (i18n key in both
locales). This copy is product-mandated trust UX — keep it verbatim in `fa`, with a faithful `en`
translation.
- the primary CTA **«ادامه پرداخت ←»** (drives `useInitiatePayment` → redirect), plus a disabled-state
**«یا پرداخت اقساطی»** seam stub that f11 wires to D1 (render it as a clearly-deferred secondary,
not a dead button — gate behind a `bnplEnabled` flag defaulting off).
- **Card payment states** — a single payment-state surface (page or modal) driving the wireframe's
documented checkout states **initiating → redirect-to-gateway → pending-callback → succeeded→confirmed
→ failed/retry**:
- **initiating** — CTA shows a spinner while `useInitiatePayment` runs.
- **redirect** — on `redirect_url`, navigate to the **mock gateway** (the b10 mock returns a redirect
URL; in dev this is a local mock-gateway page that immediately returns success — build a tiny
**mock-gateway return page** under the checkout segment so the round-trip is real without a PSP).
- **return / pending-callback** — on return, read `gateway_reference_code`/`transaction_id` from the
query, fire `useVerifyPayment`, and if status is `pending` show a **"در حال تایید پرداخت…"** state
backed by `usePaymentTransaction` polling with backoff until terminal.
- **succeeded → confirmed** — on `succeeded`, invalidate booking queries and route to the confirmation
screen.
- **failed / retry** — on `failed`/`cancelled`, show a retry affordance (re-initiate generates a
**new** idempotency key for the new attempt) and a "بازگشت به خلاصه" link.
- **Confirmation screen** — success state: "پرداخت با موفقیت انجام شد", the booking now **confirmed**,
a summary card, a **«مشاهده رزرو»** link back to the f8 booking-detail route, and a **«دانلود فاکتور»**
link to the invoice.
- **Invoice view / download** — renders `useInvoice(bookingId)`: header (`invoice_number`, issued
Shamsi date), line items, and the **VAT line on the commission** explicitly shown (per product rule:
VAT is on Balinyaar's commission, not the nurse's earnings). If the contract serves a `pdf_url`/
`download_url`, the download button hits it; otherwise render a clean printable receipt
(`window.print()` styled view) — no float math, every figure via the money util. Surface the
مودیان state read-only (e.g. «در انتظار ثبت») **only if** the contract exposes it.
### 3.3 Shared composites (build/extend at the shared level)
- **`PriceBreakdown`** — if f0 stubbed it, finish it here as the shared composite (`src/components/…`)
with a co-located `*.test.tsx`: props are typed display rows + a total; it formats nothing itself
beyond receiving already-formatted strings or raw IRR + the money util — keep money formatting in one
place. A test asserts the rows render and the total equals the sum of the served amounts.
- **`EscrowNotice`** — a small shared callout composite wrapping `AppAlert` with the mandated copy key,
so f10/f11 reuse the identical trust message. Co-located test asserts it renders the key.
- **`PaymentStatusBadge`** — map `PaymentTransactionStatus` → an i18n label + the f0 status-chip
variant. Co-located test for each status.
- Keep page-only composition (the confirmation layout, the mock-gateway return page) in the page.
### 3.4 i18n
Add all C6 / payment-state / confirmation / invoice strings to the **`payment`** namespace in **both**
`messages/en.json` and `messages/fa.json`, in sync, RTL-first. Status/enum codes map to label keys —
never hardcode a label off a code. The escrow copy and the breakdown row labels (هزینه خدمت / کارمزد
بالین‌یار / مالیات / مبلغ کل) are keys.
## 4. Mocks & seams in this phase
- **No new cross-cutting seam is owned here.** The PSP/gateway, تسهیم split, and webhook verification
are **backend** seams (`IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`) introduced
in **b10** — the frontend never talks to a PSP directly; it consumes the b10 contract. Reuse the
`services/{domain}` mock-`clientApi` seam pattern from **f0**.
- **The frontend-side mock (only if a shape is missing):** if `payments.md` (or `refunds-invoices.md`
for the **invoice**) is not yet published when you build, implement a **mock `clientApi`** behind the
`services/payment` seam — real-shaped responses (a `redirect_url` pointing at your local mock-gateway
return page, a transaction that goes `initiated → pending → succeeded`, an invoice with a VAT line) —
swap to the real `clientFetch` calls once the contract lands. **Record it** in your frontend report and
the mock registry, and **append the missing shape** to
`dev/shared-working-context/frontend/requests/for-backend.md` (operating-rules §6).
- **The dev mock-gateway return page** is a *test harness*, not a product feature: it exists so the
redirect→return round-trip is exercisable without a PSP. Keep it behind the checkout segment and note
it as test-only in the report.
## 5. Critical rules you must not get wrong
- **Money is IRR `BIGINT`, no floats.** Every amount on the wire is an **IRR digit-string**; parse it
with the f0 **integer-safe** helper and format to **Toman** for display through the f0 money util — do
**no float math** anywhere on the money path, in the DB, in the API, or in the client.
- **The breakdown must reconcile.** `gross = commission + payout`, and the **displayed rows must sum to
the displayed total** (service cost + platform commission + tax = total) using integer-safe addition —
never render a breakdown that doesn't add up, and never compute tax with a float rate client-side
(show the served `vat_irr`; if absent, request it, don't derive it loosely).
- **VAT is on the commission, not the nurse's earnings.** The invoice's VAT line is computed on
Balinyaar's commission (the taxable supply) — label and place it accordingly; never imply the nurse is
taxed.
- **The escrow copy is load-bearing trust UX.** Render the mandated notice verbatim («مبلغ به‌صورت امانی
نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود») as an info/trust callout, never an error tone,
on C6 — it is *why* the family pays on-platform.
- **Webhook/callback idempotency is the backend's guarantee — don't fight it.** Send **one stable
idempotency key** per payment attempt and **reuse it across retries of the same attempt** (a new
attempt gets a new key). A `409` on initiate/verify means "already in progress / already captured" —
treat it as a benign state convergence (re-fetch the transaction and continue to confirmation), **not**
an error toast. The server enforces one succeeded transaction per booking; the UI must never try to
double-capture.
- **Do not poll aggressively.** The pending-callback state uses `usePaymentTransaction` with
`refetchInterval` **backoff** (e.g. start ~2s, grow, cap; bounded total attempts) and **stops on any
terminal status**; never a tight loop. "PSP received ≠ cash in bank," so a pending state is normal —
reflect it calmly, don't hammer the endpoint.
- **Confirmation flips the booking, by cache invalidation.** On `succeeded`, `invalidateQueries` the
`services/booking` detail/list keys so the booking shows **confirmed** — do not refetch everything and
do not duplicate booking state in `services/payment`.
- **Caching & re-renders.** Deliberate `queryKey`/`staleTime`; the summary is short-lived, an issued
invoice is immutable (long `staleTime`). Use `select`/stable refs so the polling state doesn't re-render
the whole breakdown.
- **RTL + both locales + tokens.** `fa` default & RTL; every string in both locale files; colours from
`tokens.css` (terracotta for financial accents, info/teal for the escrow callout — never hardcoded);
MUI v9 API only; MUI primitives stay MUI, composites live shared.
- **Boundary & fetch discipline.** Fetch only via `clientFetch` through `services/payment`; no raw
`fetch()`; no toast for 401/403/5xx in hooks (only domain 4xx like a gateway-declined message).
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] **C6** renders the acceptance badge, the reconciling **service cost / commission / tax / total**
breakdown (all via the f0 money util), and the verbatim **escrow notice**; the «ادامه پرداخت ←»
CTA initiates payment.
- [ ] The **card flow** drives all five documented states (initiating → redirect → pending-callback →
succeeded→confirmed → failed/retry) end-to-end against the mock gateway; the **idempotency key** is
stable per attempt; `409` converges instead of erroring.
- [ ] On success the booking flips to **confirmed** via `invalidateQueries` (verified in React Query
Devtools — no over-fetch), and the **confirmation** screen links back to booking detail and to the
invoice.
- [ ] The **invoice** view renders the line items with the **VAT-on-commission** line and downloads
(served `pdf_url`) or prints a clean receipt; every figure via the money util, no float math.
- [ ] The pending-callback poll uses **backoff** and **stops on terminal status** — no aggressive loop.
- [ ] `PriceBreakdown`, `EscrowNotice`, `PaymentStatusBadge` are shared and each has a co-located
`*.test.tsx`; the breakdown test asserts rows sum to total.
- [ ] `payment` strings in **both** `en.json`/`fa.json`, in sync; RTL verified; colours from tokens.
- [ ] `npm run check` green; `npm run test:ci` green (shared components touched); `client/CLAUDE.md`
*Project Structure* updated for the new `services/payment` domain, checkout route segment, and new
shared components.
- [ ] Types derive from the published contract; any gap is appended to
`dev/shared-working-context/frontend/requests/for-backend.md` and mocked behind the
`services/payment` seam meanwhile (recorded in the report + mock registry).
## 7. How to test (what a human can verify after this phase)
Prereq: a booking request that the nurse has **accepted** (`accepted_awaiting_payment`) — create one via
the f7 request flow + nurse accept, or seed it. Run `npm run dev`.
1. **Open C6** from the accepted request (the C5 tracker's "پرداخت و تایید نهایی" step) → the screen
shows the **«✓ پرستار تایید کرد»** badge, the **breakdown** (هزینه خدمت / کارمزد بالین‌یار / مالیات /
مبلغ کل) where the rows **sum to the total**, and the **escrow notice** in an info tone. Switch locale
`dir` flips, all strings translate, amounts still format as Toman.
2. **Pay (mock redirect)** → tap «ادامه پرداخت ←»: CTA spins (initiating) → you are redirected to the
**mock gateway** → it returns to the checkout → **verify** runs; if pending you briefly see "در حال
تایید پرداخت…" (poll with backoff) → it resolves **succeeded** → the **confirmation** screen appears.
3. **Booking flips to confirmed** → follow «مشاهده رزرو» to the f8 booking detail: status is now
**confirmed** (no full refetch — confirm via React Query Devtools that only the booking keys were
invalidated).
4. **Download the invoice** → from confirmation tap «دانلود فاکتور»: the invoice shows the
`invoice_number`, line items, and the **VAT line on the commission**; download (or print) works; every
figure matches C6 to the rial.
5. **Idempotency / retry** → re-trigger pay on the same attempt (double-tap / refresh on return): no
double-capture — a `409`/already-succeeded converges to the confirmation, not an error toast. A
**new** attempt after a simulated failure issues a new idempotency key.
6. **Gate**`npm run check` and `npm run test:ci` pass; the `PriceBreakdown` test proves rows sum to
total; the `PaymentStatusBadge` test covers each status.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* for the new `services/payment` domain, the
checkout route segment, and the new shared components (`PriceBreakdown` if promoted, `EscrowNotice`,
`PaymentStatusBadge`). Note the `payment` i18n namespace usage. Don't reintroduce removed scaffolding.
- **Contract consumed:** [`../../contracts/domains/payments.md`](../../contracts/domains/payments.md)
(b10) for checkout-summary / initiate / verify / get-transaction, and the invoice part of
`../../contracts/domains/refunds-invoices.md` (b11) **if available**. Types come from the published
swagger — don't guess. **Append any gap** (missing breakdown line, missing `vat_irr`/`vat_rate`,
missing `redirect_url`, missing invoice shape, مودیان state field) to
`dev/shared-working-context/frontend/requests/for-backend.md`.
- **Handoff & report:** append to `dev/shared-working-context/frontend/STATUS.md`; write
`dev/shared-working-context/reports/frontend-phase-9-report.md` (what was built, **what is now testable
and exactly how** — the steps in §7, what is mocked client-side (the gateway return harness / any
unmet shape) and how it swaps to real, contracts consumed, follow-ups for f10/f11). Update
`dev/shared-working-context/reports/mocks-registry.md` for the `services/payment` mock `clientApi` and
the dev mock-gateway page (seam, what's faked, config, how to make it real once the contract lands).
- **Memory:** save a `project` memory note for the checkout state machine (initiating → redirect →
pending-callback → succeeded → failed/retry), the **idempotency-key-per-attempt** rule, the
**backoff poll, no aggressive loop** decision, and the **escrow-copy-is-verbatim-trust-UX** constraint,
with a one-line `MEMORY.md` pointer.