Files
vmall/apps/mall/pages/stores/index.vue
T
james d0b6350d2d 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
2026-09-17 17:13:32 +00:00

206 lines
4.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
// 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: "/" },
{ label: t("stores.directory") },
]);
</script>
<template>
<div class="stores-page">
<UiBreadcrumb :items="breadcrumb" />
<section class="w1200 store-directory">
<header class="sort-row">
<h1>{{ t("stores.directory") }}</h1>
</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 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 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">
<NuxtLink :to="`/stores/${store.slug}`" class="mbtn red">{{ t("stores.visitStore") }}</NuxtLink>
</div>
</article>
</div>
<UiEmptyState v-else :text="t('common.empty')" />
</section>
</div>
</template>
<style scoped>
.stores-page {
padding-bottom: 60px;
}
.store-directory {
background: #fff;
}
.sort-row {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--mall-line);
background: #fafafa;
padding: 13px 18px;
}
h1 {
margin: 0;
border-left: 3px solid var(--mall-red);
padding-left: 10px;
font-size: 16px;
font-weight: 500;
}
.sort-options {
display: flex;
}
.sort-options button {
border: 0;
border-right: 1px solid var(--mall-line-dark);
background: transparent;
color: var(--mall-muted);
padding: 2px 15px;
font-size: 13px;
cursor: pointer;
}
.sort-options button:last-child {
border-right: 0;
padding-right: 0;
}
.sort-options button.active {
color: var(--mall-red);
font-weight: 700;
}
.store-list {
border: 1px solid var(--mall-line);
border-top: 0;
}
.store-row {
display: flex;
align-items: center;
min-height: 126px;
padding: 20px 22px;
border-bottom: 1px solid var(--mall-line);
background: #fff;
}
.store-row:last-child {
border-bottom: 0;
}
.store-logo-link {
display: flex;
width: 80px;
height: 80px;
align-items: center;
justify-content: center;
flex: 0 0 80px;
border: 1px solid var(--mall-line);
background: #fff;
}
.store-logo {
width: 72px;
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;
padding: 0 28px;
}
.store-name {
display: block;
color: var(--mall-ink);
font-size: 16px;
font-weight: 700;
text-decoration: none;
}
.store-name:hover {
color: var(--mall-red);
}
.store-company,
.store-location {
margin: 8px 0 0;
color: var(--mall-muted);
font-size: 13px;
}
.store-location {
display: flex;
gap: 24px;
color: var(--mall-faint);
font-size: 12px;
}
.store-rating {
width: 130px;
color: var(--mall-red);
font-size: 13px;
text-align: center;
}
.store-actions {
display: flex;
width: 145px;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.distance {
color: var(--mall-muted);
font-size: 12px;
}
@media (max-width: 1240px) {
.w1200 {
width: calc(100% - 32px);
}
}
@media (max-width: 720px) {
.store-row {
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.store-main {
width: calc(100% - 100px);
padding: 0;
}
.store-location {
flex-direction: column;
gap: 4px;
}
.store-rating {
width: auto;
margin-left: 100px;
}
.store-actions {
width: auto;
margin-left: auto;
}
}
</style>