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
@@ -0,0 +1,81 @@
-- Storefront marketing content for the home page.
--
-- Four kinds live in explicit tables rather than one JSONB payload table, so
-- Postgres enforces each shape instead of the API hand-validating opaque blobs.
-- Seeded from the assets the page already rendered, so moving this content into
-- the database is visually a no-op.
CREATE TABLE banners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image TEXT NOT NULL,
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE promos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image TEXT NOT NULL,
url TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE quick_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label JSONB NOT NULL,
url TEXT NOT NULL,
-- inline SVG path data, 24x24 viewBox
glyph TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- An ordered pool: the home page assigns these to floors in order and wraps,
-- so the destination stays the floor's own category.
CREATE TABLE floor_adverts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image TEXT NOT NULL,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO banners (image, url, position)
VALUES
('/mock/banner-1.svg', '/search?sort=price&order=desc', 0),
('/mock/banner-2.svg', '/seckill', 1),
('/mock/banner-3.svg', '/collective', 2);
INSERT INTO promos (image, url, position)
VALUES
('/mock/promo-1.svg', '/seckill', 0),
('/mock/promo-2.svg', '/collective', 1),
('/mock/promo-3.svg', '/integral', 2);
INSERT INTO quick_links (label, url, glyph, position)
VALUES
('{"en": "Verification", "zh": "实名认证"}', '/user',
'M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6l8-4z', 0),
('{"en": "Points Mall", "zh": "积分商城"}', '/integral',
'M12 2a10 10 0 100 20 10 10 0 000-20zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z', 1),
('{"en": "Group Buy", "zh": "优惠团购"}', '/collective',
'M20 7h-3.2A3 3 0 0012 3.5 3 3 0 007.2 7H4v4h7V9h2v2h7V7zM4 13v8h7v-8H4zm9 8h7v-8h-7v8z', 2),
('{"en": "Flash Sale", "zh": "秒杀活动"}', '/seckill',
'M13 2L4 14h6l-1 8 9-12h-6l1-8z', 3),
('{"en": "Notice", "zh": "商城公告"}', '/user',
'M12 22a2.5 2.5 0 002.5-2.5h-5A2.5 2.5 0 0012 22zm7-6v-5a7 7 0 00-5-6.7V4a2 2 0 00-4 0v.3A7 7 0 005 11v5l-2 2v1h18v-1l-2-2z', 4),
('{"en": "Become a Seller", "zh": "入驻商家"}', '/stores',
'M4 4h16l2 5v2a3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 01-3 3 3 3 0 01-3-3 3 3 0 01-3 3 3 3 0 01-3-3V9l2-5zm0 10.7V20h7v-5h2v5h7v-5.3a4.98 4.98 0 01-2 .4V20H6v-5.7a4.98 4.98 0 01-2 .4z', 5);
INSERT INTO floor_adverts (image, position)
VALUES
('/mock/floor-adv-1.svg', 0),
('/mock/floor-adv-2.svg', 1),
('/mock/floor-adv-3.svg', 2),
('/mock/floor-adv-4.svg', 3),
('/mock/floor-adv-5.svg', 4),
('/mock/floor-adv-6.svg', 5);
+257
View File
@@ -0,0 +1,257 @@
use axum::{
extract::{Path, State},
routing::{get, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::models::UserRole;
use crate::state::AppState;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Banner {
pub id: Uuid,
pub image: String,
pub url: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Promo {
pub id: Uuid,
pub image: String,
pub url: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct QuickLink {
pub id: Uuid,
pub label: Value,
pub url: String,
pub glyph: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct FloorAdvert {
pub id: Uuid,
pub image: String,
pub position: i32,
pub active: bool,
}
/// Every content kind. The public read carries active rows only; the admin read
/// carries everything, so a disabled block stays editable.
#[derive(Debug, Serialize)]
pub struct ContentView {
pub banners: Vec<Banner>,
pub promos: Vec<Promo>,
pub quick_links: Vec<QuickLink>,
pub floor_adverts: Vec<FloorAdvert>,
}
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route("/content/home", get(home_content))
}
pub fn admin_router(_state: AppState) -> Router<AppState> {
Router::new()
.route("/admin/content", get(admin_content))
.route("/admin/content/{kind}", put(replace_content))
}
/// Only the string literal below decides filtering; no input reaches it.
async fn load_content(state: &AppState, active_only: bool) -> ApiResult<ContentView> {
let filter = if active_only { " WHERE active = TRUE" } else { "" };
let banners = sqlx::query_as::<_, Banner>(&format!(
"SELECT * FROM banners{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
let promos = sqlx::query_as::<_, Promo>(&format!(
"SELECT * FROM promos{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
let quick_links = sqlx::query_as::<_, QuickLink>(&format!(
"SELECT * FROM quick_links{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
let floor_adverts = sqlx::query_as::<_, FloorAdvert>(&format!(
"SELECT * FROM floor_adverts{filter} ORDER BY position, created_at"
))
.fetch_all(&state.db)
.await?;
Ok(ContentView {
banners,
promos,
quick_links,
floor_adverts,
})
}
async fn home_content(State(state): State<AppState>) -> ApiResult<Json<ContentView>> {
Ok(Json(load_content(&state, true).await?))
}
async fn admin_content(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<ContentView>> {
auth.require(&[UserRole::PlatformAdmin])?;
Ok(Json(load_content(&state, false).await?))
}
fn non_empty(value: &str, field: &str) -> ApiResult<()> {
if value.trim().is_empty() {
return Err(ApiError::BadRequest(format!("{field} must not be empty")));
}
Ok(())
}
fn localized(label: &Value) -> ApiResult<()> {
let ok = ["en", "zh"].iter().all(|code| {
label
.get(code)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
});
if !ok {
return Err(ApiError::BadRequest(
"quick-link label needs non-empty en and zh".into(),
));
}
Ok(())
}
#[derive(Deserialize)]
struct LinkInput {
image: String,
url: String,
#[serde(default = "default_active")]
active: bool,
}
#[derive(Deserialize)]
struct QuickLinkInput {
label: Value,
url: String,
glyph: String,
#[serde(default = "default_active")]
active: bool,
}
#[derive(Deserialize)]
struct AdvertInput {
image: String,
#[serde(default = "default_active")]
active: bool,
}
fn default_active() -> bool {
true
}
/// Replaces one kind from an ordered list. Validation happens before the
/// transaction, and positions are reindexed from the submitted order, so a
/// rejected list leaves the stored content untouched.
async fn replace_content(
State(state): State<AppState>,
auth: AuthUser,
Path(kind): Path<String>,
Json(body): Json<Value>,
) -> ApiResult<Json<ContentView>> {
auth.require(&[UserRole::PlatformAdmin])?;
if !matches!(
kind.as_str(),
"banners" | "promos" | "quick-links" | "floor-adverts"
) {
return Err(ApiError::BadRequest(format!("unknown content kind: {kind}")));
}
let mut tx = state.db.begin().await?;
match kind.as_str() {
"banners" | "promos" => {
let items: Vec<LinkInput> =
serde_json::from_value(body).map_err(|e| ApiError::BadRequest(e.to_string()))?;
for item in &items {
non_empty(&item.image, "image")?;
non_empty(&item.url, "url")?;
}
let table = if kind == "banners" { "banners" } else { "promos" };
sqlx::query(&format!("DELETE FROM {table}"))
.execute(&mut *tx)
.await?;
for (i, item) in items.iter().enumerate() {
sqlx::query(&format!(
"INSERT INTO {table} (image, url, position, active) VALUES ($1, $2, $3, $4)"
))
.bind(&item.image)
.bind(&item.url)
.bind(i as i32)
.bind(item.active)
.execute(&mut *tx)
.await?;
}
}
"quick-links" => {
let items: Vec<QuickLinkInput> =
serde_json::from_value(body).map_err(|e| ApiError::BadRequest(e.to_string()))?;
for item in &items {
non_empty(&item.url, "url")?;
non_empty(&item.glyph, "glyph")?;
localized(&item.label)?;
}
sqlx::query("DELETE FROM quick_links")
.execute(&mut *tx)
.await?;
for (i, item) in items.iter().enumerate() {
sqlx::query(
"INSERT INTO quick_links (label, url, glyph, position, active)
VALUES ($1, $2, $3, $4, $5)",
)
.bind(&item.label)
.bind(&item.url)
.bind(&item.glyph)
.bind(i as i32)
.bind(item.active)
.execute(&mut *tx)
.await?;
}
}
"floor-adverts" => {
let items: Vec<AdvertInput> =
serde_json::from_value(body).map_err(|e| ApiError::BadRequest(e.to_string()))?;
for item in &items {
non_empty(&item.image, "image")?;
}
sqlx::query("DELETE FROM floor_adverts")
.execute(&mut *tx)
.await?;
for (i, item) in items.iter().enumerate() {
sqlx::query(
"INSERT INTO floor_adverts (image, position, active) VALUES ($1, $2, $3)",
)
.bind(&item.image)
.bind(i as i32)
.bind(item.active)
.execute(&mut *tx)
.await?;
}
}
_ => unreachable!("kind validated above"),
}
tx.commit().await?;
Ok(Json(load_content(&state, false).await?))
}
+3
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod auth;
pub mod cart;
pub mod catalog;
pub mod content;
pub mod currency;
pub mod health;
pub mod order_common;
@@ -21,6 +22,8 @@ pub fn api_router(state: AppState) -> Router<AppState> {
.merge(auth::router(state.clone()))
.merge(currency::router(state.clone()))
.merge(catalog::router(state.clone()))
.merge(content::router(state.clone()))
.merge(content::admin_router(state.clone()))
.merge(cart::router(state.clone()))
.merge(orders::router(state.clone()))
.merge(shop::router(state.clone()))
+194
View File
@@ -0,0 +1,194 @@
mod common;
use common::{client, create_shop, login_admin, make_shop_owner, register_customer, spawn_app};
use serial_test::serial;
/// The suite shares one database and `replace_content` rewrites a kind, so every
/// test here writes the state it asserts instead of relying on the seed, and
/// none of them submits an empty list.
async fn replace(
app: &common::TestApp,
token: &str,
kind: &str,
items: serde_json::Value,
) -> reqwest::Response {
client()
.put(app.url(&format!("/api/admin/content/{kind}")))
.bearer_auth(token)
.json(&items)
.send()
.await
.unwrap()
}
async fn public_content(app: &common::TestApp) -> serde_json::Value {
let res = client()
.get(app.url("/api/content/home"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "public content read must be unauthenticated");
res.json().await.unwrap()
}
#[tokio::test]
#[serial]
async fn home_content_is_public_and_ordered() {
let app = spawn_app().await;
let content = public_content(&app).await;
for kind in ["banners", "promos", "quick_links", "floor_adverts"] {
assert!(
content[kind].is_array(),
"{kind} must always be present, even when empty"
);
}
// No test in this file empties a kind, so these stay populated.
for kind in ["banners", "promos", "quick_links"] {
assert!(
!content[kind].as_array().unwrap().is_empty(),
"{kind} should carry content"
);
}
let positions: Vec<i64> = content["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["position"].as_i64().unwrap())
.collect();
let mut sorted = positions.clone();
sorted.sort_unstable();
assert_eq!(positions, sorted, "content must come back in position order");
}
#[tokio::test]
#[serial]
async fn admin_replace_round_trips_and_reorders() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let first = serde_json::json!([
{"image": "/mock/a.svg", "url": "/seckill"},
{"image": "/mock/b.svg", "url": "/collective"}
]);
let res = replace(&app, &admin, "banners", first).await;
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let images = |v: &serde_json::Value| -> Vec<String> {
v["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["image"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(images(&public_content(&app).await), vec!["/mock/a.svg", "/mock/b.svg"]);
// The submitted order decides the stored order and the positions.
let flipped = serde_json::json!([
{"image": "/mock/b.svg", "url": "/collective"},
{"image": "/mock/a.svg", "url": "/seckill"}
]);
assert_eq!(replace(&app, &admin, "banners", flipped).await.status(), 200);
let content = public_content(&app).await;
assert_eq!(images(&content), vec!["/mock/b.svg", "/mock/a.svg"]);
assert_eq!(content["banners"][0]["position"], 0);
assert_eq!(content["banners"][1]["position"], 1);
}
#[tokio::test]
#[serial]
async fn inactive_rows_are_hidden_from_the_public_read() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let items = serde_json::json!([
{"image": "/mock/on.svg", "url": "/seckill"},
{"image": "/mock/off.svg", "url": "/collective", "active": false}
]);
assert_eq!(replace(&app, &admin, "banners", items).await.status(), 200);
let public = public_content(&app).await;
let visible: Vec<&str> = public["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["image"].as_str().unwrap())
.collect();
assert_eq!(visible, vec!["/mock/on.svg"], "inactive rows must not be public");
// The admin read keeps it, so a disabled block stays editable.
let res = client()
.get(app.url("/api/admin/content"))
.bearer_auth(&admin)
.send()
.await
.unwrap();
let all: serde_json::Value = res.json().await.unwrap();
assert_eq!(all["banners"].as_array().unwrap().len(), 2);
}
#[tokio::test]
#[serial]
async fn invalid_entry_is_rejected_without_touching_stored_content() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let good = serde_json::json!([{"image": "/mock/keep.svg", "url": "/seckill"}]);
assert_eq!(replace(&app, &admin, "banners", good).await.status(), 200);
// Second entry is missing its image.
let bad = serde_json::json!([
{"image": "/mock/ok.svg", "url": "/seckill"},
{"url": "/collective"}
]);
let res = replace(&app, &admin, "banners", bad).await;
assert_eq!(res.status(), 400);
let content = public_content(&app).await;
let images: Vec<&str> = content["banners"]
.as_array()
.unwrap()
.iter()
.map(|b| b["image"].as_str().unwrap())
.collect();
assert_eq!(images, vec!["/mock/keep.svg"], "a rejected list must change nothing");
}
#[tokio::test]
#[serial]
async fn quick_link_labels_must_be_bilingual() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let one_sided = serde_json::json!([
{"label": {"en": "Only English"}, "url": "/user", "glyph": "M12 2l8 4v6z"}
]);
let res = replace(&app, &admin, "quick-links", one_sided).await;
assert_eq!(res.status(), 400, "a label missing zh must be refused");
}
#[tokio::test]
#[serial]
async fn content_writes_require_a_platform_admin() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (customer, _) = register_customer(&app, "content-cust").await;
let shop_id = create_shop(&app, &admin, "shop-content").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let items = serde_json::json!([{"image": "/mock/x.svg", "url": "/seckill"}]);
for token in [&customer, &owner] {
let res = replace(&app, token, "banners", items.clone()).await;
assert_eq!(res.status(), 403, "only platform admins may write content");
}
let res = client()
.put(app.url("/api/admin/content/not-a-kind"))
.bearer_auth(&admin)
.json(&items)
.send()
.await
.unwrap();
assert_eq!(res.status(), 400, "an unknown kind is a client error");
}
+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();