feat(mall): address book wave 7 frontend, contract, and archive

Live addresses domain for the mall: shared contract (AddressBookEntry +
five client methods), mock adapter state v3, live domain pick, addresses
page CRUD, checkout saved-address picker with manual fallback, demo seed,
and the archived change plus address-book capability spec.
This commit is contained in:
Chengdong Zhang
2026-09-18 16:00:05 +08:00
parent 5e866d08f4
commit c51e96ae41
17 changed files with 467 additions and 88 deletions
+94 -4
View File
@@ -5,6 +5,8 @@
import { ApiError } from "@vmall/shared";
import type {
Address,
AddressBookEntry,
AddressInput,
ApiClient,
AuthTokens,
Cart,
@@ -28,6 +30,7 @@ import {
MOCK_QUICK_LINKS,
MOCK_STORES,
MOCK_USER,
MOCK_ADDRESSES,
defaultAddress,
mockConvertMinor,
productById,
@@ -42,15 +45,16 @@ interface MockState {
orders: Order[];
shipments: Shipment[];
invoices: Invoice[];
addresses: AddressBookEntry[];
addressSeq: number;
orderSeq: number;
invoiceSeq: number;
}
// v2: cart lines gained shop_id/shop_name/stock, so state saved by an older
// build is no longer a valid CartItem[].
const STORAGE_KEY = "vmall.mock.state.v2";
// v3: address book joined the persisted state.
const STORAGE_KEY = "vmall.mock.state.v3";
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "orderSeq" | "invoiceSeq">;
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "addresses" | "orderSeq" | "invoiceSeq" | "addressSeq">;
// Load cart/order session state persisted by a previous page load (client only).
function loadPersisted(): PersistedState | null {
@@ -64,6 +68,7 @@ function loadPersisted(): PersistedState | null {
if (!Array.isArray(p.cart) || !Array.isArray(p.orders)) return null;
if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null;
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
return p as PersistedState;
} catch {
return null;
@@ -74,12 +79,27 @@ function initialState(): MockState {
const persisted = loadPersisted();
if (persisted) return { token: null, ...persisted };
const seed = seedOrders(MOCK_USER.id);
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
id: a.id,
user_id: MOCK_USER.id,
recipient: a.recipient,
phone: a.phone,
country: "US",
region: a.region,
city: a.city,
line1: a.line1,
postal_code: a.postalCode,
is_default: a.isDefault,
created_at: `2026-08-0${i + 1}T09:00:00.000Z`,
}));
return {
token: null,
cart: [],
orders: seed.orders,
shipments: seed.shipments,
invoices: seed.invoices,
addresses: seededAddresses,
addressSeq: 100,
orderSeq: 100,
invoiceSeq: 100,
};
@@ -132,6 +152,8 @@ export function createMockApi(): ApiClient {
invoices: state.invoices,
orderSeq: state.orderSeq,
invoiceSeq: state.invoiceSeq,
addresses: state.addresses,
addressSeq: state.addressSeq,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
@@ -386,6 +408,74 @@ export function createMockApi(): ApiClient {
return Promise.resolve(toShopProfile(store));
},
listMyAddresses: () =>
Promise.resolve(
[...state.addresses].sort(
(a, b) => Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
),
),
createAddress: (input: AddressInput) => {
const makeDefault = input.is_default === true || state.addresses.length === 0;
if (makeDefault) for (const a of state.addresses) a.is_default = false;
state.addressSeq += 1;
const entry: AddressBookEntry = {
id: `a-${state.addressSeq}`,
user_id: MOCK_USER.id,
recipient: input.recipient,
phone: input.phone,
country: input.country,
region: input.region,
city: input.city,
line1: input.line1,
postal_code: input.postal_code,
is_default: makeDefault,
created_at: new Date().toISOString(),
};
state.addresses.push(entry);
persist();
return Promise.resolve(entry);
},
updateAddress: (id: string, input: AddressInput) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
if (input.is_default === true && !entry.is_default) {
for (const a of state.addresses) a.is_default = false;
}
entry.recipient = input.recipient;
entry.phone = input.phone;
entry.country = input.country;
entry.region = input.region;
entry.city = input.city;
entry.line1 = input.line1;
entry.postal_code = input.postal_code;
entry.is_default = input.is_default === true || entry.is_default;
persist();
return Promise.resolve({ ...entry });
},
deleteAddress: (id: string) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
const wasDefault = entry.is_default;
state.addresses = state.addresses.filter((a) => a.id !== id);
if (wasDefault && state.addresses.length > 0) {
const latest = state.addresses.reduce((a, b) => (a.created_at > b.created_at ? a : b));
latest.is_default = true;
}
persist();
return Promise.resolve([...state.addresses]);
},
setDefaultAddress: (id: string) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
for (const a of state.addresses) a.is_default = a.id === id;
persist();
return Promise.resolve({ ...entry });
},
shop: {
getMyShop: () => unsupported(),
listMyProducts: () => unsupported(),