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
+21 -29
View File
@@ -1,15 +1,12 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import { MOCK_STORES } from "~/mock/data";
const { locale, t } = useI18n();
const sortMode = ref<"default" | "distance">("default");
const { $api } = useNuxtApp();
const stores = computed(() => {
const list = [...MOCK_STORES];
if (sortMode.value === "distance") list.sort((a, b) => a.distanceKm - b.distanceKm);
return list;
});
// Shared with the order surfaces, which resolve shop ids to names from it.
const { data: shops } = await useAsyncData("shops", () => $api.listShops(), { default: () => [] });
const stores = computed(() => shops.value);
const breadcrumb = computed(() => [
{ label: t("stores.breadcrumbHome"), to: "/" },
@@ -23,40 +20,24 @@ const breadcrumb = computed(() => [
<section class="w1200 store-directory">
<header class="sort-row">
<h1>{{ t("stores.directory") }}</h1>
<div class="sort-options" role="tablist">
<button
type="button"
:class="{ active: sortMode === 'default' }"
role="tab"
:aria-selected="sortMode === 'default'"
@click="sortMode = 'default'"
>{{ t("stores.sortDefault") }}</button>
<button
type="button"
:class="{ active: sortMode === 'distance' }"
role="tab"
:aria-selected="sortMode === 'distance'"
@click="sortMode = 'distance'"
>{{ t("stores.sortDistance") }}</button>
</div>
</header>
<div v-if="stores.length" class="store-list">
<article v-for="store in stores" :key="store.id" class="store-row hover-lift">
<NuxtLink :to="`/stores/${store.slug}`" class="store-logo-link">
<img class="store-logo" :src="store.logo" :alt="pick(store.name, locale)" loading="lazy" />
<img v-if="store.logo" class="store-logo" :src="store.logo" :alt="pick(store.name, locale)" loading="lazy" />
<span v-else class="store-logo store-logo-placeholder" aria-hidden="true">{{ pick(store.name, locale).slice(0, 1) }}</span>
</NuxtLink>
<div class="store-main">
<NuxtLink :to="`/stores/${store.slug}`" class="store-name">{{ pick(store.name, locale) }}</NuxtLink>
<p class="store-company">{{ store.company }}</p>
<p class="store-location">
<span>{{ t("stores.region") }}{{ store.region }}</span>
<span>{{ t("stores.address") }}{{ pick(store.address, locale) }}</span>
<p v-if="store.company" class="store-company">{{ store.company }}</p>
<p v-if="store.region || store.address" class="store-location">
<span v-if="store.region">{{ t("stores.region") }}{{ store.region }}</span>
<span v-if="store.address">{{ t("stores.address") }}{{ pick(store.address, locale) }}</span>
</p>
</div>
<div class="store-rating">{{ t("stores.positiveRate") }}</div>
<div class="store-actions">
<span class="distance">{{ t("stores.distance", { n: store.distanceKm.toFixed(1) }) }}</span>
<NuxtLink :to="`/stores/${store.slug}`" class="mbtn red">{{ t("stores.visitStore") }}</NuxtLink>
</div>
</article>
@@ -138,6 +119,17 @@ h1 {
height: 72px;
object-fit: contain;
}
/* A shop with no profile still needs a mark, without inventing a logo. */
.store-logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--mall-line);
color: var(--mall-muted);
font-size: 28px;
font-weight: 700;
text-transform: uppercase;
}
.store-main {
min-width: 0;
flex: 1;