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
+40 -3
View File
@@ -1,9 +1,46 @@
<script setup lang="ts">
import type { Category, Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_BANNERS, MOCK_PROMOS, MOCK_QUICK_LINKS, homeFloors } from "~/mock/data";
import { MOCK_BANNERS, MOCK_PROMOS, MOCK_QUICK_LINKS } from "~/mock/data";
interface HomeFloor {
categoryId: string;
name: Record<string, string>;
advImage: string;
advUrl: string;
products: Product[];
}
const { locale, t } = useI18n();
const floors = computed(() => homeFloors().filter((f) => f.products.length > 0));
const { $api } = useNuxtApp();
// Shared with the shell's category menu, so this does not refetch the tree.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
// Floor structure comes from the category tree; floor products come from the
// catalog API. Banner, promotion, quick-link and advert art stay local content.
const { data: floors } = await useAsyncData("home-floors", async () => {
const roots: Category[] = categories.value ?? (await $api.listCategories());
const tops = roots
.filter((category) => category.parent_id === null)
.sort((a, b) => a.position - b.position);
const built = await Promise.all(
tops.map(async (category, index) => {
const page = await $api.listProducts({ category_id: category.id, per_page: 8 });
const floor: HomeFloor = {
categoryId: category.id,
name: category.name,
advImage: `/mock/floor-adv-${(index % 6) + 1}.svg`,
advUrl: `/search?category=${category.id}`,
products: page.items,
};
return floor;
}),
);
return built.filter((floor) => floor.products.length > 0);
});
const visibleFloors = computed(() => floors.value ?? []);
</script>
<template>
@@ -32,7 +69,7 @@ const floors = computed(() => homeFloors().filter((f) => f.products.length > 0))
</div>
<div class="section-bg">
<section v-for="floor in floors" :key="floor.categoryId" class="w1200 floor">
<section v-for="floor in visibleFloors" :key="floor.categoryId" class="w1200 floor">
<header class="floor-head">
<h2>{{ pick(floor.name, locale) }}</h2>
<NuxtLink :to="`/search?category=${floor.categoryId}`" class="more">{{ t("home.viewMore") }} </NuxtLink>