add build development phases
This commit is contained in:
@@ -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 f1–f15 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.
|
||||
@@ -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 (~7–10
|
||||
> 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 ~7–10 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 **~7–10 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` 7–10-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 7–10-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 **~7–10 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 ~7–10-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 **~7–10 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 ("~7–10 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 (D1–D4) 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) — D1–D5 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
|
||||
(D1–D5)** — the exact screens, RTL Persian, terracotta financial accent. D1 روش پرداخت, D2 انتخاب طرح
|
||||
اقساط, D3 اعتبارسنجی, D4 تایید طرح و قرارداد, D5 پیگیری اقساط (in Wallet). D5 carries the bottom tab nav
|
||||
(Wallet active); D1–D4 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 (D1–D4) 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): **دیجیپی** (3–12 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 "~7–10 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 D1–D5 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 D1–D5 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).
|
||||
- [ ] **D1–D5** 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 D1–D4 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 D1–D5 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 §6–7). 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 b9–b13):** 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 1–5 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` 1–5,
|
||||
`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 **1–5 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 A1–E3 — 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 **0–1**), `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 0–1 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 b9–b13):** 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 **0–1**, 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 0–1), 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 **0–1** (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.
|
||||
@@ -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** (B3–B6, 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.
|
||||
@@ -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`.
|
||||
@@ -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* (f1–f3, 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.
|
||||
@@ -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 B3–B6** 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 "در حال بررسی" (24–48h, 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 **24–48h** 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 24–48h 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`.
|
||||
@@ -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`.
|
||||
@@ -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 15–30s 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`.
|
||||
@@ -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`.
|
||||
@@ -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 (D1–D5) — 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.
|
||||
Reference in New Issue
Block a user