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:
+94
-4
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user