frontend phase 3: geography — addresses, map-pin picker & nurse coverage areas

Three domain services (mirroring the patients/nurse template): services/geography
(cached province→city→district lookups; Infinity staleTime + shared geographyKeys),
services/addresses (address book CRUD + set-primary; single-primary invariant), and
services/serviceAreas (coverage add/remove; areaExists dup-guard, districtId=null = whole city).

Four tested composites in src/components/geography: CascadingRegionSelect (drives the
cascade queries), AddressMapPicker (map-pin stand-in emitting real lat/lng), AddressForm,
AddressCard. Screens: customer address book (/addresses, reached from the profile hub) and
nurse coverage editor (/nurse/coverage, new sidebar tab, inline duplicate block + 409).

Adds geo/address/coverage i18n namespaces (both locales), location/delete/coverage icons,
ADDRESSES/NURSE_COVERAGE routes. Consumes the b4 geography-addresses contract; filed REQ-008
(accept the map pin on create/update) and REQ-009 (provinceId on CustomerAddressDto) for gaps.

Gate: npm run check + npm run test:ci (129, +17) + npm run build all green. A 5-dimension
adversarial review fixed 3 findings (map-marker RTL transform, page_size→pageSize pagination
casing, coverage districts-scope dead-end on district-less cities).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:50:41 +03:30
parent 1c266523bc
commit b8934f531d
56 changed files with 2627 additions and 3 deletions
+15
View File
@@ -122,12 +122,14 @@ client/
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/page.tsx # /bookings │ │ │ ├── bookings/page.tsx # /bookings
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/page.tsx # /wallet │ │ │ ├── wallet/page.tsx # /wallet
│ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID) │ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard) │ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
│ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked)
│ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch) │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch)
│ │ │ ├── verification/page.tsx # /nurse/verification │ │ │ ├── verification/page.tsx # /nurse/verification
│ │ │ └── visits/page.tsx # /nurse/visits (EVV) │ │ │ └── visits/page.tsx # /nurse/visits (EVV)
@@ -151,6 +153,7 @@ client/
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit │ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
│ ├── PatientCard/ # E1 patient summary card + edit/archive actions │ ├── PatientCard/ # E1 patient summary card + edit/archive actions
│ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN │ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/ ├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
@@ -195,6 +198,9 @@ client/
│ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper │ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam) │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
│ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states │ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
│ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search)
│ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation)
│ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city)
│ └── {domain}/ │ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical) │ ├── keys.ts # React Query key factory (hierarchical)
@@ -282,6 +288,9 @@ async function MyServerComponent() {
- `'profile'` — the customer profile + emergency contact - `'profile'` — the customer profile + emergency contact
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder) - `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)
- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states) - `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states)
- `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints)
- `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link
- `'coverage'` — the nurse coverage-area editor (whole-city/specific-district scope, chips, duplicate + "won't appear in search" warnings)
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) - `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
@@ -518,6 +527,12 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api`
- **Caching is deliberate:** set a `staleTime` on reads so revisiting a screen doesn't refetch; mutations - **Caching is deliberate:** set a `staleTime` on reads so revisiting a screen doesn't refetch; mutations
**invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` — never leave the **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` — never leave the
cache stale. See `services/patients/hooks/*`. cache stale. See `services/patients/hooks/*`.
- **Reference data is cached for the whole session:** rarely-changing lookups (the geo province→city→district
hierarchy) use an **Infinite `staleTime`** + a shared, hierarchical key factory (`geographyKeys`) so each
level is fetched **once** and served from cache across every consumer (the address form, the coverage editor,
and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage
areas) which invalidate on every mutation. See `services/geography/*`. Reuse this pattern for future
reference data; do not reinvent per-consumer fetching.
- **Mock behind a seam:** when the backend endpoint isn't live, implement the domain's `Api` interface - **Mock behind a seam:** when the backend endpoint isn't live, implement the domain's `Api` interface
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
+70
View File
@@ -6,6 +6,7 @@
"wallet": "Wallet", "wallet": "Wallet",
"profile": "Profile", "profile": "Profile",
"bank": "Bank account", "bank": "Bank account",
"coverage": "Coverage",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"verification": "Verification", "verification": "Verification",
"visits": "Visits", "visits": "Visits",
@@ -165,6 +166,75 @@
"empty_title": "No bank account yet", "empty_title": "No bank account yet",
"empty_body": "Add a bank account in your own name to receive payouts." "empty_body": "Add a bank account in your own name to receive payouts."
}, },
"geo": {
"province": "Province",
"city": "City",
"district": "District",
"whole_city": "Whole city",
"city_needs_province": "Choose a province first",
"district_needs_city": "Choose a city first",
"district_optional": "Optional",
"no_districts": "This city has no districts — whole city"
},
"address": {
"title": "Addresses",
"subtitle": "Where nurses will visit.",
"add": "Add address",
"add_title": "Add address",
"edit_title": "Edit address",
"edit": "Edit",
"delete": "Delete",
"empty_title": "No addresses yet",
"empty_body": "Add your first address so nurses know where to go.",
"primary": "Primary",
"set_primary": "Set as primary",
"primary_set": "Primary address updated",
"delete_title": "Delete address?",
"delete_body": "This address is removed from your list.",
"delete_confirm": "Delete",
"deleted": "Address deleted",
"saved": "Address saved",
"save_error": "This address couldn't be saved. Please try again.",
"unavailable": "This address isn't available to you.",
"title_label": "Address label",
"title_placeholder": "e.g. Home, Work",
"title_required": "Enter a label for this address",
"line_label": "Street address",
"line_hint": "Building, street, unit — the detail a nurse needs to find the door.",
"line_required": "Enter the street address",
"city_required": "Select a city",
"set_primary_toggle": "Set as primary address",
"map_hint": "Tap or drag the marker to the patient's exact location.",
"map_required": "Drop a pin on the map",
"map_lat": "Lat",
"map_lng": "Lng",
"manage_title": "Your addresses",
"manage_body": "Manage the places nurses visit.",
"manage_cta": "Manage addresses"
},
"coverage": {
"title": "Coverage areas",
"subtitle": "The cities and districts you'll travel to.",
"areas_heading": "Your areas",
"add_title": "Add a coverage area",
"scope_label": "Coverage",
"scope_whole_city": "Whole city",
"scope_districts": "Specific districts",
"add": "Add area",
"adding": "Adding…",
"whole_city_chip": "Whole city",
"empty_title": "No coverage areas yet",
"empty_warning": "You won't appear in search until you add at least one coverage area.",
"duplicate": "You already cover this area.",
"city_required": "Select a city",
"district_required": "Select a district, or switch to whole city.",
"added": "Coverage area added",
"add_error": "This area couldn't be added. Please try again.",
"remove_title": "Remove coverage area?",
"remove_body": "You'll no longer be matched for visits in this area.",
"remove_confirm": "Remove",
"removed": "Coverage area removed"
},
"auth": { "auth": {
"customer_title": "Sign in to Balinyaar", "customer_title": "Sign in to Balinyaar",
"customer_subtitle": "Sign in with your mobile number", "customer_subtitle": "Sign in with your mobile number",
+70
View File
@@ -6,6 +6,7 @@
"wallet": "کیف‌پول", "wallet": "کیف‌پول",
"profile": "پروفایل", "profile": "پروفایل",
"bank": "حساب بانکی", "bank": "حساب بانکی",
"coverage": "پوشش",
"dashboard": "داشبورد", "dashboard": "داشبورد",
"verification": "احراز هویت", "verification": "احراز هویت",
"visits": "ویزیت‌ها", "visits": "ویزیت‌ها",
@@ -165,6 +166,75 @@
"empty_title": "هنوز حسابی ثبت نشده", "empty_title": "هنوز حسابی ثبت نشده",
"empty_body": "برای دریافت درآمد، یک حساب بانکی به نام خودتان اضافه کنید." "empty_body": "برای دریافت درآمد، یک حساب بانکی به نام خودتان اضافه کنید."
}, },
"geo": {
"province": "استان",
"city": "شهر",
"district": "منطقه",
"whole_city": "کل شهر",
"city_needs_province": "ابتدا استان را انتخاب کنید",
"district_needs_city": "ابتدا شهر را انتخاب کنید",
"district_optional": "اختیاری",
"no_districts": "این شهر منطقه‌بندی ندارد — کل شهر"
},
"address": {
"title": "آدرس‌ها",
"subtitle": "جایی که پرستار به آن مراجعه می‌کند.",
"add": "افزودن آدرس",
"add_title": "افزودن آدرس",
"edit_title": "ویرایش آدرس",
"edit": "ویرایش",
"delete": "حذف",
"empty_title": "هنوز آدرسی ثبت نشده",
"empty_body": "اولین آدرس را اضافه کنید تا پرستار بداند کجا بیاید.",
"primary": "آدرس اصلی",
"set_primary": "انتخاب به‌عنوان آدرس اصلی",
"primary_set": "آدرس اصلی به‌روزرسانی شد",
"delete_title": "حذف آدرس؟",
"delete_body": "این آدرس از فهرست شما حذف می‌شود.",
"delete_confirm": "حذف",
"deleted": "آدرس حذف شد",
"saved": "آدرس ذخیره شد",
"save_error": "ذخیرهٔ این آدرس ممکن نشد. دوباره تلاش کنید.",
"unavailable": "این آدرس در دسترس شما نیست.",
"title_label": "عنوان آدرس",
"title_placeholder": "مثلاً خانه، محل کار",
"title_required": "برای این آدرس یک عنوان وارد کنید",
"line_label": "نشانی کامل",
"line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن در نیاز دارد.",
"line_required": "نشانی کامل را وارد کنید",
"city_required": "شهر را انتخاب کنید",
"set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود",
"map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.",
"map_required": "روی نقشه یک پین بگذارید",
"map_lat": "عرض",
"map_lng": "طول",
"manage_title": "آدرس‌های شما",
"manage_body": "مکان‌هایی که پرستار به آن‌ها می‌رود را مدیریت کنید.",
"manage_cta": "مدیریت آدرس‌ها"
},
"coverage": {
"title": "مناطق تحت پوشش",
"subtitle": "شهرها و مناطقی که به آن‌ها می‌روید.",
"areas_heading": "مناطق شما",
"add_title": "افزودن منطقهٔ تحت پوشش",
"scope_label": "گستره",
"scope_whole_city": "کل شهر",
"scope_districts": "مناطق خاص",
"add": "افزودن منطقه",
"adding": "در حال افزودن…",
"whole_city_chip": "کل شهر",
"empty_title": "هنوز منطقه‌ای ثبت نشده",
"empty_warning": "تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جست‌وجو نمایش داده نمی‌شوید.",
"duplicate": "این منطقه از قبل تحت پوشش شماست.",
"city_required": "شهر را انتخاب کنید",
"district_required": "یک منطقه انتخاب کنید یا به کل شهر تغییر دهید.",
"added": "منطقهٔ تحت پوشش اضافه شد",
"add_error": "افزودن این منطقه ممکن نشد. دوباره تلاش کنید.",
"remove_title": "حذف منطقهٔ تحت پوشش؟",
"remove_body": "دیگر برای ویزیت در این منطقه انتخاب نمی‌شوید.",
"remove_confirm": "حذف",
"removed": "منطقهٔ تحت پوشش حذف شد"
},
"auth": { "auth": {
"customer_title": "ورود به بلینیار", "customer_title": "ورود به بلینیار",
"customer_subtitle": "با شماره موبایل خود وارد شوید", "customer_subtitle": "با شماره موبایل خود وارد شوید",
@@ -0,0 +1,216 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { AddressCard, AddressForm } from '@/components/geography';
import {
useAddresses,
useCreateAddress,
useUpdateAddress,
useDeleteAddress,
useSetPrimaryAddress,
} from '@/services/addresses';
import type { CreateAddressInput, CustomerAddress } from '@/services/addresses/types';
/**
* The customer address book — a cached, invalidate-on-mutation list of the customer's saved
* addresses with add/edit (the cascading dropdowns + map pin in a dialog), soft-delete (confirm),
* and set-primary (exactly one badge). Loading skeleton + empty state both handled. The chosen
* address later feeds the f7 booking request.
*/
export default function AddressesPage() {
const t = useTranslations('address');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useAddresses();
const createAddress = useCreateAddress();
const updateAddress = useUpdateAddress();
const deleteAddress = useDeleteAddress();
const setPrimary = useSetPrimaryAddress();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<CustomerAddress | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CustomerAddress | null>(null);
const openAdd = () => {
setEditing(null);
setFormOpen(true);
};
const openEdit = (address: CustomerAddress) => {
setEditing(address);
setFormOpen(true);
};
const closeForm = () => setFormOpen(false);
// Address district is optional granularity: show "city · district" when set, else just the city.
const regionLabel = (address: CustomerAddress) => {
const city = locale === 'en' ? address.cityNameEn : address.cityNameFa;
if (address.districtId == null) return city;
const district = locale === 'en' ? address.districtNameEn : address.districtNameFa;
return `${city} · ${district}`;
};
const handleSubmit = (input: CreateAddressInput) => {
const onSuccess = () => {
closeForm();
enqueueSnackbar(t('saved'), { variant: 'success' });
};
const onError = () => enqueueSnackbar(t('save_error'), { variant: 'error' });
if (editing) {
updateAddress.mutate({ id: editing.id, input }, { onSuccess, onError });
} else {
createAddress.mutate(input, { onSuccess, onError });
}
};
const confirmDelete = () => {
if (!deleteTarget) return;
const id = deleteTarget.id;
setDeleteTarget(null);
deleteAddress.mutate(id, {
onSuccess: () => enqueueSnackbar(t('deleted'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }),
});
};
const addresses = data?.items ?? [];
const isEmpty = !isLoading && addresses.length === 0;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack direction="row" sx={{ alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{!isEmpty ? (
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ m: 0, flexShrink: 0 }}>
{t('add')}
</AppButton>
) : null}
</Stack>
{isLoading ? (
<Stack sx={{ gap: 1.5 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={104} />
))}
</Stack>
) : isEmpty ? (
<Paper
elevation={0}
sx={{
p: 4,
textAlign: 'center',
border: '1px dashed',
borderColor: 'divider',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<AppIcon icon="location" size={40} color="var(--bal-primary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_body')}
</Typography>
<AppButton color="primary" variant="contained" startIcon="add" onClick={openAdd} sx={{ mt: 1 }}>
{t('add')}
</AppButton>
</Paper>
) : (
<Stack sx={{ gap: 1.5 }}>
{addresses.map((address) => (
<AddressCard
key={address.id}
title={address.title}
regionLabel={regionLabel(address)}
addressLine={address.addressLine}
isPrimary={address.isPrimary}
primaryLabel={t('primary')}
onEdit={() => openEdit(address)}
onDelete={() => setDeleteTarget(address)}
onSetPrimary={() =>
setPrimary.mutate(address.id, {
onSuccess: () => enqueueSnackbar(t('primary_set'), { variant: 'success' }),
onError: () => enqueueSnackbar(t('unavailable'), { variant: 'error' }),
})
}
settingPrimary={setPrimary.isPending}
editLabel={t('edit')}
deleteLabel={t('delete')}
setPrimaryLabel={t('set_primary')}
/>
))}
</Stack>
)}
<Dialog open={formOpen} onClose={closeForm} fullWidth maxWidth="sm">
<DialogTitle>{editing ? t('edit_title') : t('add_title')}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 1 }}>
<AddressForm
key={editing?.id ?? 'new'}
initial={
editing
? {
title: editing.title,
provinceId: editing.provinceId,
cityId: editing.cityId,
districtId: editing.districtId,
addressLine: editing.addressLine,
latitude: editing.latitude,
longitude: editing.longitude,
isPrimary: editing.isPrimary,
}
: undefined
}
submitting={createAddress.isPending || updateAddress.isPending}
onSubmit={handleSubmit}
onCancel={closeForm}
/>
</Box>
</DialogContent>
</Dialog>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
<DialogTitle>{t('delete_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('delete_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setDeleteTarget(null)}>
{tc('cancel')}
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmDelete}>
{t('delete_confirm')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
}
@@ -1,10 +1,11 @@
'use client'; 'use client';
import { FunctionComponent, useState } from 'react'; import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Divider, MenuItem, Stack, TextField, Typography } from '@mui/material'; import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import { AppButton, AppLoading, PhoneNumberField } from '@/components'; import { AppButton, AppIcon, AppLoading, PhoneNumberField } from '@/components';
import { isIranianMobile } from '@/components/PhoneNumberField'; import { isIranianMobile } from '@/components/PhoneNumberField';
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 type { CustomerProfile } from '@/services/profiles/types'; import type { CustomerProfile } from '@/services/profiles/types';
@@ -18,7 +19,9 @@ export default function CustomerProfilePage() {
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => { const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => {
const t = useTranslations('profile'); const t = useTranslations('profile');
const ta = useTranslations('address');
const tc = useTranslations('common'); const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertCustomerProfile(); const upsert = useUpsertCustomerProfile();
@@ -123,6 +126,33 @@ const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }
> >
{upsert.isPending ? tc('saving') : t('save')} {upsert.isPending ? tc('saving') : t('save')}
</AppButton> </AppButton>
<Divider />
{/* Address book lives alongside the profile in the customer area; a booking (f7) needs a
chosen address, so the entry point is surfaced here on the settings hub. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', gap: 2 }}>
<AppIcon icon="location" size={28} color="var(--bal-primary)" />
<Stack sx={{ gap: 1, flexGrow: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{ta('manage_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{ta('manage_body')}
</Typography>
</Box>
<AppButton
color="primary"
variant="outlined"
startIcon="location"
to={`/${locale}${ROUTES.ADDRESSES}`}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{ta('manage_cta')}
</AppButton>
</Stack>
</Paper>
</Box> </Box>
); );
}; };
@@ -0,0 +1,265 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Paper,
Skeleton,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { AppButton, AppIcon } from '@/components';
import { CascadingRegionSelect, type CascadingRegionValue } from '@/components/geography';
import { ApiError } from '@/lib/api/errors';
import { useDistricts } from '@/services/geography';
import { useServiceAreas, useAddServiceArea, useRemoveServiceArea } from '@/services/serviceAreas';
import { areaExists, type NurseServiceArea } from '@/services/serviceAreas/types';
type Scope = 'whole_city' | 'districts';
const EMPTY_REGION: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
/**
* The nurse coverage-area editor — the cities/districts a nurse will travel to, so search (f6)
* can fan them out geographically. Areas render as chips (whole-city shown explicitly); the add
* control is the cascading dropdowns + a whole-city vs specific-districts scope toggle. A
* duplicate `(city, district)` is blocked inline before the request (and the server's 409 maps to
* the same message). Empty → a warning that the nurse won't appear in search.
*/
export default function NurseCoveragePage() {
const t = useTranslations('coverage');
const tc = useTranslations('common');
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading } = useServiceAreas();
const addArea = useAddServiceArea();
const removeArea = useRemoveServiceArea();
const [region, setRegion] = useState<CascadingRegionValue>(EMPTY_REGION);
const [scope, setScope] = useState<Scope>('whole_city');
const [cityError, setCityError] = useState(false);
const [districtError, setDistrictError] = useState(false);
const [duplicate, setDuplicate] = useState(false);
const [removeTarget, setRemoveTarget] = useState<NurseServiceArea | null>(null);
const areas = data?.items ?? [];
// A whole-city-only city (no districts, e.g. Mashhad) can't satisfy "specific districts" — reads the
// same cached districts query the cascade uses to force whole-city, so the toggle never dead-ends on a
// district that cannot exist.
const districtsQuery = useDistricts(region.cityId);
const cityHasNoDistricts =
region.cityId != null && districtsQuery.isSuccess && (districtsQuery.data?.length ?? 0) === 0;
const effectiveScope: Scope = cityHasNoDistricts ? 'whole_city' : scope;
const chipLabel = (area: NurseServiceArea) => {
const city = locale === 'en' ? area.cityNameEn : area.cityNameFa;
if (area.isWholeCity) return `${city} · ${t('whole_city_chip')}`;
const district = locale === 'en' ? area.districtNameEn : area.districtNameFa;
return `${city} · ${district}`;
};
const changeScope = (next: Scope | null) => {
if (!next) return;
setScope(next);
setDistrictError(false);
setDuplicate(false);
// Whole-city ignores any picked district — clear it so the submitted pair is unambiguous.
if (next === 'whole_city') setRegion((prev) => ({ ...prev, districtId: null }));
};
const resetForm = () => {
setRegion(EMPTY_REGION);
setScope('whole_city');
setCityError(false);
setDistrictError(false);
setDuplicate(false);
};
const handleAdd = () => {
const cityInvalid = region.cityId == null;
const districtInvalid = effectiveScope === 'districts' && region.districtId == null;
setCityError(cityInvalid);
setDistrictError(districtInvalid);
setDuplicate(false);
if (cityInvalid || districtInvalid) return;
const cityId = region.cityId as number;
const districtId = effectiveScope === 'whole_city' ? null : region.districtId;
// Fast path: block a duplicate before firing (null district treated as a real value).
if (areaExists(areas, cityId, districtId)) {
setDuplicate(true);
return;
}
addArea.mutate(
{ cityId, districtId },
{
onSuccess: () => {
resetForm();
enqueueSnackbar(t('added'), { variant: 'success' });
},
onError: (error) => {
// Belt-and-braces: the server's UNIQUE 409 maps to the same inline duplicate message.
if (error instanceof ApiError && error.status === 409) setDuplicate(true);
else enqueueSnackbar(t('add_error'), { variant: 'error' });
},
},
);
};
const confirmRemove = () => {
if (!removeTarget) return;
const id = removeTarget.id;
setRemoveTarget(null);
removeArea.mutate(id, {
onSuccess: () => enqueueSnackbar(t('removed'), { variant: 'success' }),
});
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 560 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" width={160} height={32} />
))}
</Stack>
) : areas.length > 0 ? (
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('areas_heading')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{areas.map((area) => (
<Chip
key={area.id}
label={chipLabel(area)}
onDelete={() => setRemoveTarget(area)}
sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 600 }}
/>
))}
</Box>
</Stack>
) : (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-warning)',
}}
>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'flex-start' }}>
<AppIcon icon="warning" size={24} color="var(--bal-warning)" />
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('empty_warning')}
</Typography>
</Box>
</Stack>
</Paper>
)}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('add_title')}
</Typography>
<Stack sx={{ gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('scope_label')}
</Typography>
<ToggleButtonGroup
exclusive
size="small"
color="primary"
value={effectiveScope}
onChange={(_event, next: Scope | null) => changeScope(next)}
>
<ToggleButton value="whole_city">{t('scope_whole_city')}</ToggleButton>
{/* A district-less city forces whole-city — disable the option rather than dead-end on it. */}
<ToggleButton value="districts" disabled={cityHasNoDistricts}>
{t('scope_districts')}
</ToggleButton>
</ToggleButtonGroup>
</Stack>
<CascadingRegionSelect
value={region}
onChange={(next) => {
setRegion(next);
if (cityError && next.cityId != null) setCityError(false);
if (districtError && next.districtId != null) setDistrictError(false);
setDuplicate(false);
}}
includeDistrict={effectiveScope === 'districts'}
cityError={cityError}
cityErrorText={t('city_required')}
districtError={districtError}
districtErrorText={t('district_required')}
/>
{duplicate ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('duplicate')}
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
startIcon="add"
onClick={handleAdd}
disabled={addArea.isPending}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{addArea.isPending ? t('adding') : t('add')}
</AppButton>
</Stack>
</Paper>
<Dialog open={Boolean(removeTarget)} onClose={() => setRemoveTarget(null)}>
<DialogTitle>{t('remove_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('remove_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setRemoveTarget(null)}>
{tc('cancel')}
</AppButton>
<AppButton color="error" variant="contained" onClick={confirmRemove}>
{t('remove_confirm')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
}
@@ -37,6 +37,9 @@ import ArchiveIcon from '@mui/icons-material/Inventory2Outlined';
import BankIcon from '@mui/icons-material/AccountBalanceOutlined'; import BankIcon from '@mui/icons-material/AccountBalanceOutlined';
import CameraIcon from '@mui/icons-material/PhotoCameraOutlined'; import CameraIcon from '@mui/icons-material/PhotoCameraOutlined';
import WarningIcon from '@mui/icons-material/WarningAmberOutlined'; import WarningIcon from '@mui/icons-material/WarningAmberOutlined';
import LocationIcon from '@mui/icons-material/LocationOnOutlined';
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
import CoverageIcon from '@mui/icons-material/MapOutlined';
/** /**
* List of all available Icon names * List of all available Icon names
@@ -89,4 +92,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
bank: BankIcon, bank: BankIcon,
camera: CameraIcon, camera: CameraIcon,
warning: WarningIcon, warning: WarningIcon,
location: LocationIcon,
delete: DeleteIcon,
coverage: CoverageIcon,
}; };
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
import AddressCard, { type AddressCardProps } from './AddressCard';
const BASE: AddressCardProps = {
title: 'Home',
regionLabel: 'Tehran · District 1',
addressLine: 'No. 5, Vali St',
isPrimary: false,
primaryLabel: 'Primary',
onEdit: jest.fn(),
onDelete: jest.fn(),
onSetPrimary: jest.fn(),
editLabel: 'Edit',
deleteLabel: 'Delete',
setPrimaryLabel: 'Set as primary',
};
function renderCard(overrides: Partial<AddressCardProps> = {}) {
const props = { ...BASE, ...overrides };
render(
<ThemeProvider>
<AddressCard {...props} />
</ThemeProvider>,
);
return props;
}
describe('<AddressCard/> component', () => {
it('renders the title, region label and street line', () => {
renderCard();
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('Tehran · District 1')).toBeInTheDocument();
expect(screen.getByText('No. 5, Vali St')).toBeInTheDocument();
});
it('shows the primary badge and hides set-primary on the primary card', () => {
renderCard({ isPrimary: true });
expect(screen.getByText('Primary')).toBeInTheDocument();
expect(screen.queryByText('Set as primary')).not.toBeInTheDocument();
});
it('offers set-primary only on a non-primary card and fires it', async () => {
const user = userEvent.setup();
const props = renderCard({ isPrimary: false });
await user.click(screen.getByText('Set as primary'));
expect(props.onSetPrimary).toHaveBeenCalledTimes(1);
});
it('calls onEdit and onDelete from the action buttons', async () => {
const user = userEvent.setup();
const props = renderCard();
await user.click(screen.getByLabelText('Edit'));
await user.click(screen.getByLabelText('Delete'));
expect(props.onEdit).toHaveBeenCalledTimes(1);
expect(props.onDelete).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,88 @@
'use client';
import { FunctionComponent } from 'react';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { AppButton, AppIconButton } from '@/components/common';
import StatusChip from '@/components/StatusChip';
export interface AddressCardProps {
title: string;
/** Localised "city · district" (or "city · whole city") label, computed by the caller. */
regionLabel: string;
addressLine?: string | null;
isPrimary: boolean;
primaryLabel: string;
onEdit: () => void;
onDelete: () => void;
onSetPrimary: () => void;
editLabel: string;
deleteLabel: string;
setPrimaryLabel: string;
/** Disables the set-primary action while its mutation is in flight. */
settingPrimary?: boolean;
}
/**
* Address summary card for the address book — title + a primary badge (the f0 `StatusChip`),
* the region label and street line, with edit / delete / set-as-primary actions. Presentational:
* all display text is translated by the caller. The set-primary action shows only on non-primary
* cards so the UI never presents two primaries.
* @component AddressCard
*/
const AddressCard: FunctionComponent<AddressCardProps> = ({
title,
regionLabel,
addressLine,
isPrimary,
primaryLabel,
onEdit,
onDelete,
onSetPrimary,
editLabel,
deleteLabel,
setPrimaryLabel,
settingPrimary = false,
}) => (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ alignItems: 'flex-start', gap: 1 }}>
<Stack sx={{ flexGrow: 1, gap: 0.75, minWidth: 0 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{isPrimary ? <StatusChip status="verified" label={primaryLabel} /> : null}
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{regionLabel}
</Typography>
{addressLine ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{addressLine}
</Typography>
) : null}
{!isPrimary ? (
<AppButton
variant="text"
color="primary"
disabled={settingPrimary}
onClick={onSetPrimary}
sx={{ m: 0, mt: 0.25, alignSelf: 'flex-start' }}
>
{setPrimaryLabel}
</AppButton>
) : null}
</Stack>
<Stack direction="row" sx={{ flexShrink: 0 }}>
<AppIconButton icon="edit" title={editLabel} aria-label={editLabel} size="small" onClick={onEdit} />
<AppIconButton icon="delete" title={deleteLabel} aria-label={deleteLabel} size="small" onClick={onDelete} />
</Stack>
</Stack>
</Paper>
);
export default AddressCard;
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
const PROVINCES = [{ id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }];
const CITIES = [{ id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }];
const DISTRICTS = [{ id: 1001, cityId: 101, nameFa: 'منطقه ۱', nameEn: 'District 1', sortOrder: 0 }];
jest.mock('@/services/geography', () => ({
useProvinces: () => ({ data: PROVINCES, isLoading: false, isSuccess: true }),
useCities: (provinceId: number | null) => ({ data: provinceId ? CITIES : [], isLoading: false, isSuccess: provinceId != null }),
useDistricts: (cityId: number | null) => ({ data: cityId ? DISTRICTS : [], isLoading: false, isSuccess: cityId != null }),
}));
import AddressForm from './AddressForm';
describe('<AddressForm/> component', () => {
it('blocks submit and flags city + pin + required text when empty', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(
<ThemeProvider>
<AddressForm onSubmit={onSubmit} />
</ThemeProvider>,
);
await user.click(screen.getByRole('button', { name: 'save' }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText('title_required')).toBeInTheDocument();
expect(screen.getByText('city_required')).toBeInTheDocument();
expect(screen.getByText('line_required')).toBeInTheDocument();
expect(screen.getByText('map_required')).toBeInTheDocument();
});
it('submits the mapped address input from an edit prefill', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(
<ThemeProvider>
<AddressForm
initial={{
title: 'Home',
provinceId: 1,
cityId: 101,
districtId: 1001,
addressLine: 'No. 5, Vali St',
latitude: 35.7,
longitude: 51.4,
isPrimary: false,
}}
onSubmit={onSubmit}
/>
</ThemeProvider>,
);
await user.click(screen.getByRole('button', { name: 'save' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Home',
provinceId: 1,
cityId: 101,
districtId: 1001,
addressLine: 'No. 5, Vali St',
latitude: 35.7,
longitude: 51.4,
isPrimary: false,
});
});
});
@@ -0,0 +1,164 @@
'use client';
import { FunctionComponent, useState } from 'react';
import { useTranslations } from 'next-intl';
import FormControlLabel from '@mui/material/FormControlLabel';
import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import { AppButton } from '@/components/common';
import { cityCentroid } from '@/services/geography/constants';
import type { CreateAddressInput, LatLng } from '@/services/addresses/types';
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
import AddressMapPicker from './AddressMapPicker';
/** Prefill for edit (or empty for add). `provinceId` is needed to prefill the cascade. */
export interface AddressFormInitial {
title?: string;
provinceId?: number | null;
cityId?: number | null;
districtId?: number | null;
addressLine?: string | null;
latitude?: number | null;
longitude?: number | null;
isPrimary?: boolean;
}
export interface AddressFormProps {
initial?: AddressFormInitial;
submitting?: boolean;
onSubmit: (input: CreateAddressInput) => void;
onCancel?: () => void;
}
// Only prefill the region when we have the province — a city id without its province can't drive
// the (per-province) city query, so the cascade starts fresh instead of showing a dead value.
function initialRegion(initial?: AddressFormInitial): CascadingRegionValue {
if (initial?.provinceId == null) return { provinceId: null, cityId: null, districtId: null };
return { provinceId: initial.provinceId, cityId: initial.cityId ?? null, districtId: initial.districtId ?? null };
}
function initialPin(initial?: AddressFormInitial): LatLng | null {
return initial?.latitude != null && initial?.longitude != null
? { latitude: initial.latitude, longitude: initial.longitude }
: null;
}
/**
* The add/edit address form body — the cascading region dropdowns + the map-pin picker + a
* title and street address + a "set as primary" toggle. Validation: **city required**, **pin
* required** (surfaced inline), title + street required, **district optional**. Emits a
* `CreateAddressInput` carrying the picked coordinates. Reused for create and edit.
* @component AddressForm
*/
const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting = false, onSubmit, onCancel }) => {
const t = useTranslations('address');
const tc = useTranslations('common');
const [title, setTitle] = useState(initial?.title ?? '');
const [region, setRegion] = useState<CascadingRegionValue>(() => initialRegion(initial));
const [addressLine, setAddressLine] = useState(initial?.addressLine ?? '');
const [pin, setPin] = useState<LatLng | null>(() => initialPin(initial));
const [isPrimary, setIsPrimary] = useState(initial?.isPrimary ?? false);
const [titleError, setTitleError] = useState(false);
const [cityError, setCityError] = useState(false);
const [lineError, setLineError] = useState(false);
const [pinError, setPinError] = useState(false);
const handleSubmit = () => {
const titleInvalid = title.trim().length === 0;
const cityInvalid = region.cityId == null;
const lineInvalid = addressLine.trim().length === 0;
const pinInvalid = pin == null;
setTitleError(titleInvalid);
setCityError(cityInvalid);
setLineError(lineInvalid);
setPinError(pinInvalid);
if (titleInvalid || cityInvalid || lineInvalid || pinInvalid) return;
onSubmit({
title: title.trim(),
provinceId: region.provinceId as number,
cityId: region.cityId as number,
districtId: region.districtId,
addressLine: addressLine.trim(),
latitude: pin!.latitude,
longitude: pin!.longitude,
isPrimary,
});
};
return (
<Stack sx={{ gap: 2.5 }}>
<TextField
label={t('title_label')}
placeholder={t('title_placeholder')}
value={title}
onChange={(event) => {
setTitle(event.target.value);
if (titleError) setTitleError(false);
}}
error={titleError}
helperText={titleError ? t('title_required') : undefined}
fullWidth
/>
<CascadingRegionSelect
value={region}
onChange={(next) => {
setRegion(next);
if (cityError && next.cityId != null) setCityError(false);
}}
cityError={cityError}
cityErrorText={t('city_required')}
/>
<AddressMapPicker
value={pin}
onChange={(next) => {
setPin(next);
if (pinError) setPinError(false);
}}
center={cityCentroid(region.cityId)}
helperText={t('map_hint')}
latLabel={t('map_lat')}
lngLabel={t('map_lng')}
error={pinError}
errorText={t('map_required')}
/>
<TextField
label={t('line_label')}
value={addressLine}
onChange={(event) => {
setAddressLine(event.target.value);
if (lineError) setLineError(false);
}}
error={lineError}
helperText={lineError ? t('line_required') : t('line_hint')}
multiline
minRows={2}
fullWidth
/>
<FormControlLabel
control={<Switch checked={isPrimary} onChange={(event) => setIsPrimary(event.target.checked)} />}
label={t('set_primary_toggle')}
/>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'flex-end' }}>
{onCancel ? (
<AppButton variant="text" onClick={onCancel} disabled={submitting} sx={{ m: 0 }}>
{tc('cancel')}
</AppButton>
) : null}
<AppButton color="primary" variant="contained" onClick={handleSubmit} disabled={submitting} sx={{ m: 0 }}>
{submitting ? tc('saving') : tc('save')}
</AppButton>
</Stack>
</Stack>
);
};
export default AddressForm;
@@ -0,0 +1,43 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import AddressMapPicker from './AddressMapPicker';
import type { LatLng } from '@/services/addresses/types';
function renderPicker(value: LatLng | null, center?: LatLng) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<AddressMapPicker
value={value}
onChange={onChange}
center={center}
helperText="Drop a pin"
latLabel="Latitude"
lngLabel="Longitude"
/>
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<AddressMapPicker/> component', () => {
it('prompts to drop a pin when no coordinate is set', () => {
renderPicker(null);
expect(screen.getAllByText('Drop a pin').length).toBeGreaterThan(0);
});
it('emits real coordinates around the centre when the canvas is tapped', () => {
const center = { latitude: 35.6892, longitude: 51.389 };
const { onChange } = renderPicker(null, center);
fireEvent.click(screen.getByRole('application'), { clientX: 10, clientY: 10 });
// jsdom reports a 0×0 rect, so the tap resolves to the viewport centre = the city centroid.
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(center);
});
it('shows the coordinate readout once a pin is placed', () => {
renderPicker({ latitude: 35.6892, longitude: 51.389 });
expect(screen.getByText(/35\.68920/)).toBeInTheDocument();
expect(screen.getByText(/51\.38900/)).toBeInTheDocument();
});
});
@@ -0,0 +1,157 @@
'use client';
import { FunctionComponent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import AppIcon from '@/components/common/AppIcon';
import { IRAN_CENTROID } from '@/services/geography/constants';
import type { LatLng } from '@/services/addresses/types';
export interface AddressMapPickerProps {
value: LatLng | null;
onChange: (value: LatLng) => void;
/** Centre of the picker's viewport — the chosen city's centroid, or the Iran centroid. */
center?: LatLng;
helperText: string;
latLabel: string;
lngLabel: string;
error?: boolean;
errorText?: string;
}
// Half-degree span each side of the centre (~±6 km) — enough precision for the later EVV check
// while keeping the whole stand-in viewport around one city.
const SPAN = 0.06;
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
/**
* Lightweight **map-pin picker stand-in** — a draggable/tappable marker panel that emits real
* `{ latitude, longitude }`. It is NOT a real map (no Neshan/Google tiles), only a bounded
* canvas mapping the pointer position to coordinates around the chosen city's centroid, behind a
* small component boundary so a real map drops in later without touching the address form. The
* picked coordinates are what the create/update request sends; the pin only refines coordinates —
* the bookable geography is still the region dropdown choice.
* @component AddressMapPicker
*/
const AddressMapPicker: FunctionComponent<AddressMapPickerProps> = ({
value,
onChange,
center = IRAN_CENTROID,
helperText,
latLabel,
lngLabel,
error = false,
errorText,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
// Fraction [0,1] across the canvas → coordinates. x: west→east (left→right); y: north→south (top→bottom).
const fractionToLatLng = (fx: number, fy: number): LatLng => ({
latitude: round6(center.latitude + SPAN - clamp01(fy) * 2 * SPAN),
longitude: round6(center.longitude - SPAN + clamp01(fx) * 2 * SPAN),
});
// Inverse — coordinates → percent offsets for the marker, plus the centering transform. All applied
// via inline `style` (not `sx`) so the RTL stylis plugin can't mirror them: it flips physical `left`
// AND inverts the X of `transform: translate(...)`, either of which would offset the pin from its
// click point on the default (fa/RTL) locale. Inline style bypasses the emotion cache entirely.
const markerStyle = (v: LatLng) => ({
left: `${clamp01((v.longitude - (center.longitude - SPAN)) / (2 * SPAN)) * 100}%`,
top: `${clamp01((center.latitude + SPAN - v.latitude) / (2 * SPAN)) * 100}%`,
transform: 'translate(-50%, -100%)',
});
const place = (clientX: number, clientY: number) => {
const rect = mapRef.current?.getBoundingClientRect();
// jsdom / zero-size layouts report a 0×0 rect — fall back to the centre so a tap still resolves.
const fx = rect && rect.width ? (clientX - rect.left) / rect.width : 0.5;
const fy = rect && rect.height ? (clientY - rect.top) / rect.height : 0.5;
onChange(fractionToLatLng(fx, fy));
};
// A tap places via `click` (reliable everywhere); a drag places via pointer moves while held.
const handleClick = (event: ReactMouseEvent<HTMLDivElement>) => place(event.clientX, event.clientY);
const handlePointerDown = () => setDragging(true);
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragging) place(event.clientX, event.clientY);
};
const stopDragging = () => setDragging(false);
return (
<Stack sx={{ gap: 1 }}>
<Box
ref={mapRef}
dir="ltr"
role="application"
aria-label={helperText}
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={stopDragging}
onPointerLeave={stopDragging}
sx={{
position: 'relative',
height: 220,
borderRadius: 2,
border: '1px solid',
borderColor: error ? 'var(--bal-error)' : 'divider',
cursor: 'crosshair',
overflow: 'hidden',
touchAction: 'none',
bgcolor: 'var(--bal-primary-soft)',
backgroundImage:
'linear-gradient(var(--bal-divider) 1px, transparent 1px), linear-gradient(90deg, var(--bal-divider) 1px, transparent 1px)',
backgroundSize: '28px 28px',
}}
>
{value ? (
<Box sx={{ position: 'absolute', inset: 0 }}>
<Box
style={markerStyle(value)}
sx={{
position: 'absolute',
color: 'var(--bal-primary)',
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.35))',
pointerEvents: 'none',
}}
>
<AppIcon icon="location" size={32} color="var(--bal-primary)" />
</Box>
</Box>
) : (
<Stack sx={{ position: 'absolute', inset: 0, alignItems: 'center', justifyContent: 'center', gap: 0.5, pointerEvents: 'none' }}>
<AppIcon icon="location" size={28} color="var(--bal-text-secondary)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{helperText}
</Typography>
</Stack>
)}
</Box>
{error && errorText ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{errorText}
</Typography>
) : (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{helperText}
</Typography>
)}
{value ? (
<Stack direction="row" dir="ltr" sx={{ gap: 2, alignSelf: 'flex-start' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{latLabel}: {value.latitude.toFixed(5)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{lngLabel}: {value.longitude.toFixed(5)}
</Typography>
</Stack>
) : null}
</Stack>
);
};
export default AddressMapPicker;
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
const PROVINCES = [
{ id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 },
{ id: 4, nameFa: 'فارس', nameEn: 'Fars', sortOrder: 3 },
];
const CITIES = [{ id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 }];
const DISTRICTS = [{ id: 1001, cityId: 101, nameFa: 'منطقه ۱', nameEn: 'District 1', sortOrder: 0 }];
// Mock the aggressively-cached geography hooks so the cascade renders deterministically:
// city 101 has districts; any other city is whole-city-only (fetched-but-empty).
jest.mock('@/services/geography', () => ({
useProvinces: () => ({ data: PROVINCES, isLoading: false, isSuccess: true }),
useCities: (provinceId: number | null) => ({ data: provinceId ? CITIES : [], isLoading: false, isSuccess: provinceId != null }),
useDistricts: (cityId: number | null) => ({
data: cityId === 101 ? DISTRICTS : [],
isLoading: false,
isSuccess: cityId != null,
}),
}));
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
const NONE: CascadingRegionValue = { provinceId: null, cityId: null, districtId: null };
function renderSelect(value: CascadingRegionValue) {
const onChange = jest.fn();
const utils = render(
<ThemeProvider>
<CascadingRegionSelect value={value} onChange={onChange} />
</ThemeProvider>,
);
return { ...utils, onChange };
}
describe('<CascadingRegionSelect/> component', () => {
it('lists provinces and blocks the city level until a province is chosen', () => {
renderSelect(NONE);
expect(screen.getByRole('combobox', { name: 'province' })).toBeInTheDocument();
// City is not selectable yet — its helper prompts to pick a province first.
expect(screen.getByText('city_needs_province')).toBeInTheDocument();
});
it('resets the city/district when the province changes', async () => {
const user = userEvent.setup();
const { onChange } = renderSelect({ provinceId: 1, cityId: 101, districtId: 1001 });
await user.click(screen.getByRole('combobox', { name: 'province' }));
await user.click(screen.getByRole('option', { name: 'Fars' }));
expect(onChange).toHaveBeenCalledWith({ provinceId: 4, cityId: null, districtId: null });
});
it('offers the whole-city option plus districts for a city that has them', async () => {
const user = userEvent.setup();
const { onChange } = renderSelect({ provinceId: 1, cityId: 101, districtId: null });
await user.click(screen.getByRole('combobox', { name: 'district' }));
expect(screen.getByRole('option', { name: 'whole_city' })).toBeInTheDocument();
await user.click(screen.getByRole('option', { name: 'District 1' }));
expect(onChange).toHaveBeenCalledWith({ provinceId: 1, cityId: 101, districtId: 1001 });
});
it('surfaces the whole-city affordance for a city with no districts', () => {
renderSelect({ provinceId: 1, cityId: 201, districtId: null });
expect(screen.getByText('no_districts')).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import CircularProgress from '@mui/material/CircularProgress';
import InputAdornment from '@mui/material/InputAdornment';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import { useProvinces, useCities, useDistricts } from '@/services/geography';
import { pickRegionName } from '@/services/geography/names';
/** The region a caller reads back — a `null` id means "not chosen yet"; a `null` district also
* doubles as the deliberate "whole city" choice once a city is set. */
export interface CascadingRegionValue {
provinceId: number | null;
cityId: number | null;
districtId: number | null;
}
export interface CascadingRegionSelectProps {
value: CascadingRegionValue;
onChange: (next: CascadingRegionValue) => void;
/** Render the district level. Off for a whole-city-only context (e.g. the coverage "whole city" scope). */
includeDistrict?: boolean;
cityError?: boolean;
cityErrorText?: string;
districtError?: boolean;
districtErrorText?: string;
disabled?: boolean;
}
const toId = (raw: string): number | null => (raw === '' ? null : Number(raw));
/**
* Province → city → district cascading dropdowns, driving the aggressively-cached geography
* queries itself so both the address form and the coverage editor drop it in with only a
* `value`/`onChange`. Each level enables only once its parent is chosen and resets its children
* on change; inactive regions never arrive (the server filters them), and a city with no
* districts surfaces the **whole-city** affordance rather than an error. **City is required;
* district is optional** — leaving district empty is a real choice, never an error.
* @component CascadingRegionSelect
*/
const CascadingRegionSelect: FunctionComponent<CascadingRegionSelectProps> = ({
value,
onChange,
includeDistrict = true,
cityError = false,
cityErrorText,
districtError = false,
districtErrorText,
disabled = false,
}) => {
const t = useTranslations('geo');
const locale = useLocale();
const provincesQuery = useProvinces();
const citiesQuery = useCities(value.provinceId);
const districtsQuery = useDistricts(value.cityId);
const provinces = provincesQuery.data ?? [];
const cities = citiesQuery.data ?? [];
const districts = districtsQuery.data ?? [];
const hasProvince = value.provinceId != null;
const hasCity = value.cityId != null;
// Distinguish "not fetched yet" from "fetched and genuinely empty" (whole-city-only city).
const cityHasNoDistricts = hasCity && districtsQuery.isSuccess && districts.length === 0;
// Only bind a value the loaded options actually contain — an id whose options are still
// fetching (e.g. an edit prefill) would otherwise trip MUI's out-of-range Select warning.
const provinceValue = provinces.some((province) => province.id === value.provinceId) ? value.provinceId : '';
const cityValue = cities.some((city) => city.id === value.cityId) ? value.cityId : '';
const districtValue = districts.some((district) => district.id === value.districtId) ? value.districtId : '';
const loadingAdornment = (loading: boolean) =>
loading
? {
endAdornment: (
<InputAdornment position="end">
<CircularProgress size={16} />
</InputAdornment>
),
}
: undefined;
const handleProvince = (raw: string) =>
onChange({ provinceId: toId(raw), cityId: null, districtId: null });
const handleCity = (raw: string) =>
onChange({ provinceId: value.provinceId, cityId: toId(raw), districtId: null });
const handleDistrict = (raw: string) =>
onChange({ provinceId: value.provinceId, cityId: value.cityId, districtId: toId(raw) });
return (
<Stack sx={{ gap: 2 }}>
<TextField
select
label={t('province')}
value={provinceValue}
onChange={(event) => handleProvince(event.target.value)}
disabled={disabled || provincesQuery.isLoading}
slotProps={{ input: loadingAdornment(provincesQuery.isLoading) }}
fullWidth
>
{provinces.map((province) => (
<MenuItem key={province.id} value={province.id}>
{pickRegionName(province, locale)}
</MenuItem>
))}
</TextField>
<TextField
select
label={t('city')}
value={cityValue}
onChange={(event) => handleCity(event.target.value)}
disabled={disabled || !hasProvince || citiesQuery.isLoading}
error={cityError}
helperText={cityError ? cityErrorText : !hasProvince ? t('city_needs_province') : undefined}
slotProps={{ input: loadingAdornment(citiesQuery.isLoading) }}
fullWidth
>
{cities.map((city) => (
<MenuItem key={city.id} value={city.id}>
{pickRegionName(city, locale)}
</MenuItem>
))}
</TextField>
{includeDistrict ? (
<TextField
select
label={t('district')}
value={districtValue}
onChange={(event) => handleDistrict(event.target.value)}
disabled={disabled || !hasCity || districtsQuery.isLoading || cityHasNoDistricts}
error={districtError}
helperText={
districtError
? districtErrorText
: !hasCity
? t('district_needs_city')
: cityHasNoDistricts
? t('no_districts')
: t('district_optional')
}
slotProps={{ input: loadingAdornment(districtsQuery.isLoading) }}
fullWidth
>
{/* The empty option is the explicit "whole city" choice — district is optional. */}
<MenuItem value="">{t('whole_city')}</MenuItem>
{districts.map((district) => (
<MenuItem key={district.id} value={district.id}>
{pickRegionName(district, locale)}
</MenuItem>
))}
</TextField>
) : null}
</Stack>
);
};
export default CascadingRegionSelect;
+10
View File
@@ -0,0 +1,10 @@
import CascadingRegionSelect from './CascadingRegionSelect';
import AddressMapPicker from './AddressMapPicker';
import AddressForm from './AddressForm';
import AddressCard from './AddressCard';
export { CascadingRegionSelect, AddressMapPicker, AddressForm, AddressCard };
export type { CascadingRegionSelectProps, CascadingRegionValue } from './CascadingRegionSelect';
export type { AddressMapPickerProps } from './AddressMapPicker';
export type { AddressFormProps, AddressFormInitial } from './AddressForm';
export type { AddressCardProps } from './AddressCard';
+4
View File
@@ -9,12 +9,16 @@ export const ROUTES = {
ONBOARDING: '/onboarding', ONBOARDING: '/onboarding',
BOOKINGS: '/bookings', BOOKINGS: '/bookings',
PATIENTS: '/patients', PATIENTS: '/patients',
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
ADDRESSES: '/addresses',
WALLET: '/wallet', WALLET: '/wallet',
PROFILE: '/profile', PROFILE: '/profile',
// Nurse app // Nurse app
NURSE: '/nurse', NURSE: '/nurse',
NURSE_PROFILE: '/nurse/profile', NURSE_PROFILE: '/nurse/profile',
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
NURSE_COVERAGE: '/nurse/coverage',
NURSE_BANK: '/nurse/bank', NURSE_BANK: '/nurse/bank',
NURSE_VERIFICATION: '/nurse/verification', NURSE_VERIFICATION: '/nurse/verification',
NURSE_VISITS: '/nurse/visits', NURSE_VISITS: '/nurse/visits',
+1
View File
@@ -19,6 +19,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
() => [ () => [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, { title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
{ title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' }, { title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' },
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' },
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' }, { title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' },
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' }, { title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' }, { title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
@@ -0,0 +1,80 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { ADDRESSES_PAGE_SIZE } from '../constants';
import type { CreateAddressInput, CustomerAddress, CustomerAddressDto, AddressesApi } from '../types';
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 };
}
// Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 —
// the server geocodes today; sending the pin makes the coordinate the user's once accepted);
// `provinceId` stays client-side (city implies province server-side).
function toBody(input: CreateAddressInput) {
return {
title: input.title,
cityId: input.cityId,
districtId: input.districtId ?? null,
addressLine: input.addressLine,
postalCode: input.postalCode ?? null,
recipientName: input.recipientName ?? null,
recipientPhone: input.recipientPhone ?? null,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
isPrimary: input.isPrimary ?? false,
};
}
/**
* Real HTTP implementation of the AddressesApi seam (b4 action-style routes). `clientFetch`
* returns the raw envelope, so each call reads its payload via `unwrap`. Selected once
* USE_ADDRESSES_MOCK is false and REQ-008/REQ-009 land.
*/
export const addressesClientApi: AddressesApi = {
list: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
// Pagination params bind case-insensitively to the server's `PageSize` (not snake_cased, unlike the
// 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()}`),
);
return { ...page, items: page.items.map((dto) => toAddress(dto)) };
},
create: async (input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input.provinceId,
),
update: async (id, input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input.provinceId,
),
setPrimary: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/set_primary/${id}`, { method: 'POST' });
},
remove: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/delete/${id}`, { method: 'DELETE' });
},
};
@@ -0,0 +1,10 @@
import { USE_ADDRESSES_MOCK } from '../constants';
import type { AddressesApi } from '../types';
import { addressesClientApi } from './clientApi';
import { addressesMockApi } from './mockApi';
/**
* The selected AddressesApi implementation — the single seam hooks import. Selection is by
* config (USE_ADDRESSES_MOCK), never by scattered `if (mock)` checks.
*/
export const addressesApi: AddressesApi = USE_ADDRESSES_MOCK ? addressesMockApi : addressesClientApi;
@@ -0,0 +1,98 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { resolveSeedCity, resolveSeedDistrict } from '@/services/geography/apis/seed';
import { ADDRESSES_PAGE_SIZE } from '../constants';
import type { CreateAddressInput, CustomerAddress, AddressesApi } from '../types';
const MOCK_LATENCY_MS = 350;
// In-memory store, seeded **empty** so a session can demo both the add flow and the E-style
// empty state. The single-primary invariant is enforced here exactly as the server's filtered
// unique index does: exactly one primary while ≥1 address exists.
let store: CustomerAddress[] = [];
let nextId = 1;
// Denormalise the chosen region ids into the display names the DTO carries (the server joins
// these). A whole-city address (no district) keeps null district names.
function build(id: number, input: CreateAddressInput, isPrimary: boolean): CustomerAddress {
const city = resolveSeedCity(input.cityId);
const district = input.districtId == null ? undefined : resolveSeedDistrict(input.districtId);
return {
id,
title: input.title,
provinceId: input.provinceId,
cityId: input.cityId,
cityNameFa: city?.nameFa ?? '',
cityNameEn: city?.nameEn ?? '',
districtId: input.districtId ?? null,
districtNameFa: district?.nameFa ?? null,
districtNameEn: district?.nameEn ?? null,
addressLine: input.addressLine,
postalCode: input.postalCode ?? null,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
isPrimary,
recipientName: input.recipientName ?? null,
recipientPhone: input.recipientPhone ?? null,
};
}
const clearPrimaryExcept = (id: number) =>
store.map((address) => (address.id === id ? address : { ...address, isPrimary: false }));
// Primary first, then most-recent — mirrors the contract's "primary first" list order.
const ordered = () => [...store].sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary) || b.id - a.id);
/**
* In-memory mock behind the AddressesApi seam — the b4 endpoints exist, but the wire DTO lacks
* `provinceId` (REQ-009) and the create body geocodes instead of accepting the pin (REQ-008),
* so this drives the UI until those land. Mirrors the real shapes for a one-line swap.
*/
export const addressesMockApi: AddressesApi = {
list: async (params?: PageParams): Promise<Paginated<CustomerAddress>> => {
await sleep(MOCK_LATENCY_MS);
const all = ordered();
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? ADDRESSES_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
},
create: async (input: CreateAddressInput): Promise<CustomerAddress> => {
await sleep(MOCK_LATENCY_MS);
const becomesPrimary = store.length === 0 || input.isPrimary === true;
const address = build(nextId++, input, becomesPrimary);
store = becomesPrimary ? [address, ...clearPrimaryExcept(address.id)] : [address, ...store];
return address;
},
update: async (id: number, input: CreateAddressInput): Promise<CustomerAddress> => {
await sleep(MOCK_LATENCY_MS);
const existing = store.find((address) => address.id === id);
if (!existing) throw new ApiError(404, 'Address not found');
// Editing never removes the sole primary: keep the prior flag unless the form promotes it.
const isPrimary = input.isPrimary === true || existing.isPrimary;
const updated = build(id, input, isPrimary);
store = store.map((address) => (address.id === id ? updated : address));
if (isPrimary) store = clearPrimaryExcept(id);
return updated;
},
setPrimary: async (id: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
if (!store.some((address) => address.id === id)) throw new ApiError(404, 'Address not found');
store = clearPrimaryExcept(id).map((address) => (address.id === id ? { ...address, isPrimary: true } : address));
},
remove: async (id: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const removed = store.find((address) => address.id === id);
store = store.filter((address) => address.id !== id);
// Keep exactly one primary: if the deleted row was primary, promote the next in order.
if (removed?.isPrimary && store.length > 0) {
const next = ordered()[0].id;
store = store.map((address) => ({ ...address, isPrimary: address.id === next }));
}
},
};
@@ -0,0 +1,16 @@
/**
* When true, the addresses domain is served by the in-memory mock (apis/mockApi.ts) behind the
* AddressesApi seam — single-primary + first-address-primary enforced in-memory so the address
* book demos before backend-phase-4 is reachable. The wire `CustomerAddressDto` also lacks
* `provinceId` (REQ-009) and its create body geocodes rather than accepting the pin (REQ-008),
* so this phase drives the UI behind the mock. Flip to false to use the live
* `api/v1/customer_addresses/*` routes — no hook/component changes
* (see dev/shared-working-context/reports/mocks-registry.md).
*/
export const USE_ADDRESSES_MOCK = true;
/** Address lists change only on mutation; keep them warm across screen visits. */
export const ADDRESSES_STALE_TIME = 60_000;
/** api-conventions default page size; `pageSize` ≤ 100. A customer has a handful of addresses. */
export const ADDRESSES_PAGE_SIZE = 50;
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import type { PageParams } from '@/lib/api/types';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
import { ADDRESSES_STALE_TIME } from '../constants';
/**
* The customer's addresses (primary first). A deliberate staleTime keeps the book warm across
* remounts; every mutation invalidates it, so it always reflects reality without over-fetching.
*/
export function useAddresses(params?: PageParams) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: addressKeys.list(params),
queryFn: () => addressesApi.list(params),
enabled: isAuthenticated,
staleTime: ADDRESSES_STALE_TIME,
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
import type { CreateAddressInput } from '../types';
/**
* Creates an address. Invalidates every cached list so the new card (and, if it became primary,
* the flipped badge on the old one) reflects reality. Domain 400s (empty title/addressLine,
* invalid city) surface via `mutation.error`; the fetch layer owns 401/403/5xx toasts.
*/
export function useCreateAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateAddressInput) => addressesApi.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
/** Soft-deletes an owned address, then invalidates the lists. */
export function useDeleteAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => addressesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
/**
* Makes an address the customer's primary; single-primary enforcement is server-side (the prior
* primary is cleared atomically). Invalidates the list — never hand-patches — so exactly one
* card shows the badge after the flip.
*/
export function useSetPrimaryAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => addressesApi.setPrimary(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
import type { UpdateAddressInput } from '../types';
/** Edits an owned address, then invalidates the lists so the card reflects the change. */
export function useUpdateAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateAddressInput }) => addressesApi.update(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
+5
View File
@@ -0,0 +1,5 @@
export { useAddresses } from './hooks/useAddresses';
export { useCreateAddress } from './hooks/useCreateAddress';
export { useUpdateAddress } from './hooks/useUpdateAddress';
export { useDeleteAddress } from './hooks/useDeleteAddress';
export { useSetPrimaryAddress } from './hooks/useSetPrimaryAddress';
+14
View File
@@ -0,0 +1,14 @@
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the addresses domain. Hierarchical keys let every mutation
* (create/update/delete/set-primary) invalidate all lists with `addressKeys.lists()` — the
* set-primary flip touches two rows, so we invalidate rather than hand-patch.
*/
export const addressKeys = {
all: ['addresses'] as const,
lists: () => [...addressKeys.all, 'list'] as const,
list: (params?: PageParams) => [...addressKeys.lists(), params ?? {}] as const,
details: () => [...addressKeys.all, 'detail'] as const,
detail: (id: number) => [...addressKeys.details(), id] as const,
};
+79
View File
@@ -0,0 +1,79 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Customer addresses domain — a customer's saved, geocoded delivery addresses, customer-scoped
* and tenancy-enforced server-side. Shapes mirror the b4 contract
* (`dev/contracts/domains/geography-addresses.md` → `CustomerAddressDto`); the wire is camelCase.
*
* PII (`addressLine`/`postalCode`/`recipientName`/`recipientPhone`) is encrypted at rest and
* **decrypted for the owner** on their own `list` — this screen only ever shows the owner their
* own book, so the full `addressLine` is rendered (no masking is needed here).
*
* `provinceId` is **client-augmented**: the wire `CustomerAddressDto` carries `cityId` but not
* its province (filed as REQ-009). We need the province to prefill the cascading dropdowns when
* editing, so the mock persists it and the real client echoes the form's choice onto the
* returned row (a genuinely refetched list from the server carries `null` until REQ-009 lands).
*/
/** Coordinates from the map-pin picker (also what the server's geocoder resolves to). */
export interface LatLng {
latitude: number;
longitude: number;
}
/** The b4 wire shape returned by every `customer_addresses/*` endpoint. */
export interface CustomerAddressDto {
id: number;
title: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
/** Decrypted for the owner; `null` only if never set. */
addressLine: string | null;
postalCode: string | null;
/** `null` when the geocoder couldn't resolve the address ("no map pin"). */
latitude: number | null;
longitude: number | null;
isPrimary: boolean;
recipientName: string | null;
recipientPhone: string | null;
}
/** App-level address = wire shape + the client-augmented `provinceId` (REQ-009). */
export interface CustomerAddress extends CustomerAddressDto {
provinceId: number | null;
}
/**
* Create/update input. `cityId` is required; `districtId` omitted/`null` = the whole city.
* `latitude`/`longitude` are the picked pin (REQ-008 — the contract create body geocodes
* server-side today; we send the pin so the coordinate is the user's, and echo it locally).
* `provinceId` is client-augmented (REQ-009) — used to prefill the cascade on edit.
*/
export interface CreateAddressInput {
title: string;
provinceId: number;
cityId: number;
districtId?: number | null;
addressLine: string;
postalCode?: string | null;
recipientName?: string | null;
recipientPhone?: string | null;
latitude?: number | null;
longitude?: number | null;
isPrimary?: boolean;
}
export type UpdateAddressInput = CreateAddressInput;
/** The domain's API seam — a mock and the real client both implement this interface. */
export interface AddressesApi {
list(params?: PageParams): Promise<Paginated<CustomerAddress>>;
create(input: CreateAddressInput): Promise<CustomerAddress>;
update(id: number, input: UpdateAddressInput): Promise<CustomerAddress>;
setPrimary(id: number): Promise<void>;
remove(id: number): Promise<void>;
}
@@ -0,0 +1,22 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type { City, District, GeographyApi, Province } from '../types';
const BASE = '/api/v1/geo';
/**
* Real HTTP implementation of the GeographyApi seam (b4 public `geo/*` lookups, no auth). The
* server returns active-only, `sortOrder`-ordered rows, so the client does no re-filtering or
* re-sorting beyond what the contract guarantees. Query params are snake_case
* (`province_id`/`city_id`); the response bodies are camelCase. Selected once USE_GEOGRAPHY_MOCK
* is false.
*/
export const geographyClientApi: GeographyApi = {
listProvinces: async () => unwrap(await clientFetch<ApiEnvelope<Province[]>>(`${BASE}/provinces`)),
listCities: async (provinceId: number) =>
unwrap(await clientFetch<ApiEnvelope<City[]>>(`${BASE}/cities?province_id=${provinceId}`)),
listDistricts: async (cityId: number) =>
unwrap(await clientFetch<ApiEnvelope<District[]>>(`${BASE}/districts?city_id=${cityId}`)),
};
@@ -0,0 +1,10 @@
import { USE_GEOGRAPHY_MOCK } from '../constants';
import type { GeographyApi } from '../types';
import { geographyClientApi } from './clientApi';
import { geographyMockApi } from './mockApi';
/**
* The selected GeographyApi implementation — the single seam the hooks import. Selection is by
* config (USE_GEOGRAPHY_MOCK), never by scattered `if (mock)` checks.
*/
export const geographyApi: GeographyApi = USE_GEOGRAPHY_MOCK ? geographyMockApi : geographyClientApi;
@@ -0,0 +1,29 @@
import { sleep } from '@/utils';
import type { City, District, GeographyApi, Province } from '../types';
import { SEED_CITIES, SEED_DISTRICTS, SEED_PROVINCES } from './seed';
const MOCK_LATENCY_MS = 250;
const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder - b.sortOrder;
/**
* In-memory mock behind the GeographyApi seam — returns the canned, active-only,
* `sortOrder`-ordered hierarchy. Mirrors the real lookups exactly (empty district list for a
* whole-city-only city), so swapping to the live endpoints is a one-line change in constants.ts.
*/
export const geographyMockApi: GeographyApi = {
listProvinces: async (): Promise<Province[]> => {
await sleep(MOCK_LATENCY_MS);
return [...SEED_PROVINCES].sort(bySortOrder);
},
listCities: async (provinceId: number): Promise<City[]> => {
await sleep(MOCK_LATENCY_MS);
return SEED_CITIES.filter((city) => city.provinceId === provinceId).sort(bySortOrder);
},
listDistricts: async (cityId: number): Promise<District[]> => {
await sleep(MOCK_LATENCY_MS);
return SEED_DISTRICTS.filter((district) => district.cityId === cityId).sort(bySortOrder);
},
};
@@ -0,0 +1,62 @@
import type { City, District, Province } from '../types';
/**
* Canned geography hierarchy for the client-side mock — a faithful subset of the b4 seed so the
* cascade, the address book, and the coverage editor all demo before backend-phase-4 is
* reachable. Ids mirror the contract where fixed (Tehran province 1 / city 101 / districts
* 1001…1022) and are assigned deterministically for the other seeded capital cities. Every row
* here is active; the mock never returns inactive regions (the real server filters them out).
*
* Shared by the geography mock (list endpoints) and the addresses/serviceAreas mocks (which
* resolve a saved `cityId`/`districtId` back to its display names). Real data comes from the
* server; this file is mock-only and imported nowhere in the production path.
*/
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
const toFaDigits = (n: number): string => String(n).replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
// White-space second-tier cities (Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom) the coverage
// model must serve, plus Tehran (the only seeded city with districts) and Karaj.
export const SEED_PROVINCES: Province[] = [
{ id: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 },
{ id: 2, nameFa: 'خراسان رضوی', nameEn: 'Razavi Khorasan', sortOrder: 1 },
{ id: 3, nameFa: 'اصفهان', nameEn: 'Isfahan', sortOrder: 2 },
{ id: 4, nameFa: 'فارس', nameEn: 'Fars', sortOrder: 3 },
{ id: 5, nameFa: 'آذربایجان شرقی', nameEn: 'East Azerbaijan', sortOrder: 4 },
{ id: 6, nameFa: 'خوزستان', nameEn: 'Khuzestan', sortOrder: 5 },
{ id: 7, nameFa: 'قم', nameEn: 'Qom', sortOrder: 6 },
{ id: 8, nameFa: 'البرز', nameEn: 'Alborz', sortOrder: 7 },
];
export const SEED_CITIES: City[] = [
{ id: 101, provinceId: 1, nameFa: 'تهران', nameEn: 'Tehran', sortOrder: 0 },
{ id: 201, provinceId: 2, nameFa: 'مشهد', nameEn: 'Mashhad', sortOrder: 0 },
{ id: 301, provinceId: 3, nameFa: 'اصفهان', nameEn: 'Isfahan', sortOrder: 0 },
{ id: 401, provinceId: 4, nameFa: 'شیراز', nameEn: 'Shiraz', sortOrder: 0 },
{ id: 501, provinceId: 5, nameFa: 'تبریز', nameEn: 'Tabriz', sortOrder: 0 },
{ id: 601, provinceId: 6, nameFa: 'اهواز', nameEn: 'Ahvaz', sortOrder: 0 },
{ id: 701, provinceId: 7, nameFa: 'قم', nameEn: 'Qom', sortOrder: 0 },
{ id: 801, provinceId: 8, nameFa: 'کرج', nameEn: 'Karaj', sortOrder: 0 },
];
// Tehran's 22 مناطق (1001…1022); every other seeded city is whole-city-only (no districts).
export const SEED_DISTRICTS: District[] = Array.from({ length: 22 }, (_, index) => {
const n = index + 1;
return {
id: 1000 + n,
cityId: 101,
nameFa: `منطقه ${toFaDigits(n)}`,
nameEn: `District ${n}`,
sortOrder: index,
};
});
/** Resolve a saved city id back to its names + province (mock-only; for addresses/serviceAreas). */
export function resolveSeedCity(cityId: number): City | undefined {
return SEED_CITIES.find((city) => city.id === cityId);
}
/** Resolve a saved district id back to its names (mock-only; null district = whole city). */
export function resolveSeedDistrict(districtId: number): District | undefined {
return SEED_DISTRICTS.find((district) => district.id === districtId);
}
@@ -0,0 +1,42 @@
/**
* When true, the geo hierarchy is served by the in-memory mock (apis/mockApi.ts) behind the
* GeographyApi seam — canned provinces/cities/districts (incl. Tehran's 22 مناطق and the
* white-space second-tier cities) so the cascade demos before backend-phase-4 is reachable.
* 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;
/**
* Reference data almost never changes, so it is cached **for the whole session**: an Infinite
* `staleTime` means a province/city's children are fetched once and served from cache on every
* revisit and across both editors (and later search). A generous `gcTime` keeps them warm even
* after the last consumer unmounts. This is the aggressive-caching rule the phase exists to set.
*/
export const GEO_STALE_TIME = Infinity;
export const GEO_GC_TIME = 24 * 60 * 60 * 1000; // 24h
/**
* Approximate map-pin default centre per known city, keyed by the b4 seed city ids (Tehran = 101
* matches the real seed; the other ids match apis/seed.ts). Used only to centre the map-pin
* picker on the chosen city; unknown cities (real data whose id we don't map) fall back to the
* Iran centroid. Not authoritative geography — the picker emits the user's real dropped coords.
*/
export const IRAN_CENTROID = { latitude: 32.4279, longitude: 53.688 } as const;
export const CITY_CENTROIDS: Record<number, { latitude: number; longitude: number }> = {
101: { latitude: 35.6892, longitude: 51.389 }, // Tehran
201: { latitude: 36.2605, longitude: 59.6168 }, // Mashhad
301: { latitude: 32.6539, longitude: 51.666 }, // Isfahan
401: { latitude: 29.5918, longitude: 52.5837 }, // Shiraz
501: { latitude: 38.0797, longitude: 46.2919 }, // Tabriz
601: { latitude: 31.3183, longitude: 48.6706 }, // Ahvaz
701: { latitude: 34.6416, longitude: 50.8746 }, // Qom
801: { latitude: 35.8327, longitude: 50.9916 }, // Karaj
};
/** The map-pin picker centre for a city — its centroid if known, else the Iran centroid. */
export function cityCentroid(cityId?: number | null): { latitude: number; longitude: number } {
if (cityId != null && CITY_CENTROIDS[cityId]) return CITY_CENTROIDS[cityId];
return IRAN_CENTROID;
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { geographyApi } from '../apis';
import { geographyKeys } from '../keys';
import { GEO_GC_TIME, GEO_STALE_TIME } from '../constants';
/**
* The active cities of a province (ordered). Enabled only once a province is selected; each
* province's cities are cached independently and forever within the session, so switching back
* to a previously-opened province never refetches.
*/
export function useCities(provinceId?: number | null) {
return useQuery({
queryKey: geographyKeys.cities(provinceId),
queryFn: () => geographyApi.listCities(provinceId as number),
enabled: provinceId != null,
staleTime: GEO_STALE_TIME,
gcTime: GEO_GC_TIME,
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { geographyApi } from '../apis';
import { geographyKeys } from '../keys';
import { GEO_GC_TIME, GEO_STALE_TIME } from '../constants';
/**
* The active districts of a city (ordered). Enabled only once a city is selected. An **empty
* list is valid** — a whole-city-only city (e.g. Mashhad) has no districts, which the cascade
* surfaces as the "whole city" affordance rather than an error.
*/
export function useDistricts(cityId?: number | null) {
return useQuery({
queryKey: geographyKeys.districts(cityId),
queryFn: () => geographyApi.listDistricts(cityId as number),
enabled: cityId != null,
staleTime: GEO_STALE_TIME,
gcTime: GEO_GC_TIME,
});
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { geographyApi } from '../apis';
import { geographyKeys } from '../keys';
import { GEO_GC_TIME, GEO_STALE_TIME } from '../constants';
/**
* The active provinces (ordered). Reference data — cached for the whole session (Infinite
* `staleTime`), so the province dropdown is populated once and served from cache on every
* revisit and across both editors.
*/
export function useProvinces() {
return useQuery({
queryKey: geographyKeys.provinces(),
queryFn: () => geographyApi.listProvinces(),
staleTime: GEO_STALE_TIME,
gcTime: GEO_GC_TIME,
});
}
+3
View File
@@ -0,0 +1,3 @@
export { useProvinces } from './hooks/useProvinces';
export { useCities } from './hooks/useCities';
export { useDistricts } from './hooks/useDistricts';
+12
View File
@@ -0,0 +1,12 @@
/**
* React Query key factory for the geography reference-data domain. Hierarchical keys let a
* province's cities and a city's districts be cached independently and served from cache on
* every revisit (and shared across both editors + f6 search). Reference data is fetched once
* per session, so these keys are never invalidated by this phase.
*/
export const geographyKeys = {
all: ['geography'] as const,
provinces: () => [...geographyKeys.all, 'provinces'] as const,
cities: (provinceId?: number | null) => [...geographyKeys.all, 'cities', provinceId ?? null] as const,
districts: (cityId?: number | null) => [...geographyKeys.all, 'districts', cityId ?? null] as const,
};
+10
View File
@@ -0,0 +1,10 @@
import type { RegionName } from './types';
/**
* Picks the locale-appropriate name for a reference region. `fa` is the default locale; any
* non-`en` locale reads the Persian name. Kept here (not in a component) so the address book,
* the coverage editor, and search all label regions identically.
*/
export function pickRegionName(region: RegionName, locale: string): string {
return locale === 'en' ? region.nameEn : region.nameFa;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Geography reference-data domain — the public province → city → district hierarchy the
* backend seeds and serves (b4 contract `dev/contracts/domains/geography-addresses.md`).
* Shapes mirror the wire DTOs exactly; the wire casing is **camelCase**
* (`nameFa`/`sortOrder`/`provinceId`), not the snake_case the routing convention implies —
* the swagger snapshot is the source of truth.
*
* The public lookups return **active-only, `sortOrder`-ordered** rows, so the DTOs carry no
* `isActive`: an inactive region is simply absent, never a disabled option to render. A city
* with **no districts** (e.g. Mashhad) returns a valid empty district list = whole-city-only.
*/
/** Fields shared by every reference region — the localisable name pair drives the dropdown label. */
export interface RegionName {
nameFa: string;
nameEn: string;
}
/** `ProvinceDto`. */
export interface Province extends RegionName {
id: number;
sortOrder: number;
}
/** `CityDto` — belongs to one province. */
export interface City extends RegionName {
id: number;
provinceId: number;
sortOrder: number;
}
/** `DistrictDto` — belongs to one city. A `null`/absent district on a coverage area = whole city. */
export interface District extends RegionName {
id: number;
cityId: number;
sortOrder: number;
}
/** The domain's API seam — a mock and the real client both implement this interface. */
export interface GeographyApi {
listProvinces(): Promise<Province[]>;
listCities(provinceId: number): Promise<City[]>;
listDistricts(cityId: number): Promise<District[]>;
}
@@ -0,0 +1,36 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { SERVICE_AREAS_PAGE_SIZE } from '../constants';
import type { AddServiceAreaInput, NurseServiceArea, ServiceAreasApi } from '../types';
const BASE = '/api/v1/nurse_service_areas';
/**
* Real HTTP implementation of the ServiceAreasApi seam (b4 action-style routes). A duplicate
* `add` returns `409` — `clientFetch` throws `ApiError(409)` (no toast; "other 4xx"), which the
* add hook/screen maps to the inline "already covered" message. Selected once
* USE_SERVICE_AREAS_MOCK is false.
*/
export const serviceAreasClientApi: ServiceAreasApi = {
list: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
// Pagination binds case-insensitively to the server's `PageSize` (not snake_cased); match the template.
query.set('pageSize', String(params?.pageSize ?? SERVICE_AREAS_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<NurseServiceArea>>>(`${BASE}/list?${query.toString()}`),
);
},
add: async (input: AddServiceAreaInput) =>
unwrap(
await clientFetch<ApiEnvelope<NurseServiceArea>>(`${BASE}/add`, {
method: 'POST',
body: JSON.stringify({ cityId: input.cityId, districtId: input.districtId ?? null }),
}),
),
remove: async (id: number) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/remove/${id}`, { method: 'DELETE' });
},
};
@@ -0,0 +1,12 @@
import { USE_SERVICE_AREAS_MOCK } from '../constants';
import type { ServiceAreasApi } from '../types';
import { serviceAreasClientApi } from './clientApi';
import { serviceAreasMockApi } from './mockApi';
/**
* The selected ServiceAreasApi implementation — the single seam hooks import. Selection is by
* config (USE_SERVICE_AREAS_MOCK), never by scattered `if (mock)` checks.
*/
export const serviceAreasApi: ServiceAreasApi = USE_SERVICE_AREAS_MOCK
? serviceAreasMockApi
: serviceAreasClientApi;
@@ -0,0 +1,65 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { resolveSeedCity, resolveSeedDistrict } from '@/services/geography/apis/seed';
import { SERVICE_AREAS_PAGE_SIZE } from '../constants';
import { areaExists, type AddServiceAreaInput, type NurseServiceArea, type ServiceAreasApi } from '../types';
const MOCK_LATENCY_MS = 300;
// In-memory store, seeded **empty** so the "won't appear in search" empty-state warning demos.
let store: NurseServiceArea[] = [];
let nextId = 1;
// Whole-city rows first (matching the contract's list order), then by most-recent add.
const ordered = () =>
[...store].sort((a, b) => Number(b.isWholeCity) - Number(a.isWholeCity) || b.id - a.id);
function build(id: number, input: AddServiceAreaInput): NurseServiceArea {
const city = resolveSeedCity(input.cityId);
const districtId = input.districtId ?? null;
const district = districtId == null ? undefined : resolveSeedDistrict(districtId);
return {
id,
cityId: input.cityId,
cityNameFa: city?.nameFa ?? '',
cityNameEn: city?.nameEn ?? '',
districtId,
districtNameFa: district?.nameFa ?? null,
districtNameEn: district?.nameEn ?? null,
isWholeCity: districtId == null,
isActive: true,
};
}
/**
* In-memory mock behind the ServiceAreasApi seam. Enforces the `UNIQUE(cityId, districtId)`
* rule exactly as the server does — a duplicate (including a second whole-city row) throws the
* same `409` the real endpoint returns — so the coverage editor's inline duplicate handling is
* demonstrable end-to-end. Mirrors the real shapes for a one-line swap.
*/
export const serviceAreasMockApi: ServiceAreasApi = {
list: async (params?: PageParams): Promise<Paginated<NurseServiceArea>> => {
await sleep(MOCK_LATENCY_MS);
const all = ordered();
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? SERVICE_AREAS_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
},
add: async (input: AddServiceAreaInput): Promise<NurseServiceArea> => {
await sleep(MOCK_LATENCY_MS);
if (areaExists(store, input.cityId, input.districtId ?? null)) {
throw new ApiError(409, 'Area already covered', 'area_duplicate');
}
const area = build(nextId++, input);
store = [area, ...store];
return area;
},
remove: async (id: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
store = store.filter((area) => area.id !== id);
},
};
@@ -0,0 +1,33 @@
import { areaExists, type NurseServiceArea } from './types';
const area = (id: number, cityId: number, districtId: number | null): NurseServiceArea => ({
id,
cityId,
cityNameFa: '',
cityNameEn: '',
districtId,
districtNameFa: null,
districtNameEn: null,
isWholeCity: districtId == null,
isActive: true,
});
describe('areaExists — coverage-area duplicate guard', () => {
const areas = [area(1, 101, null), area(2, 101, 1001)];
it('detects a duplicate whole-city area (null district treated as a real value)', () => {
expect(areaExists(areas, 101, null)).toBe(true);
});
it('detects a duplicate city+district area', () => {
expect(areaExists(areas, 101, 1001)).toBe(true);
});
it('treats whole-city and a specific district in the same city as distinct', () => {
expect(areaExists([area(1, 101, null)], 101, 1002)).toBe(false);
});
it('is false for a city not yet covered', () => {
expect(areaExists(areas, 201, null)).toBe(false);
});
});
@@ -0,0 +1,14 @@
/**
* When true, the nurse service-areas domain is served by the in-memory mock (apis/mockApi.ts)
* behind the ServiceAreasApi seam — the duplicate `(cityId, districtId)` 409 and whole-city-first
* ordering are enforced in-memory so the coverage editor demos before backend-phase-4 is
* 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;
/** Service areas change only on mutation; keep them warm across screen visits. */
export const SERVICE_AREAS_STALE_TIME = 60_000;
/** api-conventions default page size; `pageSize` ≤ 100. A nurse covers a handful of areas. */
export const SERVICE_AREAS_PAGE_SIZE = 100;
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { serviceAreasApi } from '../apis';
import { serviceAreaKeys } from '../keys';
import type { AddServiceAreaInput } from '../types';
/**
* Adds a coverage area, then invalidates the list. A duplicate `(cityId, districtId)` returns
* `409` — surfaced via `mutation.error` so the screen shows the inline "already covered" message
* (the client-side check is the fast path; this 409 is the belt-and-braces server truth).
*/
export function useAddServiceArea() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: AddServiceAreaInput) => serviceAreasApi.add(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceAreaKeys.lists() });
},
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { serviceAreasApi } from '../apis';
import { serviceAreaKeys } from '../keys';
/** Removes an owned coverage area, then invalidates the list. */
export function useRemoveServiceArea() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => serviceAreasApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: serviceAreaKeys.lists() });
},
});
}
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import type { PageParams } from '@/lib/api/types';
import { serviceAreasApi } from '../apis';
import { serviceAreaKeys } from '../keys';
import { SERVICE_AREAS_STALE_TIME } from '../constants';
/**
* The nurse's coverage areas (whole-city first). A deliberate staleTime keeps them warm across
* remounts; add/remove invalidate the list. The screen also reads this cache for the fast-path
* duplicate check before firing an add.
*/
export function useServiceAreas(params?: PageParams) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: serviceAreaKeys.list(params),
queryFn: () => serviceAreasApi.list(params),
enabled: isAuthenticated,
staleTime: SERVICE_AREAS_STALE_TIME,
});
}
@@ -0,0 +1,3 @@
export { useServiceAreas } from './hooks/useServiceAreas';
export { useAddServiceArea } from './hooks/useAddServiceArea';
export { useRemoveServiceArea } from './hooks/useRemoveServiceArea';
+11
View File
@@ -0,0 +1,11 @@
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the nurse service-areas domain. Add/remove invalidate
* `serviceAreaKeys.lists()` so the chip list always reflects reality.
*/
export const serviceAreaKeys = {
all: ['service-areas'] as const,
lists: () => [...serviceAreaKeys.all, 'list'] as const,
list: (params?: PageParams) => [...serviceAreaKeys.lists(), params ?? {}] as const,
};
+52
View File
@@ -0,0 +1,52 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Nurse service-areas domain — the cities/districts a nurse will travel to, nurse-scoped and
* tenancy-enforced server-side. Shapes mirror the b4 contract
* (`dev/contracts/domains/geography-addresses.md` → `NurseServiceAreaDto`); the wire is camelCase.
*
* **`districtId = null` = the whole city** — a real coverage choice ("I cover the entire city"),
* never missing data. Search (f6) treats a whole-city row as matching every district in that
* city. `UNIQUE(nurseId, cityId, districtId)` is enforced server-side (a duplicate returns 409),
* treating `null` district as a real value.
*/
/** The b4 wire shape returned by every `nurse_service_areas/*` endpoint. */
export interface NurseServiceArea {
id: number;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
isWholeCity: boolean;
isActive: boolean;
}
/** Add input — omit/`null` `districtId` for a whole-city area. */
export interface AddServiceAreaInput {
cityId: number;
districtId?: number | null;
}
/** The domain's API seam — a mock and the real client both implement this interface. */
export interface ServiceAreasApi {
list(params?: PageParams): Promise<Paginated<NurseServiceArea>>;
add(input: AddServiceAreaInput): Promise<NurseServiceArea>;
remove(id: number): Promise<void>;
}
/**
* The client-side duplicate guard (the fast path before firing the add): a `(cityId, districtId)`
* pair is a duplicate if it already exists in the nurse's areas, **treating a `null` district as a
* real value** (whole-city vs a specific district are distinct, but two whole-city rows collide).
* The server's `UNIQUE` index + 409 is the source of truth; this just avoids a doomed round-trip.
*/
export function areaExists(
areas: NurseServiceArea[],
cityId: number,
districtId: number | null,
): boolean {
return areas.some((area) => area.cityId === cityId && (area.districtId ?? null) === (districtId ?? null));
}
@@ -12,6 +12,32 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no) - **Requests filed:** frontend/requests/for-backend.md (yes/no)
--> -->
## frontend-phase-3-b4 — Addresses, map picker & nurse coverage areas — 2026-07-03
- **Shipped:** three domain services — `services/geography` (cached province→city→district reference lookups;
**Infinity `staleTime`** + shared `geographyKeys`; `useProvinces`/`useCities`/`useDistricts`; seam+mock+client),
`services/addresses` (address book CRUD + set-primary; single-primary invariant; every mutation invalidates
the list; `useAddresses`/`useCreateAddress`/`useUpdateAddress`/`useDeleteAddress`/`useSetPrimaryAddress`),
`services/serviceAreas` (coverage add/remove; `areaExists` dup-guard; `useServiceAreas`/`useAddServiceArea`/
`useRemoveServiceArea`). Shared composites (`src/components/geography/`, each tested): `CascadingRegionSelect`
(drives the cached cascade), `AddressMapPicker` (map-pin **stand-in** emitting real lat/lng), `AddressForm`,
`AddressCard`. Screens: **customer address book** (`/addresses`, reached from a profile-hub link — cascade +
map pin dialog, set-primary, delete, empty/skeleton), **nurse coverage editor** (`/nurse/coverage`, new
sidebar tab — chips, whole-city/specific-district scope toggle, inline duplicate block + 409, "won't appear
in search" empty warning). Added `geo`/`address`/`coverage` i18n namespaces + `nav.coverage` (both locales);
`location`/`delete`/`coverage` icons; routes `ADDRESSES`/`NURSE_COVERAGE`.
- **Consumes:** dev/contracts/domains/geography-addresses.md (backend-phase-4). Routes `api/v1/geo/{provinces,
cities,districts}`, `api/v1/customer_addresses/{list,create,update,set_primary,delete}`, `api/v1/
nurse_service_areas/{list,add,remove}`. Wire camelCase; geo query params snake_case; 409 on duplicate coverage.
- **Mocked client-side:** `services/geography` (`USE_GEOGRAPHY_MOCK`), `services/addresses` (`USE_ADDRESSES_MOCK`),
`services/serviceAreas` (`USE_SERVICE_AREAS_MOCK`) — all default `true`; real clients wired for a one-line flip.
The `AddressMapPicker` is a stand-in (no real map tiles). See mocks-registry + the report.
- **Reviewed:** 5-dimension adversarial review → 3 findings fixed (map marker RTL transform; `page_size`→`pageSize`
pagination casing on the real list calls; coverage "districts" dead-end on a district-less city).
- **Gate:** npm run check green · npm run test:ci green (129 tests, +17 across 5 suites) · npm run build green
with NEXT_PUBLIC_API_URL set (routes /addresses, /nurse/coverage generated).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-008 accept the map pin on address create/update,
REQ-009 `provinceId` on `CustomerAddressDto` for edit prefill).
## frontend-phase-2-b3 — Onboarding & profiles (customer, patient, nurse, bank) — 2026-07-02 ## frontend-phase-2-b3 — Onboarding & profiles (customer, patient, nurse, bank) — 2026-07-02
- **Shipped:** three domain services — `services/patients` (rewritten to the b3 `PatientDto` + client-augmented - **Shipped:** three domain services — `services/patients` (rewritten to the b3 `PatientDto` + client-augmented
`relation`/`conditions`; full CRUD seam + mock + real client; `usePatients`/`useCreatePatient`/ `relation`/`conditions`; full CRUD seam + mock + real client; `usePatients`/`useCreatePatient`/
@@ -94,3 +94,29 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`), returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
flagging in case that changes. flagging in case that changes.
- **Status:** open - **Status:** open
## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02
- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional
`latitude`/`longitude` (decimals) from the request body — the coordinates the user dropped with the map-pin
picker — and persist those when provided, only falling back to the `IGeocoder` when the client sends none.
- **Why:** The b4 contract's create body geocodes server-side from `addressLine`+city and does **not** accept
client coordinates, but f3 requires the user to **drop a pin** on the map (a hard client-side validation) so
the stored coordinate is the user's exact door location for the later EVV distance check (b9) — a geocoded
street centroid is coarser. The client already sends `latitude`/`longitude` in the create/update body and
echoes them locally; until the server accepts them, the real path silently ignores them and geocodes instead.
- **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store
them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already
returns `latitude`/`longitude`.
- **Status:** open
## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02
- **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`).
- **Why:** The address book's **edit** form prefills the cascading province → city → district dropdowns from a
saved address, and the city list is fetched **per province** (`geo/cities?province_id=`). The DTO carries
`cityId` but not its province, so the client can't drive the city query to preselect the city without the
province id. The client currently augments `provinceId` behind the `services/addresses` seam (the mock
persists it; the real client echoes the just-saved choice), so editing an address that was **loaded fresh from
the server** can't prefill the province until this lands. `cityId` still implies the province server-side —
this is purely to prefill the client cascade.
- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`).
- **Status:** open
@@ -0,0 +1,108 @@
# Frontend Phase 3 — Addresses, map picker & nurse coverage areas (b4)
**Track:** frontend · **Consumes:** [`dev/contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md) (backend-phase-4)
· **Unlocks:** f7 booking request (needs a chosen address) + f6 search (needs nurse coverage areas)
· **Date:** 2026-07-03 · **Gate:** `npm run check` green · `npm run test:ci` green (129 tests, +17 across 5 new suites) · `npm run build` green (routes `/[locale]/addresses`, `/[locale]/nurse/coverage` generated)
## What shipped
Both actors get their *place* on the map — pure geography, no money, no clinical data.
### Three domain services (mirroring the `patients`/`nurse` template)
- **`services/geography`** — the cached province → city → district reference lookups. `types.ts`
(`Province`/`City`/`District` + the `GeographyApi` seam), `keys.ts` (`geographyKeys.{provinces,cities,districts}`),
`constants.ts` (`USE_GEOGRAPHY_MOCK`, **`GEO_STALE_TIME = Infinity`** + `GEO_GC_TIME`, `CITY_CENTROIDS` +
`cityCentroid()`), `names.ts` (`pickRegionName`), `apis/{clientApi,mockApi,seed,index}.ts`, hooks
`useProvinces`/`useCities`(enabled on province)/`useDistricts`(enabled on city). **Aggressively cached**: each
level is fetched once per session and shared across both editors (and later f6 search).
- **`services/addresses`** — the customer address book. `types.ts` (`CustomerAddress` = wire `CustomerAddressDto`
+ client-augmented `provinceId`; `CreateAddressInput`; `AddressesApi`), `keys.ts`, `constants.ts`, full
seam+mock+client, hooks `useAddresses`/`useCreateAddress`/`useUpdateAddress`/`useDeleteAddress`/
`useSetPrimaryAddress`**every mutation invalidates `addressKeys.lists()`** (set-primary flips two rows, so
invalidate, don't hand-patch).
- **`services/serviceAreas`** — the nurse coverage areas. `types.ts` (`NurseServiceArea`; `AddServiceAreaInput`;
`ServiceAreasApi`; the pure **`areaExists`** dup-guard), `keys.ts`, `constants.ts`, seam+mock+client, hooks
`useServiceAreas`/`useAddServiceArea`/`useRemoveServiceArea` (add/remove invalidate the list).
### Two shared composites + two form/card composites (`src/components/geography/`, each tested)
- **`CascadingRegionSelect`** — province → city → district dependent MUI selects, **driving the cached geography
queries itself** so both editors drop it in with only `value`/`onChange`. Each level enables on its parent,
resets its children on change; a whole-city-only city surfaces the **whole-city** affordance; district is
optional. Guards edit-prefill against out-of-range values before options load.
- **`AddressMapPicker`** — the **map-pin stand-in** (see Mocks). A tappable/draggable marker canvas that emits
real `{ latitude, longitude }`; marker positioned with inline `style` (physical left/top) + `dir="ltr"` so the
RTL stylis plugin can't mirror the pin off its click point. Centres on the chosen city's centroid.
- **`AddressForm`** — the add/edit body: cascade + map pin + title + street + set-primary toggle; validates
**city required, pin required**, title + street required, district optional. Emits `CreateAddressInput`.
- **`AddressCard`** — presentational list card: title + primary badge (reuses the f0 `StatusChip`), region label,
street line, edit/delete/set-primary; set-primary shows only on non-primary cards (never two primaries).
### Screens
- **Customer address book** (`/addresses`, reached from a profile-hub link) — cards with primary badge, add/edit
dialog (cascade + map pin), set-primary, soft-delete confirm, empty + skeleton states.
- **Nurse coverage editor** (`/nurse/coverage`, new sidebar tab) — area chips (whole-city shown explicitly), an
add control (cascade + whole-city/specific-districts scope toggle), **inline duplicate block** (client
`areaExists` fast path + the server 409 mapped to the same message), remove confirm, and the empty-state
**"won't appear in search"** warning.
### Wiring
- `constants/routes.ts`: `ADDRESSES`, `NURSE_COVERAGE`. `AppIcon` registry: `location`, `delete`, `coverage`.
- i18n: new `geo`/`address`/`coverage` namespaces + `nav.coverage` in **both** `en.json` and `fa.json` (identical
key sets, RTL-first). Colours from `tokens.css` only.
- `client/CLAUDE.md` Project Structure + i18n namespaces + the reference-data caching convention updated.
## What is now testable, and exactly how
Run `cd client && npm run dev`, sign in (f1-b2 OTP). All three services default to their client mock, so the
flows work **without the backend running**.
1. **Cascading dropdowns + caching.** Customer → Profile → *Manage addresses**Add address*. Province → city →
district cascade; a whole-city-only city (Mashhad/Isfahan/…) shows the whole-city affordance. Re-open *Add
address*: the lists come **from cache** (React Query Devtools shows no refetch).
2. **Add with a map pin + set primary.** Pick city (+ optional district), **drop a pin**, enter title + street,
toggle primary, save → the card shows a primary badge. Add a second, set *it* primary → exactly one badge
moves. Save without a city or pin → inline errors.
3. **Nurse coverage + duplicate block.** Nurse → *Coverage*. Empty → the "won't appear in search" warning. Add a
**whole-city** area (a chip); add a **city + district** area; add the **same** pair again → inline "already
covered", no request fired; remove → the chip disappears.
4. **i18n / RTL.** Flip `fa``en`: every label/empty/error/duplicate string translates; the cascade, chips, and
map controls mirror correctly; colours match the brand tokens.
5. `npm run check`, `npm run test:ci`, `npm run build` all pass.
## What is mocked client-side (and how f-next swaps it)
All three services are behind a `services/{domain}` seam with a `USE_*_MOCK` flag (**default `true`**) and a
real `clientApi` already wired to the contract routes — the swap is a one-line flag flip per service. The
**`IGeocoder`** seam is backend-owned (backend-phase-4); the client only sends the picked coordinates. The
`AddressMapPicker` is a **stand-in** (no Neshan/Google tiles) behind a component boundary — a real map drops in
without touching `AddressForm`. See the **mock registry** for the exact rows + make-it-real steps.
## Contract consumed + gaps filed
Consumed `dev/contracts/domains/geography-addresses.md` (camelCase wire, snake_case query params, action-style
routes, 409 on duplicate coverage, single-primary address). Two gaps filed in
[`for-backend.md`](../frontend/requests/for-backend.md):
- **REQ-008** — accept the client-picked `latitude`/`longitude` on address create/update (the contract geocodes
server-side today; f3 requires the user's dropped pin for EVV precision). The client sends them + echoes locally.
- **REQ-009** — add `provinceId` to `CustomerAddressDto` so the edit form can prefill the province→city cascade
(the city list is fetched per-province). Client-augmented behind the seam meanwhile.
## Adversarial review (pre-merge)
Ran a 5-dimension multi-agent review (contract fidelity · conventions/i18n · single-primary · coverage
dedup/cascade · map/RTL/caching) with an adversarial verify pass. **3 findings confirmed and fixed:**
1. **(high, RTL)** `AddressMapPicker` marker's centering `transform` was in `sx`, so stylis-plugin-rtl
flipped its X on the default `fa` locale — the pin rendered ~one icon-width off the tap point. Moved the
transform into the inline `style` alongside the left/top offsets.
2. **(med, contract)** `addresses`/`serviceAreas` `list` sent `page_size` (snake_case), which the server's
`PageSize` binder ignores (only the geo lookups are explicitly snake_cased) → truncated lists once the real
endpoints are used. Changed both to `pageSize`, matching the patients template.
3. **(med, UX)** The coverage "specific districts" scope dead-ended on a whole-city-only city (district-less)
with contradictory `no_districts``district_required` messages. The page now reads the cached districts to
force whole-city for such cities (disables the "districts" toggle), so the add never dead-ends.
## Follow-ups for later phases
- **f7 booking request** consumes the chosen customer address (id + coordinates).
- **f6 search** consumes nurse coverage areas (whole-city rows match every district; reuse the `geography` cache
+ `geographyKeys`, don't reinvent). The same-gender filter is f6, not here.
- When REQ-008/REQ-009 land, flip `USE_ADDRESSES_MOCK` to `false`.
- Swap the `AddressMapPicker` stand-in for a real map (mock registry row).