Files
james 104737e4e1 feat(mall): serve home marketing content from the API
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
2026-09-17 16:48:10 +00:00

206 lines
5.2 KiB
Vue
Raw Permalink 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, 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>