Files
vmall/apps/mall/pages/index.vue
T
james e0e833d0e5 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
2026-09-17 15:15:25 +00:00

192 lines
4.6 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 type { Category, Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { MOCK_BANNERS, MOCK_PROMOS, MOCK_QUICK_LINKS } from "~/mock/data";
interface HomeFloor {
categoryId: string;
name: Record<string, string>;
advImage: string;
advUrl: string;
products: Product[];
}
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
// Shared with the shell's category menu, so this does not refetch the tree.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
// Floor structure comes from the category tree; floor products come from the
// catalog API. Banner, promotion, quick-link and advert art stay local content.
const { data: floors } = await useAsyncData("home-floors", async () => {
const roots: Category[] = categories.value ?? (await $api.listCategories());
const tops = roots
.filter((category) => category.parent_id === null)
.sort((a, b) => a.position - b.position);
const built = await Promise.all(
tops.map(async (category, index) => {
const page = await $api.listProducts({ category_id: category.id, per_page: 8 });
const floor: HomeFloor = {
categoryId: category.id,
name: category.name,
advImage: `/mock/floor-adv-${(index % 6) + 1}.svg`,
advUrl: `/search?category=${category.id}`,
products: page.items,
};
return floor;
}),
);
return built.filter((floor) => floor.products.length > 0);
});
const visibleFloors = computed(() => floors.value ?? []);
</script>
<template>
<div>
<div class="w1200 hero">
<ShellCategoryMenu pinned />
<div class="hero-slider">
<UiCarousel :images="MOCK_BANNERS" :height="450" />
</div>
</div>
<div class="w1200 strip">
<ul class="quick">
<li v-for="q in MOCK_QUICK_LINKS" :key="q.url + pick(q.label, 'en')">
<NuxtLink :to="q.url">
<svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor"><path :d="q.glyph" /></svg>
<span>{{ pick(q.label, locale) }}</span>
</NuxtLink>
</li>
</ul>
<div class="promos">
<NuxtLink v-for="p in MOCK_PROMOS" :key="p.image" :to="p.url">
<img :src="p.image" :alt="t('home.hotPromo')" loading="lazy" />
</NuxtLink>
</div>
</div>
<div class="section-bg">
<section v-for="floor in visibleFloors" :key="floor.categoryId" class="w1200 floor">
<header class="floor-head">
<h2>{{ pick(floor.name, locale) }}</h2>
<NuxtLink :to="`/search?category=${floor.categoryId}`" class="more">{{ t("home.viewMore") }} </NuxtLink>
</header>
<div class="floor-body">
<NuxtLink :to="floor.advUrl" class="floor-adv">
<img :src="floor.advImage" :alt="pick(floor.name, locale)" loading="lazy" />
</NuxtLink>
<div class="floor-grid">
<UiProductCard v-for="p in floor.products" :key="p.id" :product="p" />
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.hero {
display: flex;
height: 450px;
margin-top: 0;
background: #fff;
}
.hero-slider {
flex: 1;
min-width: 0;
}
.strip {
display: flex;
gap: 20px;
margin-top: 20px;
margin-bottom: 20px;
}
.quick {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--mall-line);
border: 1px solid var(--mall-line);
list-style: none;
margin: 0;
padding: 0;
width: 240px;
}
.quick li {
background: #fff;
text-align: center;
}
.quick a {
display: block;
padding: 14px 0;
color: var(--mall-muted);
text-decoration: none;
font-size: 12px;
}
.quick a:hover {
color: var(--mall-red);
}
.quick svg {
display: block;
margin: 0 auto 6px;
}
.promos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.promos img {
width: 100%;
height: 160px;
object-fit: cover;
display: block;
}
.floor {
margin-bottom: 30px;
}
.floor-head {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 2px solid var(--mall-red);
margin-bottom: 0;
}
.floor-head h2 {
font-size: 18px;
margin: 0;
padding: 10px 0;
}
.more {
font-size: 12px;
color: var(--mall-muted);
text-decoration: none;
}
.more:hover {
color: var(--mall-red);
}
.floor-body {
display: flex;
gap: 0;
background: #fff;
}
.floor-adv img {
width: 234px;
height: 614px;
display: block;
object-fit: cover;
}
.floor-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--mall-line);
}
.floor-grid :deep(.card) {
border: 0;
}
</style>