Wave 5: the store directory, store home and product-page store card stop reading
MOCK_STORES, and the payment and order surfaces name their shop.
- a `shop_profiles` table beside `shops`, so the identity model both consoles
consume is untouched, with a public `GET /api/shops` and `GET /api/shops/{slug}`
and an admin `PUT /api/admin/shops/{id}/profile`
- a shop with no profile is still listed, with the fields absent rather than
invented; the pages guard every block, and a missing logo renders an
initial-letter placeholder
- `scripts/seed-demo.mjs` upserts a profile per demo shop, since profiles hang
off shops that script creates
- payment and order pages resolve shop ids to names from one cached shop read,
retiring the generic "Shop" label
- three things went rather than being faked, following the wave-1 precedent:
`distanceKm` and its sort (no geo model), the store home's sales/comments
sorts, and its "best sellers" rail (no sales model)
- `lowestSku` moved out of the fixed-data module into `apps/mall/utils/product.ts`
and re-exported, so live pages stop importing the mock module for a pure
helper
Verified: 28 backend tests green including five new shop tests; all three
frontends build; the directory, store home, store card and order cards all render
real data with no distance or sales claims; the fixed-data rollback still renders
the store surfaces with the backend stopped.
Note: `nuxt build` does not typecheck in this repo (no `typescript.typeCheck`,
no `vue-tsc`), which AGENTS.md implies it does. A re-export used here created no
local binding and broke internal callers at runtime while the build stayed green;
`docs/TBD-migrate-wave.md` records the gap.
OpenSpec change: openspec/changes/replace-mock-api-wave-5
420 lines
13 KiB
TypeScript
420 lines
13 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,
|
|
HomeContent,
|
|
Invoice,
|
|
InvoiceKind,
|
|
Order,
|
|
Product,
|
|
Shipment,
|
|
ShopProfile,
|
|
User,
|
|
} from "@vmall/shared";
|
|
import {
|
|
BASE_CURRENCY,
|
|
MOCK_BANNERS,
|
|
MOCK_CATEGORIES,
|
|
MOCK_CURRENCIES,
|
|
MOCK_PROMOS,
|
|
MOCK_QUICK_LINKS,
|
|
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.");
|
|
}
|
|
|
|
type MockStoreRecord = NonNullable<ReturnType<typeof storeById>>;
|
|
|
|
/** Mock stores carry a nested `rate`; the API exposes flat score fields. */
|
|
function toShopProfile(store: MockStoreRecord): ShopProfile {
|
|
return {
|
|
id: store.id,
|
|
slug: store.slug,
|
|
name: store.name,
|
|
company: store.company,
|
|
region: store.region,
|
|
address: store.address,
|
|
logo: store.logo,
|
|
banner: store.banner,
|
|
notice: store.notice,
|
|
after_sale: store.afterSale,
|
|
score_rating: store.rate.score,
|
|
score_agreement: store.rate.agree,
|
|
score_service: store.rate.service,
|
|
score_speed: store.rate.speed,
|
|
};
|
|
}
|
|
|
|
|
|
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 }))),
|
|
|
|
// Mirror of the seeded storefront-content rows, so the home page renders
|
|
// identically when every domain is configured to fixed data.
|
|
getHomeContent: (): Promise<HomeContent> =>
|
|
Promise.resolve({
|
|
banners: MOCK_BANNERS.map((b, i) => ({
|
|
id: `bn${i + 1}`,
|
|
image: b.image,
|
|
url: b.url,
|
|
position: i,
|
|
active: true,
|
|
})),
|
|
promos: MOCK_PROMOS.map((p, i) => ({
|
|
id: `pr${i + 1}`,
|
|
image: p.image,
|
|
url: p.url,
|
|
position: i,
|
|
active: true,
|
|
})),
|
|
quick_links: MOCK_QUICK_LINKS.map((q, i) => ({
|
|
id: `ql${i + 1}`,
|
|
label: q.label,
|
|
url: q.url,
|
|
glyph: q.glyph,
|
|
position: i,
|
|
active: true,
|
|
})),
|
|
floor_adverts: Array.from({ length: 6 }, (_, i) => ({
|
|
id: `fa${i + 1}`,
|
|
image: `/mock/floor-adv-${i + 1}.svg`,
|
|
position: i,
|
|
active: true,
|
|
})),
|
|
}),
|
|
|
|
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)),
|
|
getShop: (slug) => {
|
|
const store = storeById(slug);
|
|
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
|
|
return Promise.resolve(toShopProfile(store));
|
|
},
|
|
|
|
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(),
|
|
getContent: () => unsupported(),
|
|
replaceContent: () => unsupported(),
|
|
setShopProfile: () => unsupported(),
|
|
},
|
|
};
|
|
}
|
|
|
|
export { defaultAddress };
|