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
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user