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:
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user