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
+19 -5
View File
@@ -1,18 +1,32 @@
<script setup lang="ts">
import { topCategories, childCategories } from "~/mock/data";
import type { Category } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
const props = withDefaults(defineProps<{ pinned?: boolean }>(), { pinned: false });
const { locale } = useI18n();
const { $api } = useNuxtApp();
const open = ref(false);
const hoverId = ref<string | null>(null);
// The catalog domain serves this live in Wave 1; the fixed-data adapter answers
// the same call when it is rolled back. Errors are captured rather than thrown,
// so a backend outage leaves the menu empty instead of breaking the shell.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const childrenOf = (parentId: string): Category[] =>
(categories.value ?? [])
.filter((category) => category.parent_id === parentId)
.sort((a, b) => a.position - b.position);
const cats = computed(() =>
topCategories().map((c) => ({
cat: c,
children: childCategories(c.id).map((cc) => ({ cat: cc, grandchildren: childCategories(cc.id) })),
})),
(categories.value ?? [])
.filter((category) => category.parent_id === null)
.sort((a, b) => a.position - b.position)
.map((cat) => ({
cat,
children: childrenOf(cat.id).map((cc) => ({ cat: cc, grandchildren: childrenOf(cc.id) })),
})),
);
const active = computed(() => cats.value.find((g) => g.cat.id === hoverId.value) ?? null);
+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;
+4 -1
View File
@@ -6,7 +6,10 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBase: "http://localhost:8080/api",
mockApi: true,
// Domains served by the live backend; every other domain stays on the
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
// See openspec/changes/replace-mock-api-wave-1/design.md.
liveDomains: ["catalog", "currency"],
appName: "mall",
},
},
+51 -6
View File
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Category, Sku } from "@vmall/shared";
import type { Category, Product, Sku } from "@vmall/shared";
import {
MOCK_CATEGORIES,
MOCK_COUPONS,
MOCK_STORES,
commentStats,
commentsFor,
lowestSku,
productDetail,
salesOf,
storeById,
} from "~/mock/data";
import { useCartStore } from "~/stores/cart";
@@ -21,8 +24,47 @@ const routeId = computed(() => {
const value = route.params.id;
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
});
const detail = computed(() => productDetail(routeId.value));
const product = computed(() => detail.value?.product ?? null);
// One request key for the whole page: the ranking rail must be derived from the
// product that was just fetched, and chaining two useAsyncData calls left the
// second handler running before the first had data.
const { data: pageData } = await useAsyncData(
"product-detail",
async () => {
const current = await $api.getProduct(routeId.value);
// The rail uses live catalogue products so its links resolve; the store
// card, comments and coupons stay local display-only content (non-goals).
// Store best sellers, mirroring the mock's salesRankFor(shopId). Ranking by
// category would empty the rail for any product alone in its leaf category.
const siblings = await $api.listProducts({
shop_id: current.shop_id,
per_page: 6,
});
return {
product: current,
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
};
},
{ watch: [routeId], default: () => null },
);
const product = computed(() => pageData.value?.product ?? null);
const related = computed<Product[]>(() => pageData.value?.related ?? []);
const detail = computed(() => {
const current = product.value;
if (!current) return null;
return {
product: current,
store: storeById(current.shop_id) ?? MOCK_STORES[0],
comments: commentsFor(current.id),
commentStats: commentStats(current.id),
coupons: MOCK_COUPONS,
salesRank: related.value,
};
});
/// Display-only sales figure; `salesOf` hashes the id so it is safe for UUIDs.
const store = computed(() => detail.value?.store ?? null);
const selectedAttributes = reactive<Record<string, string>>({});
const quantity = ref(1);
@@ -76,7 +118,10 @@ const marketPrice = computed(() => {
});
const currentImage = computed(() => product.value?.images[galleryIndex.value] ?? product.value?.images[0] ?? "/mock/product-1.svg");
const categoryById = (id: string): Category | undefined => MOCK_CATEGORIES.find((category) => category.id === id);
// Shared with the shell's category menu, so the tree is not refetched.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const categoryById = (id: string): Category | undefined =>
(categories.value ?? []).find((category) => category.id === id);
const categoryPath = computed(() => {
const path: Category[] = [];
let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined;
+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>
+38 -51
View File
@@ -1,20 +1,16 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Category } from "@vmall/shared";
import {
MOCK_BRANDS,
MOCK_CATEGORIES,
childCategories,
searchMockProducts,
topCategories,
} from "~/mock/data";
import type { Category, Paged, Product } from "@vmall/shared";
type SortType = "default" | "price" | "sales" | "comments";
// Only sorts the catalog model can answer. Brand and sales/comment facets were
// removed in Wave 1: no backend model backs them.
type SortType = "default" | "price";
type OrderType = "asc" | "desc";
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const queryValue = (value: unknown): string => {
if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : "";
@@ -22,33 +18,41 @@ const queryValue = (value: unknown): string => {
};
const state = computed(() => {
const sortValue = queryValue(route.query.sort);
const orderValue = queryValue(route.query.order);
const sort: SortType = ["default", "price", "sales", "comments"].includes(sortValue)
? (sortValue as SortType)
: "default";
const order: OrderType = orderValue === "asc" ? "asc" : "desc";
const sort: SortType = queryValue(route.query.sort) === "price" ? "price" : "default";
const order: OrderType = queryValue(route.query.order) === "asc" ? "asc" : "desc";
const pageValue = Number.parseInt(queryValue(route.query.page), 10);
return {
q: queryValue(route.query.q),
category: queryValue(route.query.category),
brand: queryValue(route.query.brand),
sort,
order,
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
};
});
const result = computed(() =>
searchMockProducts({
q: state.value.q,
categoryId: state.value.category || undefined,
brandId: state.value.brand || undefined,
sort: state.value.sort,
order: state.value.order,
page: state.value.page,
perPage: 20,
}),
const emptyPage: Paged<Product> = { items: [], total: 0, page: 1, per_page: 20 };
const { data: result } = await useAsyncData(
"search-results",
() =>
$api.listProducts({
q: state.value.q || undefined,
category_id: state.value.category || undefined,
sort: state.value.sort === "price" ? "price" : undefined,
order: state.value.sort === "price" ? state.value.order : undefined,
page: state.value.page,
per_page: 20,
}),
{ watch: [state], default: () => emptyPage },
);
// Shared with the shell's category menu, so the tree is not refetched.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const allCategories = computed(() => categories.value ?? []);
const byPosition = (a: Category, b: Category): number => a.position - b.position;
const topLevelCategories = computed(() =>
allCategories.value.filter((category) => category.parent_id === null).sort(byPosition),
);
const replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => {
@@ -56,7 +60,6 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
for (const [key, value] of Object.entries({
q: state.value.q,
category: state.value.category,
brand: state.value.brand,
sort: state.value.sort,
order: state.value.order,
page: String(state.value.page),
@@ -64,9 +67,9 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
})) {
if (value) next[key] = value;
}
if (next.sort === "default") {
if (next.sort === "default" || next.sort === undefined) {
delete next.sort;
if (next.order === "desc") delete next.order;
delete next.order;
}
if (next.page === "1") delete next.page;
await router.replace({ query: next });
@@ -76,10 +79,6 @@ const chooseCategory = (id?: string): void => {
void replaceQuery({ category: id, page: "1" });
};
const chooseBrand = (id?: string): void => {
void replaceQuery({ brand: id, page: "1" });
};
const chooseSort = (sort: SortType): void => {
const order: OrderType = state.value.sort === sort && sort !== "default"
? state.value.order === "asc" ? "desc" : "asc"
@@ -87,7 +86,8 @@ const chooseSort = (sort: SortType): void => {
void replaceQuery({ sort, order, page: "1" });
};
const categoryById = (id: string): Category | undefined => MOCK_CATEGORIES.find((category) => category.id === id);
const categoryById = (id: string): Category | undefined =>
allCategories.value.find((category) => category.id === id);
const selectedPath = computed(() => {
const path: Category[] = [];
@@ -101,7 +101,9 @@ const selectedPath = computed(() => {
const categoryChildren = computed(() => {
const selected = selectedPath.value[selectedPath.value.length - 1];
return selected ? childCategories(selected.id) : [];
return selected
? allCategories.value.filter((category) => category.parent_id === selected.id).sort(byPosition)
: [];
});
const breadcrumbItems = computed(() => [
@@ -116,8 +118,6 @@ const breadcrumbItems = computed(() => [
const sortOptions = computed(() => [
{ key: "default" as SortType, label: t("search.defaultSort") },
{ key: "price" as SortType, label: t("search.price") },
{ key: "sales" as SortType, label: t("search.sales") },
{ key: "comments" as SortType, label: t("search.comments") },
]);
</script>
@@ -131,7 +131,7 @@ const sortOptions = computed(() => [
<div class="filter-options">
<button type="button" :class="{ active: !state.category }" @click="chooseCategory()">{{ t("search.all") }}</button>
<button
v-for="category in topCategories()"
v-for="category in topLevelCategories"
:key="category.id"
type="button"
:class="{ active: category.id === state.category }"
@@ -155,19 +155,6 @@ const sortOptions = computed(() => [
>{{ pick(category.name, locale) }}</button>
</div>
</div>
<div class="filter-row">
<strong>{{ t("search.brand") }}</strong>
<div class="filter-options">
<button type="button" :class="{ active: !state.brand }" @click="chooseBrand()">{{ t("search.all") }}</button>
<button
v-for="brand in MOCK_BRANDS"
:key="brand.id"
type="button"
:class="{ active: brand.id === state.brand }"
@click="chooseBrand(brand.id)"
>{{ brand.name }}</button>
</div>
</div>
<div class="filter-row sort-row">
<strong>{{ t("search.sort") }}</strong>
<div class="filter-options">
+67 -9
View File
@@ -1,16 +1,74 @@
import { createApi } from "@vmall/shared";
import type { ApiClient } from "@vmall/shared";
import { createMockApi } from "~/mock/api";
/**
* Domains the live backend serves. Every other domain stays on the fixed-data
* adapter, so a domain can be migrated - or rolled back - by editing this list
* alone. See openspec/changes/replace-mock-api-wave-1/design.md.
*/
type LiveDomain = "auth" | "catalog" | "currency" | "cart" | "orders" | "shipments" | "invoices";
/**
* Explicit per-domain method picks rather than a string allowlist: indexing
* `ApiClient` by a union of method names would need an unsafe cast, and this
* keeps the compiler checking that every picked key exists.
*/
const LIVE_PICKS = {
auth: (a: ApiClient) => ({ register: a.register, login: a.login, me: a.me }),
catalog: (a: ApiClient) => ({
listProducts: a.listProducts,
getProduct: a.getProduct,
listCategories: a.listCategories,
}),
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
cart: (a: ApiClient) => ({
getCart: a.getCart,
addCartItem: a.addCartItem,
updateCartItem: a.updateCartItem,
removeCartItem: a.removeCartItem,
}),
orders: (a: ApiClient) => ({
checkout: a.checkout,
listMyOrders: a.listMyOrders,
getOrder: a.getOrder,
cancelOrder: a.cancelOrder,
payOrder: a.payOrder,
}),
shipments: (a: ApiClient) => ({
confirmDelivered: a.confirmDelivered,
listMyShipments: a.listMyShipments,
}),
invoices: (a: ApiClient) => ({
requestInvoice: a.requestInvoice,
listMyInvoices: a.listMyInvoices,
}),
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
/** Wave 1: browse and price display come from the backend, everything else stays fixed-data. */
const DEFAULT_LIVE_DOMAINS: LiveDomain[] = ["catalog", "currency"];
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
// Mock-first MVP: default to the fixed-data adapter; set NUXT_PUBLIC_MOCK_API=false
// (or runtimeConfig.public.mockApi) to talk to the live backend again.
const useMock = (config.public.mockApi as boolean | undefined) !== false;
const api = useMock
? createMockApi()
: createApi({
baseUrl: config.public.apiBase as string,
getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null),
});
const mock = createMockApi();
const live = createApi({
baseUrl: config.public.apiBase as string,
getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null),
});
const configured = config.public.liveDomains as string[] | undefined;
const liveDomains = (configured ?? DEFAULT_LIVE_DOMAINS).filter((domain): domain is LiveDomain =>
KNOWN_DOMAINS.includes(domain as LiveDomain),
);
// The fixed-data adapter is the base object, so an unmigrated domain cannot
// regress and a live domain can be rolled back by removing one entry.
let api: ApiClient = mock;
for (const domain of liveDomains) {
api = { ...api, ...LIVE_PICKS[domain](live) };
}
return { provide: { api } };
});