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:
@@ -122,12 +122,14 @@ client/
|
||||
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
|
||||
│ │ │ ├── bookings/page.tsx # /bookings
|
||||
│ │ │ ├── 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
|
||||
│ │ │ └── profile/page.tsx # /profile — customer profile + emergency contact (no national-ID)
|
||||
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
|
||||
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
|
||||
│ │ │ ├── page.tsx # /nurse (dashboard)
|
||||
│ │ │ ├── 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)
|
||||
│ │ │ ├── verification/page.tsx # /nurse/verification
|
||||
│ │ │ └── visits/page.tsx # /nurse/visits (EVV)
|
||||
@@ -151,6 +153,7 @@ client/
|
||||
│ ├── PatientForm/ # A4 patient form (name/age/gender/conditions/relation) — reused create+edit
|
||||
│ ├── PatientCard/ # E1 patient summary card + edit/archive actions
|
||||
│ ├── 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
|
||||
├── i18n/
|
||||
│ ├── 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
|
||||
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
|
||||
│ ├── 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}/
|
||||
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
|
||||
│ ├── keys.ts # React Query key factory (hierarchical)
|
||||
@@ -282,6 +288,9 @@ async function MyServerComponent() {
|
||||
- `'profile'` — the customer profile + emergency contact
|
||||
- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder)
|
||||
- `'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)
|
||||
|
||||
**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
|
||||
**invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` — never leave the
|
||||
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
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"wallet": "Wallet",
|
||||
"profile": "Profile",
|
||||
"bank": "Bank account",
|
||||
"coverage": "Coverage",
|
||||
"dashboard": "Dashboard",
|
||||
"verification": "Verification",
|
||||
"visits": "Visits",
|
||||
@@ -165,6 +166,75 @@
|
||||
"empty_title": "No bank account yet",
|
||||
"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": {
|
||||
"customer_title": "Sign in to Balinyaar",
|
||||
"customer_subtitle": "Sign in with your mobile number",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"wallet": "کیفپول",
|
||||
"profile": "پروفایل",
|
||||
"bank": "حساب بانکی",
|
||||
"coverage": "پوشش",
|
||||
"dashboard": "داشبورد",
|
||||
"verification": "احراز هویت",
|
||||
"visits": "ویزیتها",
|
||||
@@ -165,6 +166,75 @@
|
||||
"empty_title": "هنوز حسابی ثبت نشده",
|
||||
"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": {
|
||||
"customer_title": "ورود به بلینیار",
|
||||
"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';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Divider, MenuItem, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, PhoneNumberField } from '@/components';
|
||||
import { Box, Divider, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, PhoneNumberField } from '@/components';
|
||||
import { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { digitsOnly } from '@/utils';
|
||||
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
|
||||
import type { CustomerProfile } from '@/services/profiles/types';
|
||||
@@ -18,7 +19,9 @@ export default function CustomerProfilePage() {
|
||||
|
||||
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => {
|
||||
const t = useTranslations('profile');
|
||||
const ta = useTranslations('address');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertCustomerProfile();
|
||||
|
||||
@@ -123,6 +126,33 @@ const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }
|
||||
>
|
||||
{upsert.isPending ? tc('saving') : t('save')}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 CameraIcon from '@mui/icons-material/PhotoCameraOutlined';
|
||||
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
|
||||
@@ -89,4 +92,7 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
bank: BankIcon,
|
||||
camera: CameraIcon,
|
||||
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;
|
||||
@@ -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';
|
||||
@@ -9,12 +9,16 @@ export const ROUTES = {
|
||||
ONBOARDING: '/onboarding',
|
||||
BOOKINGS: '/bookings',
|
||||
PATIENTS: '/patients',
|
||||
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
|
||||
ADDRESSES: '/addresses',
|
||||
WALLET: '/wallet',
|
||||
PROFILE: '/profile',
|
||||
|
||||
// Nurse app
|
||||
NURSE: '/nurse',
|
||||
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_VERIFICATION: '/nurse/verification',
|
||||
NURSE_VISITS: '/nurse/visits',
|
||||
|
||||
@@ -19,6 +19,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
() => [
|
||||
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' },
|
||||
{ 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('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
|
||||
{ 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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useProvinces } from './hooks/useProvinces';
|
||||
export { useCities } from './hooks/useCities';
|
||||
export { useDistricts } from './hooks/useDistricts';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)
|
||||
-->
|
||||
|
||||
## 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
|
||||
- **Shipped:** three domain services — `services/patients` (rewritten to the b3 `PatientDto` + client-augmented
|
||||
`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`),
|
||||
flagging in case that changes.
|
||||
- **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).
|
||||
Reference in New Issue
Block a user