refinement phase 4

This commit is contained in:
hamid
2026-07-13 12:29:00 +03:30
parent 314763f764
commit 64f6aa45c9
27 changed files with 308 additions and 243 deletions
+14 -2
View File
@@ -136,8 +136,7 @@ client/
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews) │ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=) │ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard │ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches) │ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired (the payment mock-gateway harness was removed in refinement-phase-4 when USE_PAYMENT_MOCK flipped; on the real path the PSP redirectUrl is absolute)
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee) │ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=) │ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere │ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
@@ -659,6 +658,19 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api`
twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config
flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in
`dev/shared-working-context/reports/mocks-registry.md`. `dev/shared-working-context/reports/mocks-registry.md`.
- **De-mock status (refinement-phase-4):** **14 domains are now REAL** (`USE_*_MOCK = false`): `auth`,
`geography`, `patients`, `profiles`, `nurse` (bank), `addresses`, `serviceAreas`, `catalog`, `search`,
`bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`, `tickets`. Flipping them required
updating each `clientApi.ts` to **consume the fields Phase-3 delivered** (search name/avatar/distance +
`nurses/{id}/profile`; patient relation/conditions; address `provinceId`; booking-request
`variantPrice`/`bookingId`; ticket `unreadCount`/`lastMessageAt`/`clientMessageId`; review `my_review`
mapper; profile `avatarUrl`/`preferredLanguage` + a **multipart avatar upload** now that `clientFetch`
passes `FormData` bodies through). **7 domains stay mocked** because a precondition REQ is deferred/unsafe:
`verification` (REQ-034 admin queue), `refunds` (REQ-035 admin preview), `payouts` (REQ-036 admin preview),
`admin` (REQ-031 RBAC roles), `bnpl` (REQ-022/024 options/schedule/wallet), `partnerCenter` (REQ-032/033/038
portal reads + `/me` signal), `patientRecords` (REQ-027 endpoints exist but the client family-record
`id` model is `string` vs the wire's `int` — the customer-edit PUT is write-unsafe until reconciled). Note:
the `EVV_GPS_MODE` seam auto-selects `off` (real `navigator.geolocation`) once `USE_BOOKINGS_MOCK=false`.
- **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode, - **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode,
message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so
a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` + a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` +
@@ -1,76 +0,0 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import {
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { GatewayReturnOutcome } from '@/services/payment/types';
/**
* Dev mock-gateway page — a **test harness, not a product feature**. It stands in for the PSP so the
* initiate → redirect → return round-trip is exercisable without a real gateway: the payment mock's
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
*/
export default function MockGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
function MockGatewayScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
const returnWith = (outcome: GatewayReturnOutcome) => {
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
[CHECKOUT_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
};
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('gateway_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('gateway_hint')}
</Typography>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -8,16 +8,25 @@ import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils'; import { digitsOnly } from '@/utils';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles'; import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import { useMe } from '@/services/auth';
import type { CustomerProfile } from '@/services/profiles/types'; import type { CustomerProfile } from '@/services/profiles/types';
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */ /** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
export default function CustomerProfilePage() { export default function CustomerProfilePage() {
const { data: profile, isLoading } = useCustomerProfile(); const { data: profile, isLoading } = useCustomerProfile();
const { data: me } = useMe();
if (isLoading) return <AppLoading />; if (isLoading) return <AppLoading />;
return <CustomerProfileForm initial={profile ?? null} />; // The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
// editing the emergency contact never blanks (and re-saves as null) the existing name.
return (
<CustomerProfileForm initial={profile ?? null} nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }} />
);
} }
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => { const CustomerProfileForm: FunctionComponent<{
initial: CustomerProfile | null;
nameFallback: { firstName: string | null; lastName: string | null };
}> = ({ initial, nameFallback }) => {
const t = useTranslations('profile'); const t = useTranslations('profile');
const ta = useTranslations('address'); const ta = useTranslations('address');
const tc = useTranslations('common'); const tc = useTranslations('common');
@@ -25,8 +34,8 @@ const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertCustomerProfile(); const upsert = useUpsertCustomerProfile();
const [firstName, setFirstName] = useState(initial?.firstName ?? ''); const [firstName, setFirstName] = useState(initial?.firstName ?? nameFallback.firstName ?? '');
const [lastName, setLastName] = useState(initial?.lastName ?? ''); const [lastName, setLastName] = useState(initial?.lastName ?? nameFallback.lastName ?? '');
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa'); const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? ''); const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? '')); const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
@@ -8,6 +8,13 @@ jest.mock('next-intl', () => ({
})); }));
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) })); jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
// This is a behavioural test of the two-stage-disclosure gate; it uses the in-memory bookings mock as
// its data fixture (seeded booking 5001 + care instructions). Pin the seam to the mock so the test is
// independent of the production `USE_BOOKINGS_MOCK` flag (flipped to real in refinement-phase-4).
jest.mock('@/services/bookings/apis', () => ({
bookingsApi: jest.requireActual('@/services/bookings/apis/mockApi').bookingsMockApi,
}));
import BookingDetailView from './BookingDetailView'; import BookingDetailView from './BookingDetailView';
import { bookingsApi } from '@/services/bookings/apis'; import { bookingsApi } from '@/services/bookings/apis';
+5 -1
View File
@@ -36,9 +36,13 @@ export async function clientFetch<T>(path: string, options?: RequestInit, isRetr
const locale = window.location.pathname.split('/')[1] || 'fa'; const locale = window.location.pathname.split('/')[1] || 'fa';
const reqHeaders: Record<string, string> = { const reqHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'Accept-Language': locale, 'Accept-Language': locale,
}; };
// Let the browser set `Content-Type` (with the multipart boundary) for FormData bodies — a manual
// JSON content-type there breaks the upload. JSON bodies still declare it explicitly.
if (!(options?.body instanceof FormData)) {
reqHeaders['Content-Type'] = 'application/json';
}
if (token) { if (token) {
reqHeaders['Authorization'] = `Bearer ${token}`; reqHeaders['Authorization'] = `Bearer ${token}`;
} }
@@ -5,11 +5,14 @@ import type { CreateAddressInput, CustomerAddress, CustomerAddressDto, Addresses
const BASE = '/api/v1/customer_addresses'; const BASE = '/api/v1/customer_addresses';
// The wire `CustomerAddressDto` has no `provinceId` (REQ-009). Reads default it to null; writes // REQ-009 (delivered): the wire `CustomerAddressDto` now carries `provinceId` (joined from
// echo the caller's chosen province onto the returned row so the just-saved address can be // `cities.province_id`), so a freshly-fetched address prefills the cascade. The caller's chosen
// re-edited with its cascade prefilled (not yet persisted server-side). // province is kept as a fallback for the optimistic just-saved echo.
function toAddress(dto: CustomerAddressDto, provinceId?: number | null): CustomerAddress { interface AddressWire extends CustomerAddressDto {
return { ...dto, provinceId: provinceId ?? null }; provinceId: number;
}
function toAddress(dto: AddressWire, provinceId?: number | null): CustomerAddress {
return { ...dto, provinceId: dto.provinceId ?? provinceId ?? null };
} }
// Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 — // Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 —
@@ -43,7 +46,7 @@ export const addressesClientApi: AddressesApi = {
// geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`. // geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`.
query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE)); query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE));
const page = unwrap( const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<CustomerAddressDto>>>(`${BASE}/list?${query.toString()}`), await clientFetch<ApiEnvelope<Paginated<AddressWire>>>(`${BASE}/list?${query.toString()}`),
); );
return { ...page, items: page.items.map((dto) => toAddress(dto)) }; return { ...page, items: page.items.map((dto) => toAddress(dto)) };
}, },
@@ -51,7 +54,7 @@ export const addressesClientApi: AddressesApi = {
create: async (input) => create: async (input) =>
toAddress( toAddress(
unwrap( unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/create`, { await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/create`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
@@ -62,7 +65,7 @@ export const addressesClientApi: AddressesApi = {
update: async (id, input) => update: async (id, input) =>
toAddress( toAddress(
unwrap( unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/update/${id}`, { await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/update/${id}`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
+1 -1
View File
@@ -7,7 +7,7 @@
* `api/v1/customer_addresses/*` routes — no hook/component changes * `api/v1/customer_addresses/*` routes — no hook/component changes
* (see dev/shared-working-context/reports/mocks-registry.md). * (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_ADDRESSES_MOCK = true; export const USE_ADDRESSES_MOCK = false;
/** Address lists change only on mutation; keep them warm across screen visits. */ /** Address lists change only on mutation; keep them warm across screen visits. */
export const ADDRESSES_STALE_TIME = 60_000; export const ADDRESSES_STALE_TIME = 60_000;
@@ -12,43 +12,27 @@ import type {
const BASE = '/api/v1/booking_requests'; const BASE = '/api/v1/booking_requests';
/**
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price) and `bookingId`
* (REQ-017: a `converted` request gives no way to reach the booking it became).
*/
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>;
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted fields to `null`. */
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
return { ...wire, variantPrice: null, bookingId: null };
}
/** /**
* Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract * Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract
* `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for * `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for
* accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and * accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST. * `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST.
* *
* NOT the primary implementation this phase (`USE_BOOKING_REQUESTS_MOCK = true`): every input id (nurse, * PRIMARY once `USE_BOOKING_REQUESTS_MOCK = false` (refinement-phase-4; REQ-013/014/017 delivered): the
* patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice` * DTO now carries `variantPrice` + `nurseAvatarUrl` (REQ-013) + `bookingId` (REQ-017), and the list item
* (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is * carries `variantLabel` + `patientAge` (REQ-014), so the wire maps 1:1 to the app DTO. The `context` arg
* ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once * (a mock-only display aid) is ignored here, and the nurse-view masking is done server-side.
* the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change).
*/ */
export const bookingRequestsClientApi: BookingRequestsApi = { export const bookingRequestsClientApi: BookingRequestsApi = {
create: async (payload: CreateBookingRequestPayload) => create: async (payload: CreateBookingRequestPayload) =>
toDto(
unwrap( unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/create`, { await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/create`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}), }),
), ),
),
get: async (id: number) => get: async (id: number) => unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/get/${id}`)),
toDto(unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/get/${id}`))),
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => { list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -62,22 +46,16 @@ export const bookingRequestsClientApi: BookingRequestsApi = {
}, },
accept: async (id: number) => accept: async (id: number) =>
toDto( unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
),
reject: async (id: number, payload: RejectBookingRequestPayload) => reject: async (id: number, payload: RejectBookingRequestPayload) =>
toDto(
unwrap( unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/reject/${id}`, { await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/reject/${id}`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}), }),
), ),
),
cancel: async (id: number) => cancel: async (id: number) =>
toDto( unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
),
}; };
@@ -11,7 +11,7 @@
* (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component * (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component
* change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`). * change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`).
*/ */
export const USE_BOOKING_REQUESTS_MOCK = true; export const USE_BOOKING_REQUESTS_MOCK = false;
/** /**
* The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition * The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition
+1 -1
View File
@@ -11,7 +11,7 @@
* `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side — a single * `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side — a single
* config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`). * config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`).
*/ */
export const USE_BOOKINGS_MOCK = true; export const USE_BOOKINGS_MOCK = false;
/** /**
* The booking detail changes on status transitions (payment → confirmed → in_progress → completed) and * The booking detail changes on status transitions (payment → confirmed → in_progress → completed) and
+1 -1
View File
@@ -5,7 +5,7 @@
* demo standalone before the backend is reachable in this environment. Flip to false to hit the * demo standalone before the backend is reachable in this environment. Flip to false to hit the
* live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). * live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_CATALOG_MOCK = true; export const USE_CATALOG_MOCK = false;
/** /**
* Categories and a category's option groups/values are **admin-seeded reference data** that changes * Categories and a category's option groups/values are **admin-seeded reference data** that changes
+1 -1
View File
@@ -5,7 +5,7 @@
* Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes * Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes
* (see dev/shared-working-context/reports/mocks-registry.md). * (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_GEOGRAPHY_MOCK = true; export const USE_GEOGRAPHY_MOCK = false;
/** /**
* Reference data almost never changes, so it is cached **for the whole session**: an Infinite * Reference data almost never changes, so it is cached **for the whole session**: an Infinite
@@ -10,7 +10,7 @@
* `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll * `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll
* interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change. * interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change.
*/ */
export const USE_NOTIFICATIONS_MOCK = true; export const USE_NOTIFICATIONS_MOCK = false;
/** Notification-center page size (api-conventions `pageSize`). */ /** Notification-center page size (api-conventions `pageSize`). */
export const NOTIFICATIONS_PAGE_SIZE = 20; export const NOTIFICATIONS_PAGE_SIZE = 20;
+1 -1
View File
@@ -5,7 +5,7 @@
* the pending→verified/mismatch UI transition behind the client mock. Flip to false to use * the pending→verified/mismatch UI transition behind the client mock. Flip to false to use
* the real endpoints — no hook/component changes (mocks-registry.md). * the real endpoints — no hook/component changes (mocks-registry.md).
*/ */
export const USE_NURSE_BANK_MOCK = true; export const USE_NURSE_BANK_MOCK = false;
/** Bank accounts change rarely; keep them warm across screen visits. */ /** Bank accounts change rarely; keep them warm across screen visits. */
export const BANK_STALE_TIME = 30_000; export const BANK_STALE_TIME = 30_000;
+25 -15
View File
@@ -1,17 +1,27 @@
import { clientFetch } from '@/lib/api/client'; import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { CreatePatientInput, Patient, PatientDto, PatientsApi } from '../types'; import type {
ConditionCode,
CreatePatientInput,
Patient,
PatientDto,
PatientsApi,
Relation,
} from '../types';
const BASE = '/api/v1/patients'; const BASE = '/api/v1/patients';
// The wire `PatientDto` has no relation/conditions yet (REQ-005). Reads default them; writes /** REQ-005 (delivered): the wire `PatientDto` now carries `relation`/`conditions` (both nullable). */
// echo the caller's choice onto the returned row so the just-edited card reflects it (not interface PatientWire extends PatientDto {
// yet persisted server-side). relation: Relation | null;
function toPatient(dto: PatientDto, augment?: Pick<CreatePatientInput, 'relation' | 'conditions'>): Patient { conditions: ConditionCode[] | null;
return { ...dto, relation: augment?.relation ?? null, conditions: augment?.conditions ?? [] };
} }
// Only the wire fields cross the boundary — relation/conditions are client-augmented (REQ-005). function toPatient(dto: PatientWire): Patient {
return { ...dto, relation: dto.relation ?? null, conditions: dto.conditions ?? [] };
}
// REQ-005 (delivered): relation/conditions now cross the wire and are persisted server-side.
function toBody(input: CreatePatientInput) { function toBody(input: CreatePatientInput) {
const { displayName, firstName, lastName, birthDate, gender } = input; const { displayName, firstName, lastName, birthDate, gender } = input;
return { return {
@@ -22,13 +32,15 @@ function toBody(input: CreatePatientInput) {
gender, gender,
bloodType: input.bloodType ?? null, bloodType: input.bloodType ?? null,
initialMedicalNotes: input.initialMedicalNotes ?? null, initialMedicalNotes: input.initialMedicalNotes ?? null,
relation: input.relation,
conditions: input.conditions,
}; };
} }
/** /**
* Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch` * Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch`
* returns the raw envelope, so each call reads its payload via `unwrap`. Selected once * returns the raw envelope, so each call reads its payload via `unwrap`. Primary once
* USE_PATIENTS_MOCK is false and the relation/conditions fields land. * USE_PATIENTS_MOCK is false (refinement-phase-4; REQ-005 delivered).
*/ */
export const patientsClientApi: PatientsApi = { export const patientsClientApi: PatientsApi = {
list: async (params) => { list: async (params) => {
@@ -36,32 +48,30 @@ export const patientsClientApi: PatientsApi = {
if (params?.page) query.set('page', String(params.page)); if (params?.page) query.set('page', String(params.page));
if (params?.pageSize) query.set('pageSize', String(params.pageSize)); if (params?.pageSize) query.set('pageSize', String(params.pageSize));
const qs = query.toString(); const qs = query.toString();
const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientDto>>>(`${BASE}/list${qs ? `?${qs}` : ''}`)); const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientWire>>>(`${BASE}/list${qs ? `?${qs}` : ''}`));
return { ...page, items: page.items.map((dto) => toPatient(dto)) }; return { ...page, items: page.items.map((dto) => toPatient(dto)) };
}, },
get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/get/${id}`))), get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/get/${id}`))),
create: async (input) => create: async (input) =>
toPatient( toPatient(
unwrap( unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/create`, { await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/create`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
), ),
input,
), ),
update: async (id, input) => update: async (id, input) =>
toPatient( toPatient(
unwrap( unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/update/${id}`, { await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/update/${id}`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
), ),
input,
), ),
archive: async (id) => { archive: async (id) => {
+1 -1
View File
@@ -5,7 +5,7 @@
* mock. Flip to false once those fields land — no hook/component changes are needed * mock. Flip to false once those fields land — no hook/component changes are needed
* (see dev/shared-working-context/reports/mocks-registry.md). * (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_PATIENTS_MOCK = true; export const USE_PATIENTS_MOCK = false;
export const PATIENTS_STALE_TIME = 60_000; export const PATIENTS_STALE_TIME = 60_000;
+1 -1
View File
@@ -14,7 +14,7 @@
* store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end. * store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end.
* Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change. * Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change.
*/ */
export const USE_PAYMENT_MOCK = true; export const USE_PAYMENT_MOCK = false;
/** /**
* f11 wired the BNPL method screens (D1D5): C6's «پرداخت اقساطی» seam now navigates into the installment * f11 wired the BNPL method screens (D1D5): C6's «پرداخت اقساطی» seam now navigates into the installment
+38 -14
View File
@@ -25,23 +25,34 @@ async function orNull<T>(promise: Promise<T>): Promise<T | null> {
} }
} }
// The wire DTOs carry no avatar/name yet (REQ-006/007); reads default the augmented fields. /** REQ-006 (delivered): both profile DTOs now carry `avatarUrl`; the customer DTO also carries `preferredLanguage`. */
function toNurseProfile(dto: NurseProfileDto): NurseProfile { interface NurseProfileWire extends NurseProfileDto {
return { ...dto, avatarUrl: null }; avatarUrl: string | null;
} }
function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile { interface CustomerProfileWire extends CustomerProfileDto {
return { ...dto, firstName: null, lastName: null, preferredLanguage: null }; avatarUrl: string | null;
preferredLanguage: string | null;
}
function toNurseProfile(dto: NurseProfileWire): NurseProfile {
return { ...dto, avatarUrl: dto.avatarUrl ?? null };
}
// The customer name lives on `/me` (REQ-007 design), not on `CustomerProfileDto` — the profile screen
// sources first/last name from `useMe`. Here we carry the served `preferredLanguage`; name stays null.
function toCustomerProfile(dto: CustomerProfileWire): CustomerProfile {
return { ...dto, firstName: null, lastName: null, preferredLanguage: dto.preferredLanguage ?? null };
} }
/** /**
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). Selected once * Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). PRIMARY once
* USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet * USE_PROFILES_MOCK is false (refinement-phase-4; REQ-006/007 delivered). Avatar upload posts multipart
* (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only. * to the b3 `nurse_profiles/avatar` route (the JSON-only default is bypassed for `FormData` bodies —
* see `lib/api/client.ts`); the customer name/language reach the server via the upsert body.
*/ */
export const profilesClientApi: ProfilesApi = { export const profilesClientApi: ProfilesApi = {
getCustomerProfile: async () => getCustomerProfile: async () =>
orNull( orNull(
clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) => clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/me`).then((env) =>
toCustomerProfile(unwrap(env)), toCustomerProfile(unwrap(env)),
), ),
), ),
@@ -49,11 +60,15 @@ export const profilesClientApi: ProfilesApi = {
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) => upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
toCustomerProfile( toCustomerProfile(
unwrap( unwrap(
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, { await clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/upsert`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
defaultEmergencyContactName: input.defaultEmergencyContactName, defaultEmergencyContactName: input.defaultEmergencyContactName,
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone, defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
// REQ-007 (delivered): name + preferred language are accepted on the upsert command.
firstName: input.firstName ?? null,
lastName: input.lastName ?? null,
preferredLanguage: input.preferredLanguage ?? null,
}), }),
}), }),
), ),
@@ -61,13 +76,13 @@ export const profilesClientApi: ProfilesApi = {
getNurseProfile: async () => getNurseProfile: async () =>
orNull( orNull(
clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))), clientFetch<ApiEnvelope<NurseProfileWire>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
), ),
upsertNurseProfile: async (input: UpsertNurseProfileInput) => upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
toNurseProfile( toNurseProfile(
unwrap( unwrap(
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/upsert`, { await clientFetch<ApiEnvelope<NurseProfileWire>>(`${BASE}/nurse_profiles/upsert`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
bio: input.bio, bio: input.bio,
@@ -80,7 +95,16 @@ export const profilesClientApi: ProfilesApi = {
), ),
), ),
uploadAvatar: async (): Promise<AvatarUploadResult> => { // REQ-006 (delivered): only the nurse profile screen uploads an avatar today; it persists immediately
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.'); // via the dedicated multipart route and the returned URL is echoed for display + read back on reload.
uploadAvatar: async (file: File): Promise<AvatarUploadResult> => {
const form = new FormData();
form.append('file', file);
return unwrap(
await clientFetch<ApiEnvelope<AvatarUploadResult>>(`${BASE}/nurse_profiles/avatar`, {
method: 'POST',
body: form,
}),
);
}, },
}; };
+1 -1
View File
@@ -5,7 +5,7 @@
* (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land — * (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land —
* no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). * no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_PROFILES_MOCK = true; export const USE_PROFILES_MOCK = false;
/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */ /** Profiles are stable within a session; revisiting a screen shouldn't refetch. */
export const PROFILE_STALE_TIME = 60_000; export const PROFILE_STALE_TIME = 60_000;
+36 -14
View File
@@ -24,11 +24,17 @@ interface NurseReviewsWire {
reviews: Paginated<ReviewListItem>; reviews: Paginated<ReviewListItem>;
} }
/** /** Wire `MyReviewDto` (REQ-026) — keys the moderation state as `moderationStatus` (`'none'` when unreviewed). */
* Wire `ModerationQueueItemDto`. Per the b14 contract it does **not** carry `tagCodes` (REQ-037 — the admin interface MyReviewDto {
* card can't show the review's tags); the client defaults it to `[]` on map. moderationStatus: MyReviewState['status'];
*/ rating: number | null;
type ModerationQueueItemWire = Omit<ModerationQueueItem, 'tagCodes'>; body: string | null;
tagCodes: string[];
createdAt: string | null;
}
/** Wire `ModerationQueueItemDto` — carries `tagCodes` since REQ-037 (delivered in refinement-phase-3). */
type ModerationQueueItemWire = ModerationQueueItem;
/** /**
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`, * Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
@@ -57,14 +63,30 @@ export const reviewsClientApi: ReviewsApi = {
return { aggregate: wire.aggregate, reviews: wire.reviews }; return { aggregate: wire.aggregate, reviews: wire.reviews };
}, },
// REQ-026: proposed owner-scoped read (no wire endpoint yet). 404s until delivered — never called while // REQ-026 (delivered): owner-scoped eligibility read. Wire `reason` is nullable; normalise to `undefined`.
// the domain is mock-primary. Kept symmetric so the swap stays a one-line config flip. getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => {
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => const wire = unwrap(
unwrap(await clientFetch<ApiEnvelope<ReviewEligibility>>(`${API}/bookings/${bookingId}/review_eligibility`)), await clientFetch<ApiEnvelope<{ canReview: boolean; reason: ReviewEligibility['reason'] | null }>>(
`${API}/bookings/${bookingId}/review_eligibility`,
),
);
return { canReview: wire.canReview, reason: wire.reason ?? undefined };
},
// REQ-026: proposed owner-scoped read of the caller's own review for this booking. // REQ-026 (delivered): the caller's own review for this booking + its moderation state. The wire dto keys
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> => // the state as `moderationStatus` (incl. `'none'` when unreviewed); the client model calls it `status`.
unwrap(await clientFetch<ApiEnvelope<MyReviewState>>(`${API}/bookings/${bookingId}/my_review`)), getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> => {
const wire = unwrap(
await clientFetch<ApiEnvelope<MyReviewDto>>(`${API}/bookings/${bookingId}/my_review`),
);
return {
status: wire.moderationStatus,
rating: wire.rating,
body: wire.body,
tagCodes: wire.tagCodes ?? [],
createdAt: wire.createdAt,
};
},
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> => createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> =>
unwrap( unwrap(
@@ -84,8 +106,8 @@ export const reviewsClientApi: ReviewsApi = {
`${API}/admin/reviews/moderation_queue?${query.toString()}`, `${API}/admin/reviews/moderation_queue?${query.toString()}`,
), ),
); );
// REQ-037: the wire dto omits tagCodes default to [] so the admin card renders without them. // REQ-037 (delivered): the wire dto carries tagCodes; default to [] only if the server omits it.
return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: [] })) }; return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: item.tagCodes ?? [] })) };
}, },
moderateReview: async ( moderateReview: async (
+1 -1
View File
@@ -11,7 +11,7 @@
* review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no * review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no
* hook/component change (only `clientApi.ts`'s two gap methods start returning real data). * hook/component change (only `clientApi.ts`'s two gap methods start returning real data).
*/ */
export const USE_REVIEWS_MOCK = true; export const USE_REVIEWS_MOCK = false;
/** Page size for the public nurse-reviews list (api-conventions `pageSize`). */ /** Page size for the public nurse-reviews list (api-conventions `pageSize`). */
export const REVIEWS_PAGE_SIZE = 5; export const REVIEWS_PAGE_SIZE = 5;
+71 -33
View File
@@ -1,11 +1,12 @@
import { clientFetch } from '@/lib/api/client'; import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types'; import type { PriceUnit } from '@/services/catalog/types';
import type { TrustBadge } from '@/services/verification/types';
import { SEARCH_PAGE_SIZE } from '../constants'; import { SEARCH_PAGE_SIZE } from '../constants';
import type { import type {
NurseGender, NurseGender,
NurseProfile, NurseProfile,
NurseProfileServiceRow,
NurseReviewSnippet,
NurseSearchFilters, NurseSearchFilters,
NurseSearchResult, NurseSearchResult,
SearchApi, SearchApi,
@@ -27,23 +28,44 @@ interface NurseSearchResultDto {
totalCompletedBookings: number; totalCompletedBookings: number;
cityId: number; cityId: number;
districtId: number | null; districtId: number | null;
/** REQ-012 — the C2 card identity, now denormalized into the index row. */
nurseName: string | null;
avatarUrl: string | null;
distanceKm: number | null;
} }
/** The INO-membership credential type code (see b6 verification). */ /** The b6/b7 aggregated `NursePublicProfileDto` (REQ-012) — the C3 profile payload. */
const INO_MEMBERSHIP_CODE = 'ino_membership'; interface NursePublicProfileDto {
nurseId: number;
nurseName: string;
avatarUrl: string | null;
bio: string;
yearsExperience: number;
averageRating: number;
totalReviews: number;
totalCompletedBookings: number;
isVerified: boolean;
inoMembership: boolean;
attributeChips: string[];
services: {
variantId: number;
displayName: string;
priceIrr: string;
priceUnit: PriceUnit;
sessionCount: number | null;
}[];
latestReview: { rating: number; body: string | null; authorMasked: string | null; createdAt: string } | null;
}
/** /**
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6 trust badge). Routes are * Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6/b7 public profile). Routes are
* action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and * action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`. * `clientFetch` returns the raw envelope, so we `unwrap()`.
* *
* NOT the primary implementation this phase (`USE_SEARCH_MOCK = true`): b7's index row omits the nurse * The PRIMARY implementation once `USE_SEARCH_MOCK = false` (refinement-phase-4). REQ-012 delivered the
* **display name, avatar, and distance** the C2 card renders, and there is **no** aggregated * discovery enrichment the C2 card + C3 profile need: `nurseName`/`avatarUrl`/`distanceKm` are now
* nurse-profile endpoint (name/bio/specialties/full services list/latest review) for C3 — only the b6 * denormalized onto `NurseSearchResultDto`, and `GET nurses/{id}/profile` aggregates identity + bio +
* trust badge is public. Both gaps are filed in * specialties + the full services list + the latest review. This client maps both 1:1.
* `dev/shared-working-context/frontend/requests/for-backend.md`. This client maps everything b7/b6
* currently provide (leaving the missing fields blank) so the swap is a single config flip once the
* backend lands the join + profile route.
*/ */
export const searchClientApi: SearchApi = { export const searchClientApi: SearchApi = {
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => { searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
@@ -70,16 +92,15 @@ export const searchClientApi: SearchApi = {
nurseId: dto.nurseId, nurseId: dto.nurseId,
variantId: dto.variantId, variantId: dto.variantId,
serviceCategoryId: dto.serviceCategoryId, serviceCategoryId: dto.serviceCategoryId,
// Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label. // REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
nurseName: '', nurseName: dto.nurseName ?? '',
avatarUrl: null, avatarUrl: dto.avatarUrl,
// Every returned row is searchable by the index invariant. // Every returned row is searchable by the index invariant.
isVerified: true, isVerified: true,
averageRating: dto.averageRating, averageRating: dto.averageRating,
totalReviews: dto.totalReviews, totalReviews: dto.totalReviews,
totalCompletedBookings: dto.totalCompletedBookings, totalCompletedBookings: dto.totalCompletedBookings,
// Gap (filed): no geo-distance in the index row yet. distanceKm: dto.distanceKm,
distanceKm: null,
priceFromIrr: dto.price, priceFromIrr: dto.price,
priceUnit: dto.priceUnit, priceUnit: dto.priceUnit,
nurseGender: dto.nurseGender, nurseGender: dto.nurseGender,
@@ -90,26 +111,43 @@ export const searchClientApi: SearchApi = {
}, },
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => { getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
// Only the public trust badge is available today; the aggregated profile (name/bio/specialties/ const dto = unwrap(
// services list/latest review) is filed for the backend. Compose what b6 exposes; leave the rest blank. await clientFetch<ApiEnvelope<NursePublicProfileDto>>(`${NURSES_BASE}/${nurseId}/profile`),
const badge = unwrap(
await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`),
); );
const services: NurseProfileServiceRow[] = dto.services.map((s) => ({
variantId: s.variantId,
displayName: s.displayName,
priceIrr: s.priceIrr,
priceUnit: s.priceUnit,
sessionCount: s.sessionCount,
}));
const latestReview: NurseReviewSnippet | null = dto.latestReview
? {
rating: dto.latestReview.rating,
body: dto.latestReview.body ?? '',
authorMasked: dto.latestReview.authorMasked ?? '',
createdAt: dto.latestReview.createdAt,
}
: null;
return { return {
nurseId: badge.nurseId, nurseId: dto.nurseId,
nurseName: '', nurseName: dto.nurseName,
avatarUrl: null, avatarUrl: dto.avatarUrl,
bio: null, bio: dto.bio || null,
yearsExperience: null, yearsExperience: dto.yearsExperience,
averageRating: 0, averageRating: dto.averageRating,
totalReviews: 0, totalReviews: dto.totalReviews,
totalCompletedBookings: 0, totalCompletedBookings: dto.totalCompletedBookings,
isVerified: badge.isVerified, isVerified: dto.isVerified,
inoMembership: badge.credentialTypes.includes(INO_MEMBERSHIP_CODE), inoMembership: dto.inoMembership,
attributeChips: badge.credentialTypes, attributeChips: dto.attributeChips,
services: [], services,
latestReview: null, latestReview,
// Not carried by the public profile DTO; the same-gender intent for booking comes from the C1
// filter carried through the query string, not this field. Unused by the C3 page.
nurseGender: 'female', nurseGender: 'female',
}; };
}, },
+1 -1
View File
@@ -6,7 +6,7 @@
* mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills * mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills
* the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`). * the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`).
*/ */
export const USE_SEARCH_MOCK = true; export const USE_SEARCH_MOCK = false;
/** /**
* Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache * Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache
@@ -5,7 +5,7 @@
* reachable. Flip to false to use the live `api/v1/nurse_service_areas/*` routes — no * reachable. Flip to false to use the live `api/v1/nurse_service_areas/*` routes — no
* hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). * hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_SERVICE_AREAS_MOCK = true; export const USE_SERVICE_AREAS_MOCK = false;
/** Service areas change only on mutation; keep them warm across screen visits. */ /** Service areas change only on mutation; keep them warm across screen visits. */
export const SERVICE_AREAS_STALE_TIME = 60_000; export const SERVICE_AREAS_STALE_TIME = 60_000;
+21 -7
View File
@@ -21,7 +21,7 @@ import type {
const API = '/api/v1'; const API = '/api/v1';
/** Wire `TicketSummaryDto` (camelCase, per api-conventions). */ /** Wire `TicketSummaryDto` (camelCase). REQ-028 (delivered) added `lastMessageAt`/`unreadCount`. */
interface TicketSummaryWire { interface TicketSummaryWire {
id: number; id: number;
referenceCode: string; referenceCode: string;
@@ -31,6 +31,8 @@ interface TicketSummaryWire {
bookingId: number | null; bookingId: number | null;
refundId: number | null; refundId: number | null;
createdAt: string; createdAt: string;
lastMessageAt: string | null;
unreadCount: number;
} }
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */ /** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
@@ -67,6 +69,9 @@ function mapSummary(w: TicketSummaryWire): TicketSummary {
bookingId: w.bookingId, bookingId: w.bookingId,
refundId: w.refundId, refundId: w.refundId,
createdAt: w.createdAt, createdAt: w.createdAt,
// REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire.
lastMessageAt: w.lastMessageAt,
unreadCount: w.unreadCount,
}; };
} }
@@ -162,9 +167,11 @@ function mapAdminThread(w: TicketThreadWire, viewerUserId?: number): AdminTicket
* - `openTicket` → `POST /tickets`. * - `openTicket` → `POST /tickets`.
* - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`). * - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`).
* *
* NOT the primary implementation this phase (`USE_TICKETS_MOCK = true`) — see `constants.ts`. The wire * PRIMARY once `USE_TICKETS_MOCK = false` (refinement-phase-4; REQ-028 delivered): the summary now carries
* summary has no `unreadCount`/`lastMessageAt` (REQ-028), so those stay undefined here (the inbox degrades). * `unreadCount`/`lastMessageAt` (inbox badge + last-activity sort) and the message post sends the optimistic
* `clientMessageId` is client-only (optimistic reconcile) — not sent (the server has no field for it yet). * `clientMessageId` (server dedupes + echoes it back). The user list still filters only by `Status`; the
* "jump to the existing coordination ticket" by-booking lookup is a minor follow-up (REQ-028 #3 —
* `GET /tickets?BookingId=` is served, but no client method targets it yet).
*/ */
export const ticketsClientApi: TicketsApi = { export const ticketsClientApi: TicketsApi = {
listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => { listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
@@ -202,7 +209,9 @@ export const ticketsClientApi: TicketsApi = {
unwrap( unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, { await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: body.body }), // REQ-028 (delivered): send the optimistic `clientMessageId` so the server dedupes a retried send
// and echoes it back on `PostMessageResult` for reconciliation.
body: JSON.stringify({ body: body.body, clientMessageId: body.clientMessageId }),
}), }),
), ),
@@ -233,12 +242,17 @@ export const ticketsClientApi: TicketsApi = {
return mapAdminThread(wire, viewerUserId); return mapAdminThread(wire, viewerUserId);
}, },
// Staff post — may set `isInternal` (the one caller allowed to). `clientMessageId` stays client-only. // Staff post — may set `isInternal` (the one caller allowed to). REQ-028: send `clientMessageId` too.
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> => postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> =>
unwrap( unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, { await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: body.body, isInternal: body.isInternal }), body: JSON.stringify({
body: body.body,
isInternal: body.isInternal,
clientMessageId: body.clientMessageId,
}),
}), }),
), ),
}; };
+1 -1
View File
@@ -12,7 +12,7 @@
* ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam * ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam
* selection in `apis/index.ts`). * selection in `apis/index.ts`).
*/ */
export const USE_TICKETS_MOCK = true; export const USE_TICKETS_MOCK = false;
/** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */ /** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */
export const TICKETS_PAGE_SIZE = 20; export const TICKETS_PAGE_SIZE = 20;
@@ -57,31 +57,51 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so
the frontend can build before the backend phase merges, and swap to the real HTTP client in one line. the frontend can build before the backend phase merges, and swap to the real HTTP client in one line.
> **Refinement-phase-4 de-mock (2026-07-13) — 14 domains flipped to REAL** (`USE_*_MOCK = false`), verified
> against the regenerated swagger + `npm run check`/`test:ci` green: **geography, patients, profiles,
> nurse (bank), addresses, serviceAreas, catalog, search, bookingRequests, bookings, payment, reviews,
> tickets, notifications** (auth was already real). The flip was **not** a pure flag flip for most: Phase-3
> delivered fields the `clientApi.ts` mappers were written to null-override, so each mapper was updated to
> consume/send them (search name/avatar/distance + `nurses/{id}/profile`; patient relation/conditions;
> address `provinceId`; booking-request `variantPrice`/`bookingId`; ticket `unreadCount`/`lastMessageAt` +
> `clientMessageId`; review `my_review`; profile `avatarUrl`/`preferredLanguage` + a real **multipart avatar
> upload** — `clientFetch` now passes `FormData` through; the customer profile sources name from `/me`). The
> **payment mock-gateway harness page was deleted**; `EVV_GPS_MODE` auto-selects `off` (real geolocation).
>
> **7 domains stay 🟡 mocked — precondition REQ deferred/unsafe (documented, not forgotten):**
> `verification` (REQ-034 admin queue/doc-URL/approve — nurse flow ready, admin half blocks the shared flag),
> `refunds` (REQ-035 admin preview/approve — customer cancel/policy ready), `payouts` (REQ-036 admin
> preview/holidayShifted/transfer-ref — nurse earnings ready), `admin` (REQ-031 RBAC roles — config/audit/
> holidays/alerts ready), `bnpl` (REQ-022 options/schedule + REQ-024 wallet_installments deferred),
> `partnerCenter` (REQ-032/033 portal split reads + REQ-038 `/me` signal deferred), `patientRecords` (REQ-027
> endpoints exist but the client family-record `id` model is `string` vs the wire's `int` → the customer-edit
> PUT is write-unsafe until the id types are reconciled; the nurse visit-note history half is contract-real).
| Seam (interface) | File | What it fakes | Config flag | Make it real → | Status | | Seam (interface) | File | What it fakes | Config flag | Make it real → | Status |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false``patientsClientApi` is already wired to the b3 `patients/*` routes | 🟡 | | `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false``patientsClientApi` is already wired to the b3 `patients/*` routes | 🟢 (real, refinement-phase-4) |
| `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false``profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟡 | | `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false``profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟢 (real, refinement-phase-4) |
| `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false``nurseBankClientApi` is wired | 🟡 | | `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false``nurseBankClientApi` is wired | 🟢 (real, refinement-phase-4) |
| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp``{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available | | `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp``{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available |
| `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false``geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟡 | | `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false``geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟢 (real, refinement-phase-4) |
| `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false``addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟡 | | `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false``addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟢 (real, refinement-phase-4) |
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 | | `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 | | `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 | | `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 | | `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟡 | | `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟢 (real, refinement-phase-4) |
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟡 | | `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟡 | | `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🟡 | | Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🗑 removed in refinement-phase-4 (payment flipped real) |
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 | | `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 | | `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 | | BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 | | `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 |
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟡 | | `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 | | `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟡 | | f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) |
| `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false``ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟡 | | `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false``ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟢 (real, refinement-phase-4) |
| `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟡 | | `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 | | `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 |
| `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 | | `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 |
| Admin-endpoint additions to existing domain mocks (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) | the same `apis/mockApi.ts` files (+ their `clientApi.ts`) | **The f15 staff lens over prior domains** — new admin methods added behind the existing seams (no new seam, no hook/component change on swap). **verification:** a nurse-level review queue (`pending`/`in_review`, one with an expiring credential) + a per-nurse case whose manual credential steps carry a document, and `getDocumentSignedUrl` that returns a **fresh short-lived URL each call** (sentinel `documentId 9999` throws → viewer error/re-request path); `decideStep`/`approve`/`reject` re-aggregate. **refunds:** a `getRefundPreview` with the fee/payout split reconciling to the rial per booking (a normal card, a BNPL w/ ETA, a post-payout w/ clawback notice, and a provider-decline **sentinel that fails then retries succeeds**). **payouts:** batches spanning `completed`/`partially_failed`/`processing` (one holiday-shifted), a preview w/ eligible + skipped(no-IBAN) + clawback line + holiday-shifted date, an **idempotency-keyed** run/retry (same key → same result, never double-pays), a `failed` payout to retry, and record-transfer-reference. **reviews:** a moderation queue incl. a low-rating flagged review; `moderateReview` returns a plausible recomputed aggregate. **tickets:** a global admin queue + a thread that **includes** the seeded internal note (the no-leak *inverse* demo) + `postAdminMessage` w/ `isInternal`; a refund-linked ticket (bookingId+refundId) so the RefundPanel opens from it | the owning domain's flag (`USE_VERIFICATION_MOCK` / `USE_REFUNDS_MOCK` / `USE_PAYOUTS_MOCK` / `USE_REVIEWS_MOCK` / `USE_TICKETS_MOCK`, all default `true`) | Deliver the per-domain admin gaps — **REQ-034** (verification nurse-queue + on-demand doc URL + whole-verification approve/reject), **REQ-035** (refund preview + explicit approve/reject), **REQ-036** (payout single-preview + `holidayShifted` + record-transfer-reference), **REQ-037** (moderation `tagCodes`) — then flip the owning domain's flag. The real `clientApi` methods already map the live admin routes 1:1 and target the proposed slugs for the gaps | 🟡 | | Admin-endpoint additions to existing domain mocks (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) | the same `apis/mockApi.ts` files (+ their `clientApi.ts`) | **The f15 staff lens over prior domains** — new admin methods added behind the existing seams (no new seam, no hook/component change on swap). **verification:** a nurse-level review queue (`pending`/`in_review`, one with an expiring credential) + a per-nurse case whose manual credential steps carry a document, and `getDocumentSignedUrl` that returns a **fresh short-lived URL each call** (sentinel `documentId 9999` throws → viewer error/re-request path); `decideStep`/`approve`/`reject` re-aggregate. **refunds:** a `getRefundPreview` with the fee/payout split reconciling to the rial per booking (a normal card, a BNPL w/ ETA, a post-payout w/ clawback notice, and a provider-decline **sentinel that fails then retries succeeds**). **payouts:** batches spanning `completed`/`partially_failed`/`processing` (one holiday-shifted), a preview w/ eligible + skipped(no-IBAN) + clawback line + holiday-shifted date, an **idempotency-keyed** run/retry (same key → same result, never double-pays), a `failed` payout to retry, and record-transfer-reference. **reviews:** a moderation queue incl. a low-rating flagged review; `moderateReview` returns a plausible recomputed aggregate. **tickets:** a global admin queue + a thread that **includes** the seeded internal note (the no-leak *inverse* demo) + `postAdminMessage` w/ `isInternal`; a refund-linked ticket (bookingId+refundId) so the RefundPanel opens from it | the owning domain's flag (`USE_VERIFICATION_MOCK` / `USE_REFUNDS_MOCK` / `USE_PAYOUTS_MOCK` / `USE_REVIEWS_MOCK` / `USE_TICKETS_MOCK`, all default `true`) | Deliver the per-domain admin gaps — **REQ-034** (verification nurse-queue + on-demand doc URL + whole-verification approve/reject), **REQ-035** (refund preview + explicit approve/reject), **REQ-036** (payout single-preview + `holidayShifted` + record-transfer-reference), **REQ-037** (moderation `tagCodes`) — then flip the owning domain's flag. The real `clientApi` methods already map the live admin routes 1:1 and target the proposed slugs for the gaps | 🟢 (real, refinement-phase-4) |