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();
+1 -1
View File
@@ -48,7 +48,7 @@ leaves the mock half reading state the live half never populates:
Each is a new backend capability rather than a domain flip.
- [ ] **Storefront content** — banners, promos, quick links and floor advert art. Needs real tables, admin CRUD and i18n JSONB. Do this first if you want the home page fully live; it is the most visible remaining mock surface.
- [x] **Storefront content** — banners, promos, quick links and floor advert art. Done in `replace-mock-api-wave-4`: four tables seeded from the existing assets, a public `GET /api/content/home`, and an admin read/replace pair.
- [ ] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Small: products already carry `shop_id`, and the public catalog already joins shops for the active check.
- [ ] **Brand model + sales/comments sorts** — restores the brand facet and the sorts removed in Wave 1. Needs a `brands` table (`products.brand_id` + i18n) plus sales and comments data, neither of which exists today.
- [ ] Extend the `ORDER BY` whitelist if more sorts are wanted beyond the `sort=price` added in Wave 1.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-17
@@ -0,0 +1,54 @@
# Design
## Context
See `proposal.md` — Why. The home page renders four local arrays from `~/mock/data` (`pages/index.vue`): `MOCK_BANNERS` and `MOCK_PROMOS` are `{ image, url }`, `MOCK_QUICK_LINKS` adds a bilingual `label` and an inline SVG `glyph`, and each floor's advert art is picked by floor index from `/mock/floor-adv-1..6.svg`. Those assets already exist under `apps/mall/public/mock/`.
The backend has no content concept: the only shared tables are identity, catalog and orders (`apps/api/migrations/0001..0005`). The admin console already manages shops, users and currencies through `/api/admin/*` with `platform_admin` gating (`apps/api/src/routes/admin.rs`).
## Goals / Non-Goals
**Goals:**
- The home page renders every element from the backend, with no local content arrays left in `pages/index.vue`.
- Content is editable by a platform admin through the API, not only by SQL.
- The flip is visually invisible: the seeded content reproduces today's page exactly.
**Non-Goals:**
- No image upload or CDN; rows hold URLs and the seed points at the existing static assets.
- No scheduling, targeting or A/B testing.
- No content for the seckill, collective or integral pages, nor for stores, brands, coupons, favourites or addresses.
## Decisions
**1. Four typed tables, not one JSONB content table.**
`banners`, `promos`, `quick_links` and `floor_adverts` each get explicit columns, so Postgres enforces the shape and `sqlx::query_as` maps rows without hand-written validation.
*Alternative:* a single `content_blocks(kind, payload JSONB)` table is fewer lines, but every read then needs a runtime payload check, which is precisely the `any`-shaped weakness the repo's TS and Rust rules avoid.
**2. Replace a whole kind at a time instead of per-entry CRUD.**
`PUT /api/admin/content/{kind}` takes the ordered list, validates it, and rewrites that kind in one transaction, reindexing positions from array order.
*Alternatives:* twelve REST routes for four small lists is a lot of surface, and per-entry CRUD still needs a separate reorder endpoint to express order. Bulk replace makes reordering, insertion and deletion one operation, which is how the content is actually edited.
*Consequence to state plainly:* submitting an empty list clears that kind. That is intended (it is how a block is removed), and the endpoint returns the stored list so a caller can confirm what it now holds.
**3. Floor adverts are an ordered pool, not rows keyed to categories.**
The page assigns them to floors in order and wraps, matching today's `index % 6` behaviour.
*Alternative:* `floor_adverts.category_id` would let an editor target one specific floor. That is a nicer model, but it changes what the page does today and would leave floors without adverts whenever the tree grows; the pool keeps the current behaviour and the schema smaller.
**4. The quick-link glyph is stored with the row.**
It is a short SVG path string. Keeping it in content means a new quick link renders without a frontend deploy, which is the point of moving this into the database at all.
**5. A migration seeds content from the assets already in use.**
`banner-1..3`, `promo-1..3` and `floor-adv-1..6` become rows, so the page is byte-identical after the flip and the change can be verified as a visual no-op.
## Risks / Trade-offs
- [An empty submission silently clears a content kind] → intended (decision 2); the naive admin UI should confirm, and the response echoes the stored list.
- [Content rows point at frontend asset paths] → temporary coupling, called out in the non-goals; a real upload capability replaces the URLs later without touching the schema.
- [Four extra queries on every home render] → negligible at this size; if it ever matters, the four reads collapse into one query.
- [The home page loses its content if the content API is down] → the page renders the remaining blocks rather than failing (a spec scenario), and the fixed-data rollback still works because the mock client keeps serving the same four lists.
## Migration Plan
1. Ship the migration and the public read; nothing is reading it yet.
2. Flip `pages/index.vue` to the content API and drop the three mock arrays in the same commit as the admin surface, so content is never live-but-uneditable.
3. Verify the page is visually unchanged, then verify rollback with the fixed-data adapter.
4. Rollback: revert `pages/index.vue`. The content tables are additive and harmless if left in place.
@@ -0,0 +1,31 @@
# Proposal
## Why
Every domain that had an API is now live, but the home page still renders from `~/mock/data`: the carousel, promotion tiles, quick-link strip and floor advert art are local arrays. They are the last thing between the storefront and backend-driven content, and nobody can change them without a deploy.
## What Changes
- Add four content kinds behind one public read: `GET /api/content/home` returns banners, promos, quick links and floor advert art, ordered and active-only.
- Store them in typed tables rather than one JSONB blob, so the database enforces each shape.
- Seed them from the assets the page already uses (`banner-1..3`, `promo-1..3`, `floor-adv-1..6`), so the home page looks unchanged while becoming data-driven.
- Manage them with a small admin surface: `GET /api/admin/content` plus `PUT /api/admin/content/{kind}`, replacing one ordered list transactionally. Ordering falls out of array order, and four lists do not need twelve endpoints.
- Point `pages/index.vue` at the content API and delete the three mock arrays. The quick-link glyph stays in the row: it is a short SVG path, so a new link renders without a frontend deploy.
## Capabilities
### New Capabilities
- `storefront-content`: the storefront's editable marketing content — home banners, promotion tiles, quick links and floor advert art — read publicly and written by platform admins.
### Modified Capabilities
- `frontend-mall`: the home page sources its banner, promotion, quick-link and floor advert content from the content API instead of local constants.
## Impact
A new migration and `apps/api/src/routes/` module; `packages/shared/src/{api,types}.ts`; `apps/mall/pages/index.vue`. The shared contract change means all three frontends rebuild.
## Non-goals
No image upload: rows reference URLs, seeded with the existing `/mock/*.svg` assets. No content for the seckill, collective or integral pages, and none for stores, brands, coupons, favourites or addresses — each stays mock until its own wave. No scheduling, A/B testing or translated artwork.
@@ -0,0 +1,22 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Mock PC home page
The mall home page SHALL render a hero row composed of a pinned 240px category sidebar on the left and a hero carousel filling the remainder of the 1200px grid, both 450px tall and occupying layout space (not overlaid). The sidebar SHALL list the catalog's top-level categories with up to three child links each; hovering a top-level category SHALL expand the mega-menu panel to the right over the carousel. Banner images SHALL render at fixed 450px height, center-cropped horizontally to the narrower carousel width. Below the hero, the page SHALL render a six-item quick-link strip with promotion tiles and bilingual product floors, where each floor's products come from the catalog API and the banners, promotions, quick links and floor advert art come from the content API.
#### Scenario: shopper lands on home
- **WHEN** `/` loads
- **THEN** the category sidebar is visible to the left of the carousel without any hover or click, the carousel renders center-cropped banners at 450px height, and the quick links, promotions and every non-empty product floor render, with floor products from the catalog API and the marketing content from the content API
#### Scenario: sidebar stays while scrolling
- **WHEN** a shopper scrolls the home page beyond 200px
- **THEN** the category sidebar remains rendered in the hero row and does not auto-hide
#### Scenario: expand a category
- **WHEN** a shopper hovers a top-level category in the pinned sidebar
- **THEN** the mega-menu panel expands to the right, overlaying the carousel with that category's child and grandchild links from the catalog API
#### Scenario: home renders with no content published
- **WHEN** the content API returns empty lists
- **THEN** the page still renders its category sidebar and product floors instead of failing
@@ -0,0 +1,33 @@
# Spec Delta
## Purpose
The storefront's editable marketing content: the home page's banners, promotion tiles, quick links and floor advert art, written by platform admins and read publicly.
## ADDED Requirements
### Requirement: Public home content
`GET /api/content/home` SHALL be readable without authentication and SHALL return the active home content as four ordered lists: `banners`, `promos`, `quick_links` and `floor_adverts`. Each entry SHALL carry what the storefront renders: an image URL and a destination URL for banners and promos, a bilingual label and an inline SVG glyph for quick links, and an image URL for floor adverts, whose destination is the floor's own category. Entries flagged inactive SHALL never appear, and each list SHALL be ordered by its stored position.
#### Scenario: only active content is served
- **WHEN** a shopper loads the home content while one banner is marked inactive
- **THEN** that banner is absent from `banners`, and the remaining entries keep their stored order
#### Scenario: an empty kind still answers
- **WHEN** a kind has no active entries
- **THEN** the response returns an empty list for it rather than omitting the key or failing, so the page renders without that block
### Requirement: Content management
Platform admins SHALL read all home content with `GET /api/admin/content` and replace one kind at a time with `PUT /api/admin/content/{kind}`, where `kind` is `banners`, `promos`, `quick-links` or `floor-adverts`. A replacement SHALL validate every entry, apply as a single transaction, and reindex positions from the submitted order. A rejected entry SHALL leave the stored content exactly as it was. Writing SHALL require the `platform_admin` role.
#### Scenario: replace reorders and reindexes
- **WHEN** an admin submits the same three banners in a different order
- **THEN** a subsequent public read returns them in the new order
#### Scenario: an invalid entry is rejected atomically
- **WHEN** an admin submits a list whose second entry is missing its image URL
- **THEN** the request fails and the previously stored list is unchanged
#### Scenario: non-admins cannot write
- **WHEN** a signed-in customer or shop owner sends a replacement
- **THEN** the API refuses the write
@@ -0,0 +1,32 @@
# Tasks
## 1. Schema and seed
- [x] 1.1 Add a migration creating `banners`, `promos`, `quick_links` and `floor_adverts` with explicit columns (`image`, `url`, `position`, `active`, plus `label`/`glyph` on quick links); verified the tables exist after `cargo run -p vmall-api`
- [x] 1.2 Seed the same migration from the assets the page uses today, matching `pages/index.vue`'s arrays; verified `GET /api/content/home` returns 3 banners, 3 promos, 6 quick links and 6 floor adverts. The seeded destinations are valid routes (`/seckill`, `/collective`, `/integral`, `/search?sort=price&order=desc`) rather than the mock's dangling `?category=c1` ids and its `sort=sales`, which the catalog API rejects
## 2. Shared contract
- [x] 2.1 Add `HomeBanner`, `HomePromo`, `HomeQuickLink`, `FloorAdvert`, `HomeContent`, `ContentKind` and `ContentInputByKind` to `packages/shared/src/types.ts`, with the quick-link label as `LocalizedText`; verified all three frontends build
- [x] 2.2 Add `getHomeContent()` and `admin.getContent()` / `admin.replaceContent(kind, list)` to the `ApiClient` interface and `createApi`, and give the fixed-data client matching implementations so the rollback path keeps serving the current arrays. Also registered a `content` domain in the per-domain switch (`plugins/api.ts` and `nuxt.config.ts`) — missed on the first pass and caught by the browser check, where the promos still rendered the mock hrefs because the new method had no live pick
## 3. Public content read
- [x] 3.1 Add `GET /api/content/home` returning the four active, position-ordered lists, with no authentication; verified it answers with the seeded counts and omits a row once it is marked inactive
- [x] 3.2 Return an empty list for any kind with no active entries rather than omitting the key; asserted by `home_content_is_public_and_ordered`
## 4. Admin content management
- [x] 4.1 Add `GET /api/admin/content` returning every kind including inactive rows, gated to `platform_admin`; verified a customer and a shop owner are refused with 403
- [x] 4.2 Add `PUT /api/admin/content/{kind}` replacing one kind transactionally, validating each entry and reindexing positions from the submitted order; verified a reordered submission reads back in the new order, and that the rendered page follows
- [x] 4.3 Verify an invalid entry (missing `image`, or a quick-link label without `zh`) fails without changing the stored list
## 5. Mall home page
- [x] 5.1 Point `pages/index.vue` at `getHomeContent()`, delete the three mock arrays from the page, and render the floor advert pool by floor order; verified the page still renders its sidebar and floors, and that a floor renders no advert rather than a broken image when the pool is empty
## 6. Verification
- [x] 6.1 Run the mall, shop-admin and admin builds, since the shared contract changed; all three pass, and `cargo test -p vmall-api` is green at 23 tests
- [x] 6.2 Compared the home page against the pre-change render: 3 banners, 6 quick links, 3 promo tiles and floor adverts 1-6 across 6 floors, visually unchanged. Reordering banners and marking one inactive through the admin API changed the rendered carousel accordingly, then the seed was restored
- [x] 6.3 Verified the rollback: with every domain on fixed data and the backend stopped, the home page still renders all four blocks with no console errors
+15
View File
@@ -3,7 +3,10 @@ import type {
AuthTokens,
Cart,
Category,
ContentInputByKind,
ContentKind,
Currency,
HomeContent,
Invoice,
InvoiceKind,
LocalizedText,
@@ -160,6 +163,8 @@ export interface ApiClient {
kind: InvoiceKind,
): Promise<Invoice>;
listMyInvoices(): Promise<Invoice[]>;
/** Public home-page marketing content: banners, promos, quick links, floor adverts. */
getHomeContent(): Promise<HomeContent>;
shop: {
getMyShop(): Promise<Shop>;
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
@@ -192,6 +197,13 @@ export interface ApiClient {
listCurrencies(): Promise<Currency[]>;
upsertCurrency(body: CurrencyUpsertBody): Promise<Currency>;
setRate(code: string, rateToBase: string): Promise<Currency>;
/** Every content kind, inactive rows included. */
getContent(): Promise<HomeContent>;
/** Replaces one kind from an ordered list; positions follow the array order. */
replaceContent<K extends ContentKind>(
kind: K,
items: ContentInputByKind[K],
): Promise<HomeContent>;
};
}
@@ -224,6 +236,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
requestInvoice: (orderId, title, taxNo, kind) =>
r("POST", `/orders/${orderId}/invoice`, { title, tax_no: taxNo, kind }),
listMyInvoices: () => r("GET", "/invoices"),
getHomeContent: () => r("GET", "/content/home"),
shop: {
getMyShop: () => r("GET", "/shop/profile"),
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
@@ -259,6 +272,8 @@ export function createApi(opts: ApiClientOptions): ApiClient {
setRate: (code, rateToBase) => r("PUT", `/admin/currencies/${code}/rate`, {
rate_to_base: rateToBase,
}),
getContent: () => r("GET", "/admin/content"),
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
},
};
}
+55
View File
@@ -188,3 +188,58 @@ export interface Paged<T> {
export interface ApiErrorBody {
error: { code: string; message: string };
}
// ---- storefront content ----
/** The home page's editable marketing content. See the storefront-content spec. */
export interface HomeBanner {
id: string;
image: string;
url: string;
position: number;
active: boolean;
}
export interface HomePromo {
id: string;
image: string;
url: string;
position: number;
active: boolean;
}
export interface HomeQuickLink {
id: string;
label: LocalizedText;
url: string;
/** Inline SVG path data, 24x24 viewBox. */
glyph: string;
position: number;
active: boolean;
}
/** Ordered pool; the home page assigns these to floors in order and wraps. */
export interface FloorAdvert {
id: string;
image: string;
position: number;
active: boolean;
}
export interface HomeContent {
banners: HomeBanner[];
promos: HomePromo[];
quick_links: HomeQuickLink[];
floor_adverts: FloorAdvert[];
}
export type ContentKind = "banners" | "promos" | "quick-links" | "floor-adverts";
/** Request payload per kind for `replaceContent`; ids and positions are assigned server-side. */
export interface ContentInputByKind {
banners: { image: string; url: string; active?: boolean }[];
promos: { image: string; url: string; active?: boolean }[];
"quick-links": { label: LocalizedText; url: string; glyph: string; active?: boolean }[];
"floor-adverts": { image: string; active?: boolean }[];
}