Wave 4 of replacing the fixed-data mock adapter, and the first capability the
mall never had a backend for: the home page's banners, promo tiles, quick links
and floor advert art move out of local arrays.
- four explicit tables (`banners`, `promos`, `quick_links`, `floor_adverts`)
rather than one JSONB payload table, so Postgres enforces each shape
- a migration seeds them from the assets the page already rendered, so the flip
is visually a no-op. Destinations are real routes now: the mock's promo links
pointed at dangling `?category=c1` ids and its first banner used `sort=sales`,
which the catalog API rejects
- `GET /api/content/home` is public and returns the four active, ordered lists,
always including a key so a page can render a missing block
- `GET /api/admin/content` and `PUT /api/admin/content/{kind}` let a platform
admin read everything and replace one kind transactionally, with positions
reindexed from the submitted order and a rejected list changing nothing
- the mall's fixed-data adapter learns `getHomeContent`, and a `content` domain
joins the per-domain switch so the rollback path still renders the page
Verified: 23 backend tests green including six new content tests; all three
frontends build; the home page renders the same four blocks as before, an admin
reorder and deactivation change the rendered carousel, and the fixed-data
rollback renders every block with the backend stopped.
OpenSpec change: openspec/changes/replace-mock-api-wave-4
206 lines
5.2 KiB
Vue
206 lines
5.2 KiB
Vue
<script setup lang="ts">
|
||
import type { Category, HomeContent, Product } from "@vmall/shared";
|
||
import { t as pick } from "@vmall/shared";
|
||
|
||
interface HomeFloor {
|
||
categoryId: string;
|
||
name: Record<string, string>;
|
||
advImage: string | null;
|
||
advUrl: string;
|
||
products: Product[];
|
||
}
|
||
|
||
const emptyContent: HomeContent = { banners: [], promos: [], quick_links: [], floor_adverts: [] };
|
||
|
||
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());
|
||
|
||
// Marketing content comes from the content API; an outage leaves these lists
|
||
// empty rather than breaking the page.
|
||
const { data: content } = await useAsyncData("home-content", () => $api.getHomeContent(), {
|
||
default: () => emptyContent,
|
||
});
|
||
|
||
const banners = computed(() =>
|
||
content.value.banners.map((banner) => ({ image: banner.image, url: banner.url })),
|
||
);
|
||
const quickLinks = computed(() => content.value.quick_links);
|
||
const promos = computed(() => content.value.promos);
|
||
|
||
// Floor structure comes from the category tree and floor products from the
|
||
// catalog API; advert art is an ordered pool assigned to floors in turn.
|
||
const { data: floors } = await useAsyncData("home-floors", async () => {
|
||
const roots: Category[] = categories.value ?? (await $api.listCategories());
|
||
const adverts = content.value.floor_adverts;
|
||
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: adverts.length > 0 ? adverts[index % adverts.length].image : null,
|
||
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="banners" :height="450" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="w1200 strip">
|
||
<ul class="quick">
|
||
<li v-for="q in quickLinks" :key="q.id">
|
||
<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 promos" :key="p.id" :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 v-if="floor.advImage" :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>
|