Files
vmall/apps/mall/mock/api.ts
T
Chengdong Zhang 21e99bb52b feat(mall): classic B2B2C PC storefront with fixed mock API layer
- Mock adapter implementing @vmall/shared ApiClient (localStorage-persisted
  cart/orders/invoices), runtime switch via mockApi flag (default on)
- Fixed bilingual mock catalog: 3-level categories, 24 products w/ SKUs,
  stores, brands, banners, floors, seckill/collective/integral, comments,
  coupons, addresses, seeded orders/shipments/invoices
- B2B2C mall shell: top bar, logo/search/cart header, dark nav + category
  mega-menu, value-prop footer, back-to-top; 1200px grid, #ca151e theme
- UI primitives replacing element-plus: carousel, pagination, breadcrumb,
  qty stepper, rating stars, modal, tabs, step bar, product card
- Pages: home floors, search (filters/sort/paging), goods detail (SKU
  picker, store rail, review tabs), cart -> checkout -> pay -> success,
  auth pages, user center (dashboard/orders/addresses/favorites/coupons/
  invoices), stores, seckill, collective, integral
- i18n split into per-domain locale modules (en+zh)
- OpenSpec change mall-pc-storefront-replica archived; all specs green
2026-09-17 19:13:29 +08:00

341 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_USER,
defaultAddress,
mockConvertMinor,
productById,
searchMockProducts,
seedOrders,
} from "./data";
interface MockState {
token: string | null;
cart: CartItem[];
orders: Order[];
shipments: Shipment[];
invoices: Invoice[];
orderSeq: number;
invoiceSeq: number;
}
const STORAGE_KEY = "vmall.mock.state.v1";
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,
});
}
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 };