Wave 3 of replacing the fixed-data mock adapter: cart, orders, shipments and invoices flip together, so one purchase runs end to end against the backend. - cart: CartItemView carries the line's shop and the SKU's stock, so the cart keeps grouping per shop and the quantity stepper caps at real stock instead of a hard-coded 999 - contract: Shipment.items is optional and Invoice.invoice_no nullable, both matching what the API actually returns. Invoice was declared twice in types.ts and TypeScript merges duplicate interfaces, so the duplicate had to go for the change to take effect at all - an anonymous add-to-cart redirects to /login?redirect=..., and sign-in honours only same-origin paths - the fixed-data adapter learns the new cart fields, and its persisted state key moves to v2 because a cart saved by an older build is no longer valid - order surfaces drop their storeById lookups and keep the generic store label until the public store read arrives Verified end to end: two-shop cart grouping with live shop names, stock caps read from the API, checkout, payment, shipment, delivery confirmation and an issued invoice. Rollback re-verified with every domain on fixed data and the backend stopped. Also checks off Wave 3 in docs/TBD-migrate-wave.md and re-points that file at the mock content that remains. OpenSpec change: openspec/changes/replace-mock-api-wave-3
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
// Mock API adapter: implements the full @vmall/shared ApiClient surface with
|
|
// fixed data + in-memory cart/order state. Pages call $api exactly as they
|
|
// would against the live backend, so switching back is a config flip.
|
|
|
|
import { ApiError } from "@vmall/shared";
|
|
import type {
|
|
Address,
|
|
ApiClient,
|
|
AuthTokens,
|
|
Cart,
|
|
CartItem,
|
|
Invoice,
|
|
InvoiceKind,
|
|
Order,
|
|
Product,
|
|
Shipment,
|
|
User,
|
|
} from "@vmall/shared";
|
|
import {
|
|
BASE_CURRENCY,
|
|
MOCK_CATEGORIES,
|
|
MOCK_CURRENCIES,
|
|
MOCK_STORES,
|
|
MOCK_USER,
|
|
defaultAddress,
|
|
mockConvertMinor,
|
|
productById,
|
|
searchMockProducts,
|
|
seedOrders,
|
|
storeById,
|
|
} from "./data";
|
|
|
|
interface MockState {
|
|
token: string | null;
|
|
cart: CartItem[];
|
|
orders: Order[];
|
|
shipments: Shipment[];
|
|
invoices: Invoice[];
|
|
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";
|
|
|
|
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "orderSeq" | "invoiceSeq">;
|
|
|
|
// Load cart/order session state persisted by a previous page load (client only).
|
|
function loadPersisted(): PersistedState | null {
|
|
if (!import.meta.client) return null;
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return null;
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
const p = parsed as Partial<PersistedState>;
|
|
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;
|
|
return p as PersistedState;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function initialState(): MockState {
|
|
const persisted = loadPersisted();
|
|
if (persisted) return { token: null, ...persisted };
|
|
const seed = seedOrders(MOCK_USER.id);
|
|
return {
|
|
token: null,
|
|
cart: [],
|
|
orders: seed.orders,
|
|
shipments: seed.shipments,
|
|
invoices: seed.invoices,
|
|
orderSeq: 100,
|
|
invoiceSeq: 100,
|
|
};
|
|
}
|
|
|
|
function unsupported(): never {
|
|
throw new ApiError(501, "MOCK_UNSUPPORTED", "This admin/shop endpoint is not part of the mall mock.");
|
|
}
|
|
|
|
|
|
function skuIndex(): Record<string, { product: Product; skuId: string }> {
|
|
const index: Record<string, { product: Product; skuId: string }> = {};
|
|
for (const p of searchMockProducts({ perPage: 1000 }).items) {
|
|
for (const s of p.skus ?? []) index[s.id] = { product: p, skuId: s.id };
|
|
}
|
|
return index;
|
|
}
|
|
|
|
export function createMockApi(): ApiClient {
|
|
const state = initialState();
|
|
function persist(): void {
|
|
if (!import.meta.client) return;
|
|
try {
|
|
const snapshot: PersistedState = {
|
|
cart: state.cart,
|
|
orders: state.orders,
|
|
shipments: state.shipments,
|
|
invoices: state.invoices,
|
|
orderSeq: state.orderSeq,
|
|
invoiceSeq: state.invoiceSeq,
|
|
};
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
|
} catch {
|
|
/* storage unavailable: keep in-memory only */
|
|
}
|
|
}
|
|
|
|
function tokens(): AuthTokens {
|
|
state.token = `mock-token-${Date.now()}`;
|
|
return { token: state.token, user: MOCK_USER };
|
|
}
|
|
|
|
function cartSnapshot(): Cart {
|
|
return { items: state.cart.map((i) => ({ ...i })) };
|
|
}
|
|
|
|
function nextOrderNo(): string {
|
|
state.orderSeq += 1;
|
|
return `VM20260917${String(state.orderSeq).padStart(3, "0")}`;
|
|
}
|
|
|
|
return {
|
|
register: () => Promise.resolve(tokens()),
|
|
login: () => Promise.resolve(tokens()),
|
|
me: (): Promise<User> => Promise.resolve(MOCK_USER),
|
|
|
|
listProducts: (q = {}) =>
|
|
Promise.resolve(
|
|
searchMockProducts({
|
|
q: q.q,
|
|
categoryId: q.category_id,
|
|
shopId: q.shop_id,
|
|
page: q.page,
|
|
perPage: q.per_page,
|
|
}),
|
|
),
|
|
|
|
getProduct: (idOrSlug) => {
|
|
const product = productById(idOrSlug);
|
|
if (!product) return Promise.reject(new ApiError(404, "NOT_FOUND", "Product not found"));
|
|
return Promise.resolve(product);
|
|
},
|
|
|
|
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
|
|
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
|
|
convert: (amountMinor, from, to) =>
|
|
Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }),
|
|
|
|
getCart: () => Promise.resolve(cartSnapshot()),
|
|
|
|
addCartItem: (skuId, qty) => {
|
|
const entry = skuIndex()[skuId];
|
|
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "SKU not found"));
|
|
const sku = entry.product.skus?.find((s) => s.id === skuId);
|
|
if (!sku || !sku.active) return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available"));
|
|
const existing = state.cart.find((i) => i.sku_id === skuId);
|
|
const nextQty = (existing?.qty ?? 0) + qty;
|
|
if (nextQty > sku.stock) return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock"));
|
|
if (existing) existing.qty = nextQty;
|
|
else {
|
|
state.cart.push({
|
|
sku_id: skuId,
|
|
product_id: entry.product.id,
|
|
product_name: entry.product.name,
|
|
sku_code: sku.sku_code,
|
|
image: entry.product.images[0] ?? null,
|
|
unit_price_minor: sku.price_minor,
|
|
currency: sku.currency,
|
|
qty,
|
|
// The live cart view carries these too; see replace-mock-api-wave-3.
|
|
shop_id: entry.product.shop_id,
|
|
shop_name: storeById(entry.product.shop_id)?.name ?? MOCK_STORES[0].name,
|
|
stock: sku.stock,
|
|
});
|
|
}
|
|
persist();
|
|
return Promise.resolve(cartSnapshot());
|
|
},
|
|
|
|
updateCartItem: (skuId, qty) => {
|
|
const item = state.cart.find((i) => i.sku_id === skuId);
|
|
if (!item) return Promise.reject(new ApiError(404, "NOT_FOUND", "Cart item not found"));
|
|
if (qty <= 0) state.cart = state.cart.filter((i) => i.sku_id !== skuId);
|
|
else item.qty = qty;
|
|
persist();
|
|
return Promise.resolve(cartSnapshot());
|
|
},
|
|
|
|
removeCartItem: (skuId) => {
|
|
state.cart = state.cart.filter((i) => i.sku_id !== skuId);
|
|
persist();
|
|
return Promise.resolve(cartSnapshot());
|
|
},
|
|
|
|
checkout: (shippingAddress: Address, currency: string) => {
|
|
if (state.cart.length === 0) {
|
|
return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty"));
|
|
}
|
|
const byShop: Record<string, CartItem[]> = {};
|
|
for (const item of state.cart) {
|
|
const product = productById(item.product_id);
|
|
const shopId = product?.shop_id ?? "unknown";
|
|
(byShop[shopId] ??= []).push(item);
|
|
}
|
|
const created: Order[] = Object.entries(byShop).map(([shopId, items]) => {
|
|
const order: Order = {
|
|
id: `o-${Date.now()}-${shopId}`,
|
|
order_no: nextOrderNo(),
|
|
shop_id: shopId,
|
|
user_id: MOCK_USER.id,
|
|
status: "pending_payment",
|
|
currency,
|
|
total_minor: items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0),
|
|
items: items.map((i, k) => ({
|
|
id: `oi-${Date.now()}-${k}`,
|
|
sku_id: i.sku_id,
|
|
product_name: i.product_name,
|
|
sku_code: i.sku_code,
|
|
unit_price_minor: i.unit_price_minor,
|
|
qty: i.qty,
|
|
image: i.image,
|
|
})),
|
|
shipping_address: shippingAddress,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
return order;
|
|
});
|
|
state.orders = [...created, ...state.orders];
|
|
state.cart = [];
|
|
persist();
|
|
return Promise.resolve(created);
|
|
},
|
|
|
|
listMyOrders: (page = 1) => {
|
|
const perPage = 10;
|
|
const start = (page - 1) * perPage;
|
|
return Promise.resolve({
|
|
items: state.orders.slice(start, start + perPage),
|
|
total: state.orders.length,
|
|
page,
|
|
per_page: perPage,
|
|
});
|
|
},
|
|
|
|
getOrder: (id) => {
|
|
const order = state.orders.find((o) => o.id === id || o.order_no === id);
|
|
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
|
|
return Promise.resolve(order);
|
|
},
|
|
|
|
cancelOrder: (id) => {
|
|
const order = state.orders.find((o) => o.id === id);
|
|
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
|
|
if (order.status !== "pending_payment") {
|
|
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"));
|
|
}
|
|
order.status = "cancelled";
|
|
persist();
|
|
return Promise.resolve(order);
|
|
},
|
|
|
|
payOrder: (id) => {
|
|
const order = state.orders.find((o) => o.id === id);
|
|
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
|
|
if (order.status !== "pending_payment") {
|
|
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be paid"));
|
|
}
|
|
order.status = "paid";
|
|
persist();
|
|
return Promise.resolve(order);
|
|
},
|
|
|
|
confirmDelivered: (shipmentId) => {
|
|
const shipment = state.shipments.find((s) => s.id === shipmentId);
|
|
if (!shipment) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shipment not found"));
|
|
shipment.status = "delivered";
|
|
shipment.delivered_at = new Date().toISOString();
|
|
const order = state.orders.find((o) => o.id === shipment.order_id);
|
|
if (order) order.status = "completed";
|
|
persist();
|
|
return Promise.resolve(shipment);
|
|
},
|
|
|
|
listMyShipments: () => Promise.resolve(state.shipments.map((s) => ({ ...s }))),
|
|
|
|
requestInvoice: (orderId: string, title: string, taxNo: string | null, kind: InvoiceKind) => {
|
|
const order = state.orders.find((o) => o.id === orderId);
|
|
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
|
|
state.invoiceSeq += 1;
|
|
const invoice: Invoice = {
|
|
id: `inv-${Date.now()}`,
|
|
invoice_no: `INV20260917${String(state.invoiceSeq).padStart(3, "0")}`,
|
|
order_id: order.id,
|
|
order_no: order.order_no,
|
|
title,
|
|
tax_no: taxNo,
|
|
kind,
|
|
amount_minor: order.total_minor,
|
|
currency: order.currency,
|
|
status: "requested",
|
|
issued_at: null,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
state.invoices = [invoice, ...state.invoices];
|
|
persist();
|
|
return Promise.resolve(invoice);
|
|
},
|
|
|
|
listMyInvoices: () => Promise.resolve(state.invoices.map((i) => ({ ...i }))),
|
|
|
|
shop: {
|
|
getMyShop: () => unsupported(),
|
|
listMyProducts: () => unsupported(),
|
|
getProduct: () => unsupported(),
|
|
createProduct: () => unsupported(),
|
|
updateProduct: () => unsupported(),
|
|
publish: () => unsupported(),
|
|
unpublish: () => unsupported(),
|
|
upsertSku: () => unsupported(),
|
|
listOrders: () => unsupported(),
|
|
getOrder: () => unsupported(),
|
|
createShipment: () => unsupported(),
|
|
listShipments: () => unsupported(),
|
|
markShipped: () => unsupported(),
|
|
listInvoices: () => unsupported(),
|
|
issueInvoice: () => unsupported(),
|
|
},
|
|
admin: {
|
|
listUsers: () => unsupported(),
|
|
setUserRole: () => unsupported(),
|
|
listShops: () => unsupported(),
|
|
createShop: () => unsupported(),
|
|
setShopStatus: () => unsupported(),
|
|
listOrders: () => unsupported(),
|
|
listCurrencies: () => unsupported(),
|
|
upsertCurrency: () => unsupported(),
|
|
setRate: () => unsupported(),
|
|
},
|
|
};
|
|
}
|
|
|
|
export { defaultAddress };
|