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
This commit is contained in:
2026-09-17 16:48:10 +00:00
parent 2136a48fbe
commit 104737e4e1
17 changed files with 866 additions and 15 deletions
+40
View File
@@ -9,6 +9,7 @@ import type {
AuthTokens,
Cart,
CartItem,
HomeContent,
Invoice,
InvoiceKind,
Order,
@@ -18,8 +19,11 @@ import type {
} from "@vmall/shared";
import {
BASE_CURRENCY,
MOCK_BANNERS,
MOCK_CATEGORIES,
MOCK_CURRENCIES,
MOCK_PROMOS,
MOCK_QUICK_LINKS,
MOCK_STORES,
MOCK_USER,
defaultAddress,
@@ -314,6 +318,40 @@ export function createMockApi(): ApiClient {
listMyInvoices: () => Promise.resolve(state.invoices.map((i) => ({ ...i }))),
// Mirror of the seeded storefront-content rows, so the home page renders
// identically when every domain is configured to fixed data.
getHomeContent: (): Promise<HomeContent> =>
Promise.resolve({
banners: MOCK_BANNERS.map((b, i) => ({
id: `bn${i + 1}`,
image: b.image,
url: b.url,
position: i,
active: true,
})),
promos: MOCK_PROMOS.map((p, i) => ({
id: `pr${i + 1}`,
image: p.image,
url: p.url,
position: i,
active: true,
})),
quick_links: MOCK_QUICK_LINKS.map((q, i) => ({
id: `ql${i + 1}`,
label: q.label,
url: q.url,
glyph: q.glyph,
position: i,
active: true,
})),
floor_adverts: Array.from({ length: 6 }, (_, i) => ({
id: `fa${i + 1}`,
image: `/mock/floor-adv-${i + 1}.svg`,
position: i,
active: true,
})),
}),
shop: {
getMyShop: () => unsupported(),
listMyProducts: () => unsupported(),
@@ -341,6 +379,8 @@ export function createMockApi(): ApiClient {
listCurrencies: () => unsupported(),
upsertCurrency: () => unsupported(),
setRate: () => unsupported(),
getContent: () => unsupported(),
replaceContent: () => unsupported(),
},
};
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineNuxtConfig({
// Domains served by the live backend; every other domain stays on the
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
liveDomains: ["catalog", "currency", "auth", "cart", "orders", "shipments", "invoices"],
liveDomains: ["catalog", "currency", "content", "auth", "cart", "orders", "shipments", "invoices"],
appName: "mall",
},
},
+24 -10
View File
@@ -1,26 +1,40 @@
<script setup lang="ts">
import type { Category, Product } from "@vmall/shared";
import type { Category, HomeContent, 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;
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());
// Floor structure comes from the category tree; floor products come from the
// catalog API. Banner, promotion, quick-link and advert art stay local content.
// 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);
@@ -30,7 +44,7 @@ const { data: floors } = await useAsyncData("home-floors", async () => {
const floor: HomeFloor = {
categoryId: category.id,
name: category.name,
advImage: `/mock/floor-adv-${(index % 6) + 1}.svg`,
advImage: adverts.length > 0 ? adverts[index % adverts.length].image : null,
advUrl: `/search?category=${category.id}`,
products: page.items,
};
@@ -48,13 +62,13 @@ const visibleFloors = computed(() => floors.value ?? []);
<div class="w1200 hero">
<ShellCategoryMenu pinned />
<div class="hero-slider">
<UiCarousel :images="MOCK_BANNERS" :height="450" />
<UiCarousel :images="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')">
<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>
@@ -62,7 +76,7 @@ const visibleFloors = computed(() => floors.value ?? []);
</li>
</ul>
<div class="promos">
<NuxtLink v-for="p in MOCK_PROMOS" :key="p.image" :to="p.url">
<NuxtLink v-for="p in promos" :key="p.id" :to="p.url">
<img :src="p.image" :alt="t('home.hotPromo')" loading="lazy" />
</NuxtLink>
</div>
@@ -75,7 +89,7 @@ const visibleFloors = computed(() => floors.value ?? []);
<NuxtLink :to="`/search?category=${floor.categoryId}`" class="more">{{ t("home.viewMore") }} </NuxtLink>
</header>
<div class="floor-body">
<NuxtLink :to="floor.advUrl" class="floor-adv">
<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">
+21 -3
View File
@@ -7,7 +7,15 @@ import { createMockApi } from "~/mock/api";
* adapter, so a domain can be migrated - or rolled back - by editing this list
* alone. See openspec/changes/replace-mock-api-wave-1/design.md.
*/
type LiveDomain = "auth" | "catalog" | "currency" | "cart" | "orders" | "shipments" | "invoices";
type LiveDomain =
| "auth"
| "catalog"
| "currency"
| "content"
| "cart"
| "orders"
| "shipments"
| "invoices";
/**
* Explicit per-domain method picks rather than a string allowlist: indexing
@@ -22,6 +30,7 @@ const LIVE_PICKS = {
listCategories: a.listCategories,
}),
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
content: (a: ApiClient) => ({ getHomeContent: a.getHomeContent }),
cart: (a: ApiClient) => ({
getCart: a.getCart,
addCartItem: a.addCartItem,
@@ -47,8 +56,17 @@ const LIVE_PICKS = {
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
/** Wave 1: browse and price display come from the backend, everything else stays fixed-data. */
const DEFAULT_LIVE_DOMAINS: LiveDomain[] = ["catalog", "currency"];
/** Fallback when runtimeConfig supplies no list; keep in step with nuxt.config.ts. */
const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
"catalog",
"currency",
"content",
"auth",
"cart",
"orders",
"shipments",
"invoices",
];
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();