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