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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 14:50:41 +03:30
parent 1c266523bc
commit b8934f531d
56 changed files with 2627 additions and 3 deletions
@@ -0,0 +1,80 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { ADDRESSES_PAGE_SIZE } from '../constants';
import type { CreateAddressInput, CustomerAddress, CustomerAddressDto, AddressesApi } from '../types';
const BASE = '/api/v1/customer_addresses';
// The wire `CustomerAddressDto` has no `provinceId` (REQ-009). Reads default it to null; writes
// echo the caller's chosen province onto the returned row so the just-saved address can be
// re-edited with its cascade prefilled (not yet persisted server-side).
function toAddress(dto: CustomerAddressDto, provinceId?: number | null): CustomerAddress {
return { ...dto, provinceId: provinceId ?? null };
}
// Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 —
// the server geocodes today; sending the pin makes the coordinate the user's once accepted);
// `provinceId` stays client-side (city implies province server-side).
function toBody(input: CreateAddressInput) {
return {
title: input.title,
cityId: input.cityId,
districtId: input.districtId ?? null,
addressLine: input.addressLine,
postalCode: input.postalCode ?? null,
recipientName: input.recipientName ?? null,
recipientPhone: input.recipientPhone ?? null,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
isPrimary: input.isPrimary ?? false,
};
}
/**
* Real HTTP implementation of the AddressesApi seam (b4 action-style routes). `clientFetch`
* returns the raw envelope, so each call reads its payload via `unwrap`. Selected once
* USE_ADDRESSES_MOCK is false and REQ-008/REQ-009 land.
*/
export const addressesClientApi: AddressesApi = {
list: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
// Pagination params bind case-insensitively to the server's `PageSize` (not snake_cased, unlike the
// geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`.
query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE));
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<CustomerAddressDto>>>(`${BASE}/list?${query.toString()}`),
);
return { ...page, items: page.items.map((dto) => toAddress(dto)) };
},
create: async (input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input.provinceId,
),
update: async (id, input) =>
toAddress(
unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input.provinceId,
),
setPrimary: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/set_primary/${id}`, { method: 'POST' });
},
remove: async (id) => {
await clientFetch<ApiEnvelope<boolean>>(`${BASE}/delete/${id}`, { method: 'DELETE' });
},
};
@@ -0,0 +1,10 @@
import { USE_ADDRESSES_MOCK } from '../constants';
import type { AddressesApi } from '../types';
import { addressesClientApi } from './clientApi';
import { addressesMockApi } from './mockApi';
/**
* The selected AddressesApi implementation — the single seam hooks import. Selection is by
* config (USE_ADDRESSES_MOCK), never by scattered `if (mock)` checks.
*/
export const addressesApi: AddressesApi = USE_ADDRESSES_MOCK ? addressesMockApi : addressesClientApi;
@@ -0,0 +1,98 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { resolveSeedCity, resolveSeedDistrict } from '@/services/geography/apis/seed';
import { ADDRESSES_PAGE_SIZE } from '../constants';
import type { CreateAddressInput, CustomerAddress, AddressesApi } from '../types';
const MOCK_LATENCY_MS = 350;
// In-memory store, seeded **empty** so a session can demo both the add flow and the E-style
// empty state. The single-primary invariant is enforced here exactly as the server's filtered
// unique index does: exactly one primary while ≥1 address exists.
let store: CustomerAddress[] = [];
let nextId = 1;
// Denormalise the chosen region ids into the display names the DTO carries (the server joins
// these). A whole-city address (no district) keeps null district names.
function build(id: number, input: CreateAddressInput, isPrimary: boolean): CustomerAddress {
const city = resolveSeedCity(input.cityId);
const district = input.districtId == null ? undefined : resolveSeedDistrict(input.districtId);
return {
id,
title: input.title,
provinceId: input.provinceId,
cityId: input.cityId,
cityNameFa: city?.nameFa ?? '',
cityNameEn: city?.nameEn ?? '',
districtId: input.districtId ?? null,
districtNameFa: district?.nameFa ?? null,
districtNameEn: district?.nameEn ?? null,
addressLine: input.addressLine,
postalCode: input.postalCode ?? null,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
isPrimary,
recipientName: input.recipientName ?? null,
recipientPhone: input.recipientPhone ?? null,
};
}
const clearPrimaryExcept = (id: number) =>
store.map((address) => (address.id === id ? address : { ...address, isPrimary: false }));
// Primary first, then most-recent — mirrors the contract's "primary first" list order.
const ordered = () => [...store].sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary) || b.id - a.id);
/**
* In-memory mock behind the AddressesApi seam — the b4 endpoints exist, but the wire DTO lacks
* `provinceId` (REQ-009) and the create body geocodes instead of accepting the pin (REQ-008),
* so this drives the UI until those land. Mirrors the real shapes for a one-line swap.
*/
export const addressesMockApi: AddressesApi = {
list: async (params?: PageParams): Promise<Paginated<CustomerAddress>> => {
await sleep(MOCK_LATENCY_MS);
const all = ordered();
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? ADDRESSES_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
},
create: async (input: CreateAddressInput): Promise<CustomerAddress> => {
await sleep(MOCK_LATENCY_MS);
const becomesPrimary = store.length === 0 || input.isPrimary === true;
const address = build(nextId++, input, becomesPrimary);
store = becomesPrimary ? [address, ...clearPrimaryExcept(address.id)] : [address, ...store];
return address;
},
update: async (id: number, input: CreateAddressInput): Promise<CustomerAddress> => {
await sleep(MOCK_LATENCY_MS);
const existing = store.find((address) => address.id === id);
if (!existing) throw new ApiError(404, 'Address not found');
// Editing never removes the sole primary: keep the prior flag unless the form promotes it.
const isPrimary = input.isPrimary === true || existing.isPrimary;
const updated = build(id, input, isPrimary);
store = store.map((address) => (address.id === id ? updated : address));
if (isPrimary) store = clearPrimaryExcept(id);
return updated;
},
setPrimary: async (id: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
if (!store.some((address) => address.id === id)) throw new ApiError(404, 'Address not found');
store = clearPrimaryExcept(id).map((address) => (address.id === id ? { ...address, isPrimary: true } : address));
},
remove: async (id: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const removed = store.find((address) => address.id === id);
store = store.filter((address) => address.id !== id);
// Keep exactly one primary: if the deleted row was primary, promote the next in order.
if (removed?.isPrimary && store.length > 0) {
const next = ordered()[0].id;
store = store.map((address) => ({ ...address, isPrimary: address.id === next }));
}
},
};
@@ -0,0 +1,16 @@
/**
* When true, the addresses domain is served by the in-memory mock (apis/mockApi.ts) behind the
* AddressesApi seam — single-primary + first-address-primary enforced in-memory so the address
* book demos before backend-phase-4 is reachable. The wire `CustomerAddressDto` also lacks
* `provinceId` (REQ-009) and its create body geocodes rather than accepting the pin (REQ-008),
* so this phase drives the UI behind the mock. Flip to false to use the live
* `api/v1/customer_addresses/*` routes — no hook/component changes
* (see dev/shared-working-context/reports/mocks-registry.md).
*/
export const USE_ADDRESSES_MOCK = true;
/** Address lists change only on mutation; keep them warm across screen visits. */
export const ADDRESSES_STALE_TIME = 60_000;
/** api-conventions default page size; `pageSize` ≤ 100. A customer has a handful of addresses. */
export const ADDRESSES_PAGE_SIZE = 50;
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import type { PageParams } from '@/lib/api/types';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
import { ADDRESSES_STALE_TIME } from '../constants';
/**
* The customer's addresses (primary first). A deliberate staleTime keeps the book warm across
* remounts; every mutation invalidates it, so it always reflects reality without over-fetching.
*/
export function useAddresses(params?: PageParams) {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: addressKeys.list(params),
queryFn: () => addressesApi.list(params),
enabled: isAuthenticated,
staleTime: ADDRESSES_STALE_TIME,
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
import type { CreateAddressInput } from '../types';
/**
* Creates an address. Invalidates every cached list so the new card (and, if it became primary,
* the flipped badge on the old one) reflects reality. Domain 400s (empty title/addressLine,
* invalid city) surface via `mutation.error`; the fetch layer owns 401/403/5xx toasts.
*/
export function useCreateAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateAddressInput) => addressesApi.create(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
/** Soft-deletes an owned address, then invalidates the lists. */
export function useDeleteAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => addressesApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
/**
* Makes an address the customer's primary; single-primary enforcement is server-side (the prior
* primary is cleared atomically). Invalidates the list — never hand-patches — so exactly one
* card shows the badge after the flip.
*/
export function useSetPrimaryAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => addressesApi.setPrimary(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
@@ -0,0 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../apis';
import { addressKeys } from '../keys';
import type { UpdateAddressInput } from '../types';
/** Edits an owned address, then invalidates the lists so the card reflects the change. */
export function useUpdateAddress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateAddressInput }) => addressesApi.update(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: addressKeys.lists() });
},
});
}
+5
View File
@@ -0,0 +1,5 @@
export { useAddresses } from './hooks/useAddresses';
export { useCreateAddress } from './hooks/useCreateAddress';
export { useUpdateAddress } from './hooks/useUpdateAddress';
export { useDeleteAddress } from './hooks/useDeleteAddress';
export { useSetPrimaryAddress } from './hooks/useSetPrimaryAddress';
+14
View File
@@ -0,0 +1,14 @@
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the addresses domain. Hierarchical keys let every mutation
* (create/update/delete/set-primary) invalidate all lists with `addressKeys.lists()` — the
* set-primary flip touches two rows, so we invalidate rather than hand-patch.
*/
export const addressKeys = {
all: ['addresses'] as const,
lists: () => [...addressKeys.all, 'list'] as const,
list: (params?: PageParams) => [...addressKeys.lists(), params ?? {}] as const,
details: () => [...addressKeys.all, 'detail'] as const,
detail: (id: number) => [...addressKeys.details(), id] as const,
};
+79
View File
@@ -0,0 +1,79 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Customer addresses domain — a customer's saved, geocoded delivery addresses, customer-scoped
* and tenancy-enforced server-side. Shapes mirror the b4 contract
* (`dev/contracts/domains/geography-addresses.md` → `CustomerAddressDto`); the wire is camelCase.
*
* PII (`addressLine`/`postalCode`/`recipientName`/`recipientPhone`) is encrypted at rest and
* **decrypted for the owner** on their own `list` — this screen only ever shows the owner their
* own book, so the full `addressLine` is rendered (no masking is needed here).
*
* `provinceId` is **client-augmented**: the wire `CustomerAddressDto` carries `cityId` but not
* its province (filed as REQ-009). We need the province to prefill the cascading dropdowns when
* editing, so the mock persists it and the real client echoes the form's choice onto the
* returned row (a genuinely refetched list from the server carries `null` until REQ-009 lands).
*/
/** Coordinates from the map-pin picker (also what the server's geocoder resolves to). */
export interface LatLng {
latitude: number;
longitude: number;
}
/** The b4 wire shape returned by every `customer_addresses/*` endpoint. */
export interface CustomerAddressDto {
id: number;
title: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
/** Decrypted for the owner; `null` only if never set. */
addressLine: string | null;
postalCode: string | null;
/** `null` when the geocoder couldn't resolve the address ("no map pin"). */
latitude: number | null;
longitude: number | null;
isPrimary: boolean;
recipientName: string | null;
recipientPhone: string | null;
}
/** App-level address = wire shape + the client-augmented `provinceId` (REQ-009). */
export interface CustomerAddress extends CustomerAddressDto {
provinceId: number | null;
}
/**
* Create/update input. `cityId` is required; `districtId` omitted/`null` = the whole city.
* `latitude`/`longitude` are the picked pin (REQ-008 — the contract create body geocodes
* server-side today; we send the pin so the coordinate is the user's, and echo it locally).
* `provinceId` is client-augmented (REQ-009) — used to prefill the cascade on edit.
*/
export interface CreateAddressInput {
title: string;
provinceId: number;
cityId: number;
districtId?: number | null;
addressLine: string;
postalCode?: string | null;
recipientName?: string | null;
recipientPhone?: string | null;
latitude?: number | null;
longitude?: number | null;
isPrimary?: boolean;
}
export type UpdateAddressInput = CreateAddressInput;
/** The domain's API seam — a mock and the real client both implement this interface. */
export interface AddressesApi {
list(params?: PageParams): Promise<Paginated<CustomerAddress>>;
create(input: CreateAddressInput): Promise<CustomerAddress>;
update(id: number, input: UpdateAddressInput): Promise<CustomerAddress>;
setPrimary(id: number): Promise<void>;
remove(id: number): Promise<void>;
}