feat(mall): serve catalog and currency from the live API

Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API
adapter per domain, with catalog and currency served live while auth, cart,
orders, shipments and invoices stay on fixed data.

Backend:
- seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API
  exposes no category write route, so this cannot come from the seed script
- filter public product listing by the category subtree with a recursive CTE,
  matching the mock's existing behaviour instead of exact-match
- add sort=price with order=asc|desc, validated by hand so an unsupported value
  returns the project's ApiError 400 shape rather than axum's own rejection

Mall:
- replace the all-or-nothing mockApi boolean with a liveDomains list composed
  through a typed per-domain pick map
- source home floors, the category menu, search and product detail from the
  catalog API; banners, promos, quick links, store card and comment/coupon
  content stay local display-only content
- drop the brand facet and the sales/comments sorts: no backend model backs them
- fix salesOf/commentCountOf, which parsed digits out of the product id and so
  rendered "NaN sold" for live UUID ids; they now hash the id

Seed: 24 products across 4 shops, idempotent on re-run.

Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API
to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work.

OpenSpec change: openspec/changes/replace-mock-api-wave-1
This commit is contained in:
2026-09-17 15:15:25 +00:00
parent 44466e5e88
commit e0e833d0e5
20 changed files with 909 additions and 223 deletions
+15 -54
View File
@@ -19,11 +19,6 @@ const L = (en: string, zh: string): LocalizedText => ({ en, zh });
// ---------- mall-local mock types ----------
export interface MockBrand {
id: string;
name: string;
}
export interface MockStore {
id: string;
slug: string;
@@ -55,14 +50,6 @@ export interface MockPromo {
url: string;
}
export interface HomeFloor {
categoryId: string;
name: LocalizedText;
advImage: string;
advUrl: string;
products: Product[];
}
export interface MockComment {
id: string;
productId: string;
@@ -343,32 +330,10 @@ export function categorySubtreeIds(rootId: string): Set<string> {
return ids;
}
export function topCategories(): Category[] {
return MOCK_CATEGORIES.filter((c) => c.parent_id === null).sort((a, b) => a.position - b.position);
}
export function childCategories(parentId: string): Category[] {
return MOCK_CATEGORIES.filter((c) => c.parent_id === parentId).sort((a, b) => a.position - b.position);
}
// ---------- brands ----------
export const MOCK_BRANDS: MockBrand[] = [
{ id: "b1", name: "Aurora" },
{ id: "b2", name: "Nordwind" },
{ id: "b3", name: "Hexon" },
{ id: "b4", name: "Mikado" },
{ id: "b5", name: "Solace" },
{ id: "b6", name: "Terra" },
];
const PRODUCT_BRAND: Record<string, string> = {};
export function brandOf(productId: string): MockBrand | null {
const id = PRODUCT_BRAND[productId];
return MOCK_BRANDS.find((b) => b.id === id) ?? null;
}
// ---------- stores ----------
export const MOCK_STORES: MockStore[] = [
@@ -547,12 +512,20 @@ export interface MockSearchQuery {
perPage?: number;
}
// deterministic pseudo stats so sort orders are stable
// Deterministic pseudo stats so sort orders are stable. Seeded by hashing the
// id, not by parsing digits out of it: mock ids are "p1" but live ids are UUIDs,
// and `Number("f8a1...")` is NaN, which rendered as "NaN sold".
function seedOf(id: string): number {
let hash = 0;
for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
return hash;
}
export function salesOf(p: Product): number {
return 50 + ((Number(p.id.slice(1)) * 137) % 950);
return 50 + ((seedOf(p.id) * 137) % 950);
}
export function commentCountOf(p: Product): number {
return 5 + ((Number(p.id.slice(1)) * 61) % 240);
return 5 + ((seedOf(p.id) * 61) % 240);
}
export function searchMockProducts(query: MockSearchQuery): { items: Product[]; total: number; page: number; per_page: number } {
@@ -585,7 +558,7 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[];
list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b)));
break;
default:
list = [...list].sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1)));
list = [...list].sort((a, b) => seedOf(a.id) - seedOf(b.id));
}
const total = list.length;
const items = list.slice((page - 1) * perPage, page * perPage);
@@ -624,20 +597,6 @@ export const MOCK_PROMOS: MockPromo[] = [
{ image: "/mock/promo-3.svg", url: "/search?category=c6" },
];
export function homeFloors(): HomeFloor[] {
return topCategories().map((cat, i) => {
const ids = categorySubtreeIds(cat.id);
const products = MOCK_PRODUCTS.filter((p) => p.category_id !== null && ids.has(p.category_id)).slice(0, 8);
return {
categoryId: cat.id,
name: cat.name,
advImage: `/mock/floor-adv-${i + 1}.svg`,
advUrl: `/search?category=${cat.id}`,
products,
};
});
}
// ---------- product detail extras ----------
export const MOCK_COUPONS: MockCoupon[] = [
@@ -677,7 +636,9 @@ export function commentsFor(productId: string): MockComment[] {
}
export function commentStats(productId: string): { all: number; good: number; medium: number; bad: number; goodRate: number } {
const all = commentCountOf(productById(productId) ?? MOCK_PRODUCTS[0]);
// Derived from the id itself so it matches commentCountOf for live UUID ids
// too, instead of falling back to an arbitrary mock product.
const all = 5 + ((seedOf(productId) * 61) % 240);
const good = Math.round(all * 0.92);
const medium = Math.round(all * 0.06);
const bad = all - good - medium;