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' });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user