feat(mall): read the store directory from the API

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
This commit is contained in:
2026-09-17 17:13:32 +00:00
parent 51f8bb7c1d
commit d0b6350d2d
19 changed files with 666 additions and 130 deletions
+29 -25
View File
@@ -2,15 +2,8 @@
import { t as pick } from "@vmall/shared";
import { ApiError } from "@vmall/shared";
import type { Category, Product, Sku } from "@vmall/shared";
import {
MOCK_COUPONS,
MOCK_STORES,
commentStats,
commentsFor,
lowestSku,
salesOf,
storeById,
} from "~/mock/data";
import { MOCK_COUPONS, commentStats, commentsFor, salesOf } from "~/mock/data";
import { lowestSku } from "~/utils/product";
import { useCartStore } from "~/stores/cart";
type AttributeGroup = { key: string; values: string[] };
@@ -51,12 +44,16 @@ const { data: pageData } = await useAsyncData(
const product = computed(() => pageData.value?.product ?? null);
const related = computed<Product[]>(() => pageData.value?.related ?? []);
// Shared with the store directory and the order surfaces.
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
const detail = computed(() => {
const current = product.value;
if (!current) return null;
return {
product: current,
store: storeById(current.shop_id) ?? MOCK_STORES[0],
// Null when the shop has no public profile; the card is then not rendered.
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
comments: commentsFor(current.id),
commentStats: commentStats(current.id),
coupons: MOCK_COUPONS,
@@ -183,14 +180,19 @@ const tabs = computed(() => [
{ key: "service", label: t("product.tabsAfterSale") },
]);
/** Only the scores the platform actually set; nothing is defaulted. */
const rateRows = computed(() => {
const rate = store.value?.rate;
return [
{ key: "overall", value: rate?.score ?? 0, label: t("product.overall") },
{ key: "service", value: rate?.service ?? 0, label: t("product.service") },
{ key: "delivery", value: rate?.speed ?? 0, label: t("product.delivery") },
{ key: "agree", value: rate?.agree ?? 0, label: t("product.quality") },
const profile = store.value;
if (!profile) return [];
const rows = [
{ key: "overall", value: profile.score_rating, label: t("product.overall") },
{ key: "service", value: profile.score_service, label: t("product.service") },
{ key: "delivery", value: profile.score_speed, label: t("product.delivery") },
{ key: "agree", value: profile.score_agreement, label: t("product.quality") },
];
return rows.filter(
(row): row is { key: string; value: number; label: string } => typeof row.value === "number",
);
});
const detailImages = computed(() => product.value?.images ?? []);
@@ -283,20 +285,22 @@ const detailImages = computed(() => product.value?.images ?? []);
<section class="below-grid">
<aside class="left-rail">
<div class="store-card mpanel">
<div v-if="detail.store" class="store-card mpanel">
<h2>{{ t("product.store") }}</h2>
<div class="store-heading">
<img :src="detail.store.logo" :alt="pick(detail.store.name, locale)" />
<img v-if="detail.store.logo" :src="detail.store.logo" :alt="pick(detail.store.name, locale)" />
<strong>{{ pick(detail.store.name, locale) }}</strong>
</div>
<dl>
<div><dt>{{ t("product.company") }}</dt><dd>{{ detail.store.company }}</dd></div>
<div><dt>{{ t("product.region") }}</dt><dd>{{ detail.store.region }}</dd></div>
<div v-if="detail.store.company"><dt>{{ t("product.company") }}</dt><dd>{{ detail.store.company }}</dd></div>
<div v-if="detail.store.region"><dt>{{ t("product.region") }}</dt><dd>{{ detail.store.region }}</dd></div>
</dl>
<h3>{{ t("product.rates") }}</h3>
<div v-for="row in rateRows" :key="row.key" class="rate-row">
<span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" />
</div>
<template v-if="rateRows.length">
<h3>{{ t("product.rates") }}</h3>
<div v-for="row in rateRows" :key="row.key" class="rate-row">
<span>{{ row.label }}</span><UiRatingStars :value="row.value" :size="13" />
</div>
</template>
<div class="store-actions">
<NuxtLink class="store-button" :to="`/stores/${detail.store.slug}`">{{ t("product.enterStore") }}</NuxtLink>
<button type="button" class="store-button" disabled>{{ t("product.contact") }}</button>
@@ -336,7 +340,7 @@ const detailImages = computed(() => product.value?.images ?? []);
</div>
<div v-else class="service-content">
<h2>{{ t("product.tabsAfterSale") }}</h2>
<p>{{ pick(detail.store.afterSale, locale) }}</p>
<p v-if="detail.store?.after_sale">{{ pick(detail.store.after_sale, locale) }}</p>
</div>
</template>
</UiTabs>