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)
│ │ │ │ └── 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
│ │ │ │ ├── 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
│ │ │ │ ├── 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)
│ │ │ │ ├── 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=)
│ │ │ │ ├── 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
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`.
- **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,
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/` +
@@ -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 { digitsOnly } from '@/utils';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import { useMe } from '@/services/auth';
import type { CustomerProfile } from '@/services/profiles/types';
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
export default function CustomerProfilePage() {
const { data: profile, isLoading } = useCustomerProfile();
const { data: me } = useMe();
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 ta = useTranslations('address');
const tc = useTranslations('common');
@@ -25,8 +34,8 @@ const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertCustomerProfile();
const [firstName, setFirstName] = useState(initial?.firstName ?? '');
const [lastName, setLastName] = useState(initial?.lastName ?? '');
const [firstName, setFirstName] = useState(initial?.firstName ?? nameFallback.firstName ?? '');
const [lastName, setLastName] = useState(initial?.lastName ?? nameFallback.lastName ?? '');
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
@@ -8,6 +8,13 @@ jest.mock('next-intl', () => ({
}));
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 { 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 reqHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'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) {
reqHeaders['Authorization'] = `Bearer ${token}`;
}
@@ -5,11 +5,14 @@ import type { CreateAddressInput, CustomerAddress, CustomerAddressDto, Addresses
const BASE = '/api/v1/customer_addresses';
// The wire `CustomerAddressDto` has no `provinceId` (REQ-009). Reads default it to null; writes
// echo the caller's chosen province onto the returned row so the just-saved address can be
// re-edited with its cascade prefilled (not yet persisted server-side).
function toAddress(dto: CustomerAddressDto, provinceId?: number | null): CustomerAddress {
return { ...dto, provinceId: provinceId ?? null };
// REQ-009 (delivered): the wire `CustomerAddressDto` now carries `provinceId` (joined from
// `cities.province_id`), so a freshly-fetched address prefills the cascade. The caller's chosen
// province is kept as a fallback for the optimistic just-saved echo.
interface AddressWire extends CustomerAddressDto {
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 —
@@ -43,7 +46,7 @@ export const addressesClientApi: AddressesApi = {
// geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`.
query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE));
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)) };
},
@@ -51,7 +54,7 @@ export const addressesClientApi: AddressesApi = {
create: async (input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/create`, {
await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
@@ -62,7 +65,7 @@ export const addressesClientApi: AddressesApi = {
update: async (id, input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/update/${id}`, {
await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
+1 -1
View File
@@ -7,7 +7,7 @@
* `api/v1/customer_addresses/*` routes — no hook/component changes
* (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. */
export const ADDRESSES_STALE_TIME = 60_000;
@@ -12,43 +12,27 @@ import type {
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
* `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
* `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,
* patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice`
* (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is
* ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once
* the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change).
* PRIMARY once `USE_BOOKING_REQUESTS_MOCK = false` (refinement-phase-4; REQ-013/014/017 delivered): the
* DTO now carries `variantPrice` + `nurseAvatarUrl` (REQ-013) + `bookingId` (REQ-017), and the list item
* carries `variantLabel` + `patientAge` (REQ-014), so the wire maps 1:1 to the app DTO. The `context` arg
* (a mock-only display aid) is ignored here, and the nurse-view masking is done server-side.
*/
export const bookingRequestsClientApi: BookingRequestsApi = {
create: async (payload: CreateBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
unwrap(
await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
get: async (id: number) =>
toDto(unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/get/${id}`))),
get: async (id: number) => unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/get/${id}`)),
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
const query = new URLSearchParams();
@@ -62,22 +46,16 @@ export const bookingRequestsClientApi: BookingRequestsApi = {
},
accept: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
),
unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
reject: async (id: number, payload: RejectBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/reject/${id}`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
unwrap(
await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/reject/${id}`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
cancel: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
),
unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${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
* 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
+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
* 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
+1 -1
View File
@@ -5,7 +5,7 @@
* 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).
*/
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
+1 -1
View File
@@ -5,7 +5,7 @@
* Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes
* (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
@@ -10,7 +10,7 @@
* `__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.
*/
export const USE_NOTIFICATIONS_MOCK = true;
export const USE_NOTIFICATIONS_MOCK = false;
/** Notification-center page size (api-conventions `pageSize`). */
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 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. */
export const BANK_STALE_TIME = 30_000;
+25 -15
View File
@@ -1,17 +1,27 @@
import { clientFetch } from '@/lib/api/client';
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';
// The wire `PatientDto` has no relation/conditions yet (REQ-005). Reads default them; writes
// echo the caller's choice onto the returned row so the just-edited card reflects it (not
// yet persisted server-side).
function toPatient(dto: PatientDto, augment?: Pick<CreatePatientInput, 'relation' | 'conditions'>): Patient {
return { ...dto, relation: augment?.relation ?? null, conditions: augment?.conditions ?? [] };
/** REQ-005 (delivered): the wire `PatientDto` now carries `relation`/`conditions` (both nullable). */
interface PatientWire extends PatientDto {
relation: Relation | null;
conditions: ConditionCode[] | null;
}
// 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) {
const { displayName, firstName, lastName, birthDate, gender } = input;
return {
@@ -22,13 +32,15 @@ function toBody(input: CreatePatientInput) {
gender,
bloodType: input.bloodType ?? null,
initialMedicalNotes: input.initialMedicalNotes ?? null,
relation: input.relation,
conditions: input.conditions,
};
}
/**
* 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
* USE_PATIENTS_MOCK is false and the relation/conditions fields land.
* returns the raw envelope, so each call reads its payload via `unwrap`. Primary once
* USE_PATIENTS_MOCK is false (refinement-phase-4; REQ-005 delivered).
*/
export const patientsClientApi: PatientsApi = {
list: async (params) => {
@@ -36,32 +48,30 @@ export const patientsClientApi: PatientsApi = {
if (params?.page) query.set('page', String(params.page));
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
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)) };
},
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) =>
toPatient(
unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/create`, {
await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input,
),
update: async (id, input) =>
toPatient(
unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/update/${id}`, {
await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input,
),
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
* (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;
+1 -1
View File
@@ -14,7 +14,7 @@
* 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.
*/
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
+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.
function toNurseProfile(dto: NurseProfileDto): NurseProfile {
return { ...dto, avatarUrl: null };
/** REQ-006 (delivered): both profile DTOs now carry `avatarUrl`; the customer DTO also carries `preferredLanguage`. */
interface NurseProfileWire extends NurseProfileDto {
avatarUrl: string | null;
}
function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile {
return { ...dto, firstName: null, lastName: null, preferredLanguage: null };
interface CustomerProfileWire extends CustomerProfileDto {
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
* USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet
* (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only.
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). PRIMARY once
* USE_PROFILES_MOCK is false (refinement-phase-4; REQ-006/007 delivered). Avatar upload posts multipart
* 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 = {
getCustomerProfile: async () =>
orNull(
clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) =>
clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/me`).then((env) =>
toCustomerProfile(unwrap(env)),
),
),
@@ -49,11 +60,15 @@ export const profilesClientApi: ProfilesApi = {
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
toCustomerProfile(
unwrap(
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, {
await clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/upsert`, {
method: 'POST',
body: JSON.stringify({
defaultEmergencyContactName: input.defaultEmergencyContactName,
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 () =>
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) =>
toNurseProfile(
unwrap(
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/upsert`, {
await clientFetch<ApiEnvelope<NurseProfileWire>>(`${BASE}/nurse_profiles/upsert`, {
method: 'POST',
body: JSON.stringify({
bio: input.bio,
@@ -80,7 +95,16 @@ export const profilesClientApi: ProfilesApi = {
),
),
uploadAvatar: async (): Promise<AvatarUploadResult> => {
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.');
// REQ-006 (delivered): only the nurse profile screen uploads an avatar today; it persists immediately
// 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 —
* 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. */
export const PROFILE_STALE_TIME = 60_000;
+36 -14
View File
@@ -24,11 +24,17 @@ interface NurseReviewsWire {
reviews: Paginated<ReviewListItem>;
}
/**
* Wire `ModerationQueueItemDto`. Per the b14 contract it does **not** carry `tagCodes` (REQ-037 — the admin
* card can't show the review's tags); the client defaults it to `[]` on map.
*/
type ModerationQueueItemWire = Omit<ModerationQueueItem, 'tagCodes'>;
/** Wire `MyReviewDto` (REQ-026) — keys the moderation state as `moderationStatus` (`'none'` when unreviewed). */
interface MyReviewDto {
moderationStatus: MyReviewState['status'];
rating: number | null;
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`,
@@ -57,14 +63,30 @@ export const reviewsClientApi: ReviewsApi = {
return { aggregate: wire.aggregate, reviews: wire.reviews };
},
// REQ-026: proposed owner-scoped read (no wire endpoint yet). 404s until delivered — never called while
// the domain is mock-primary. Kept symmetric so the swap stays a one-line config flip.
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> =>
unwrap(await clientFetch<ApiEnvelope<ReviewEligibility>>(`${API}/bookings/${bookingId}/review_eligibility`)),
// REQ-026 (delivered): owner-scoped eligibility read. Wire `reason` is nullable; normalise to `undefined`.
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => {
const wire = unwrap(
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.
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> =>
unwrap(await clientFetch<ApiEnvelope<MyReviewState>>(`${API}/bookings/${bookingId}/my_review`)),
// REQ-026 (delivered): the caller's own review for this booking + its moderation state. The wire dto keys
// the state as `moderationStatus` (incl. `'none'` when unreviewed); the client model calls it `status`.
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> =>
unwrap(
@@ -84,8 +106,8 @@ export const reviewsClientApi: ReviewsApi = {
`${API}/admin/reviews/moderation_queue?${query.toString()}`,
),
);
// REQ-037: the wire dto omits tagCodes default to [] so the admin card renders without them.
return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: [] })) };
// 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: item.tagCodes ?? [] })) };
},
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
* 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`). */
export const REVIEWS_PAGE_SIZE = 5;
+71 -33
View File
@@ -1,11 +1,12 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types';
import type { TrustBadge } from '@/services/verification/types';
import { SEARCH_PAGE_SIZE } from '../constants';
import type {
NurseGender,
NurseProfile,
NurseProfileServiceRow,
NurseReviewSnippet,
NurseSearchFilters,
NurseSearchResult,
SearchApi,
@@ -27,23 +28,44 @@ interface NurseSearchResultDto {
totalCompletedBookings: number;
cityId: number;
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). */
const INO_MEMBERSHIP_CODE = 'ino_membership';
/** The b6/b7 aggregated `NursePublicProfileDto` (REQ-012) — the C3 profile payload. */
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
* `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
* **display name, avatar, and distance** the C2 card renders, and there is **no** aggregated
* nurse-profile endpoint (name/bio/specialties/full services list/latest review) for C3 — only the b6
* trust badge is public. Both gaps are filed in
* `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.
* The PRIMARY implementation once `USE_SEARCH_MOCK = false` (refinement-phase-4). REQ-012 delivered the
* discovery enrichment the C2 card + C3 profile need: `nurseName`/`avatarUrl`/`distanceKm` are now
* denormalized onto `NurseSearchResultDto`, and `GET nurses/{id}/profile` aggregates identity + bio +
* specialties + the full services list + the latest review. This client maps both 1:1.
*/
export const searchClientApi: SearchApi = {
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
@@ -70,16 +92,15 @@ export const searchClientApi: SearchApi = {
nurseId: dto.nurseId,
variantId: dto.variantId,
serviceCategoryId: dto.serviceCategoryId,
// Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label.
nurseName: '',
avatarUrl: null,
// REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
nurseName: dto.nurseName ?? '',
avatarUrl: dto.avatarUrl,
// Every returned row is searchable by the index invariant.
isVerified: true,
averageRating: dto.averageRating,
totalReviews: dto.totalReviews,
totalCompletedBookings: dto.totalCompletedBookings,
// Gap (filed): no geo-distance in the index row yet.
distanceKm: null,
distanceKm: dto.distanceKm,
priceFromIrr: dto.price,
priceUnit: dto.priceUnit,
nurseGender: dto.nurseGender,
@@ -90,26 +111,43 @@ export const searchClientApi: SearchApi = {
},
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
// Only the public trust badge is available today; the aggregated profile (name/bio/specialties/
// services list/latest review) is filed for the backend. Compose what b6 exposes; leave the rest blank.
const badge = unwrap(
await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`),
const dto = unwrap(
await clientFetch<ApiEnvelope<NursePublicProfileDto>>(`${NURSES_BASE}/${nurseId}/profile`),
);
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 {
nurseId: badge.nurseId,
nurseName: '',
avatarUrl: null,
bio: null,
yearsExperience: null,
averageRating: 0,
totalReviews: 0,
totalCompletedBookings: 0,
isVerified: badge.isVerified,
inoMembership: badge.credentialTypes.includes(INO_MEMBERSHIP_CODE),
attributeChips: badge.credentialTypes,
services: [],
latestReview: null,
nurseId: dto.nurseId,
nurseName: dto.nurseName,
avatarUrl: dto.avatarUrl,
bio: dto.bio || null,
yearsExperience: dto.yearsExperience,
averageRating: dto.averageRating,
totalReviews: dto.totalReviews,
totalCompletedBookings: dto.totalCompletedBookings,
isVerified: dto.isVerified,
inoMembership: dto.inoMembership,
attributeChips: dto.attributeChips,
services,
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',
};
},
+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
* 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
@@ -5,7 +5,7 @@
* 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).
*/
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. */
export const SERVICE_AREAS_STALE_TIME = 60_000;
+21 -7
View File
@@ -21,7 +21,7 @@ import type {
const API = '/api/v1';
/** Wire `TicketSummaryDto` (camelCase, per api-conventions). */
/** Wire `TicketSummaryDto` (camelCase). REQ-028 (delivered) added `lastMessageAt`/`unreadCount`. */
interface TicketSummaryWire {
id: number;
referenceCode: string;
@@ -31,6 +31,8 @@ interface TicketSummaryWire {
bookingId: number | null;
refundId: number | null;
createdAt: string;
lastMessageAt: string | null;
unreadCount: number;
}
/** 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,
refundId: w.refundId,
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`.
* - `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
* summary has no `unreadCount`/`lastMessageAt` (REQ-028), so those stay undefined here (the inbox degrades).
* `clientMessageId` is client-only (optimistic reconcile) — not sent (the server has no field for it yet).
* PRIMARY once `USE_TICKETS_MOCK = false` (refinement-phase-4; REQ-028 delivered): the summary now carries
* `unreadCount`/`lastMessageAt` (inbox badge + last-activity sort) and the message post sends the optimistic
* `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 = {
listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
@@ -202,7 +209,9 @@ export const ticketsClientApi: TicketsApi = {
unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
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);
},
// 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> =>
unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
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
* 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). */
export const TICKETS_PAGE_SIZE = 20;