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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user