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:
@@ -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);
|
||||
@@ -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?))
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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