From 104737e4e1fd127d3c1e36b19137807a7ae7377d Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Thu, 17 Sep 2026 16:48:10 +0000 Subject: [PATCH] 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 --- .../migrations/0006_storefront_content.sql | 81 ++++++ apps/api/src/routes/content.rs | 257 ++++++++++++++++++ apps/api/src/routes/mod.rs | 3 + apps/api/tests/content.rs | 194 +++++++++++++ apps/mall/mock/api.ts | 40 +++ apps/mall/nuxt.config.ts | 2 +- apps/mall/pages/index.vue | 34 ++- apps/mall/plugins/api.ts | 24 +- docs/TBD-migrate-wave.md | 2 +- .../replace-mock-api-wave-4/.openspec.yaml | 2 + .../changes/replace-mock-api-wave-4/design.md | 54 ++++ .../replace-mock-api-wave-4/proposal.md | 31 +++ .../specs/frontend-mall/spec.md | 22 ++ .../specs/storefront-content/spec.md | 33 +++ .../changes/replace-mock-api-wave-4/tasks.md | 32 +++ packages/shared/src/api.ts | 15 + packages/shared/src/types.ts | 55 ++++ 17 files changed, 866 insertions(+), 15 deletions(-) create mode 100644 apps/api/migrations/0006_storefront_content.sql create mode 100644 apps/api/src/routes/content.rs create mode 100644 apps/api/tests/content.rs create mode 100644 openspec/changes/replace-mock-api-wave-4/.openspec.yaml create mode 100644 openspec/changes/replace-mock-api-wave-4/design.md create mode 100644 openspec/changes/replace-mock-api-wave-4/proposal.md create mode 100644 openspec/changes/replace-mock-api-wave-4/specs/frontend-mall/spec.md create mode 100644 openspec/changes/replace-mock-api-wave-4/specs/storefront-content/spec.md create mode 100644 openspec/changes/replace-mock-api-wave-4/tasks.md diff --git a/apps/api/migrations/0006_storefront_content.sql b/apps/api/migrations/0006_storefront_content.sql new file mode 100644 index 0000000..dfa877b --- /dev/null +++ b/apps/api/migrations/0006_storefront_content.sql @@ -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); diff --git a/apps/api/src/routes/content.rs b/apps/api/src/routes/content.rs new file mode 100644 index 0000000..9585c86 --- /dev/null +++ b/apps/api/src/routes/content.rs @@ -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, + pub promos: Vec, + pub quick_links: Vec, + pub floor_adverts: Vec, +} + +pub fn router(_state: AppState) -> Router { + Router::new().route("/content/home", get(home_content)) +} + +pub fn admin_router(_state: AppState) -> Router { + 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 { + 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) -> ApiResult> { + Ok(Json(load_content(&state, true).await?)) +} + +async fn admin_content( + State(state): State, + auth: AuthUser, +) -> ApiResult> { + 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, + auth: AuthUser, + Path(kind): Path, + Json(body): Json, +) -> ApiResult> { + 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 = + 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 = + 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 = + 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?)) +} diff --git a/apps/api/src/routes/mod.rs b/apps/api/src/routes/mod.rs index b0653db..d25a073 100644 --- a/apps/api/src/routes/mod.rs +++ b/apps/api/src/routes/mod.rs @@ -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 { .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())) diff --git a/apps/api/tests/content.rs b/apps/api/tests/content.rs new file mode 100644 index 0000000..3c5db0a --- /dev/null +++ b/apps/api/tests/content.rs @@ -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 = 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 { + 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"); +} diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index df50a7b..6c8f871 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -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 => + 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(), }, }; } diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index f13e5d8..730d837 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -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", }, }, diff --git a/apps/mall/pages/index.vue b/apps/mall/pages/index.vue index 54f70bc..b92f6c6 100644 --- a/apps/mall/pages/index.vue +++ b/apps/mall/pages/index.vue @@ -1,26 +1,40 @@