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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user