feat(mall): serve catalog and currency from the live API

Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API
adapter per domain, with catalog and currency served live while auth, cart,
orders, shipments and invoices stay on fixed data.

Backend:
- seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API
  exposes no category write route, so this cannot come from the seed script
- filter public product listing by the category subtree with a recursive CTE,
  matching the mock's existing behaviour instead of exact-match
- add sort=price with order=asc|desc, validated by hand so an unsupported value
  returns the project's ApiError 400 shape rather than axum's own rejection

Mall:
- replace the all-or-nothing mockApi boolean with a liveDomains list composed
  through a typed per-domain pick map
- source home floors, the category menu, search and product detail from the
  catalog API; banners, promos, quick links, store card and comment/coupon
  content stay local display-only content
- drop the brand facet and the sales/comments sorts: no backend model backs them
- fix salesOf/commentCountOf, which parsed digits out of the product id and so
  rendered "NaN sold" for live UUID ids; they now hash the id

Seed: 24 products across 4 shops, idempotent on re-run.

Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API
to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work.

OpenSpec change: openspec/changes/replace-mock-api-wave-1
This commit is contained in:
2026-09-17 15:15:25 +00:00
parent 44466e5e88
commit e0e833d0e5
20 changed files with 909 additions and 223 deletions
@@ -0,0 +1,95 @@
-- Reference taxonomy: extend the three top-level categories seeded in
-- 0003_catalog.sql into the full 6 x 2 x 2 tree the storefront is designed
-- around (six pinned sidebar rows, six home floors, two grandchildren per
-- child link).
--
-- Categories are reference data, not demo data: `GET /categories` is the only
-- category route (there is no write endpoint), so the seed script cannot create
-- them, and shop-admin needs a real tree for its category picker.
-- New top-level categories (positions 4-6 continue 0003's 1-3).
INSERT INTO categories (parent_id, name, slug, position)
VALUES
(NULL, '{"en": "Computers & Office", "zh": "电脑办公"}', 'computers-office', 4),
(NULL, '{"en": "Beauty & Care", "zh": "美妆个护"}', 'beauty-care', 5),
(NULL, '{"en": "Grocery & Fresh", "zh": "食品生鲜"}', 'grocery-fresh', 6);
-- Second level: two children under every top-level category.
INSERT INTO categories (parent_id, name, slug, position)
VALUES
((SELECT id FROM categories WHERE slug = 'electronics'),
'{"en": "Phones & Accessories", "zh": "手机与配件"}', 'electronics-phones', 1),
((SELECT id FROM categories WHERE slug = 'electronics'),
'{"en": "Audio", "zh": "影音娱乐"}', 'electronics-audio', 2),
((SELECT id FROM categories WHERE slug = 'fashion'),
'{"en": "Menswear", "zh": "男装"}', 'fashion-menswear', 1),
((SELECT id FROM categories WHERE slug = 'fashion'),
'{"en": "Bags & Luggage", "zh": "箱包"}', 'fashion-bags', 2),
((SELECT id FROM categories WHERE slug = 'home-living'),
'{"en": "Kitchen", "zh": "厨房电器"}', 'home-kitchen', 1),
((SELECT id FROM categories WHERE slug = 'home-living'),
'{"en": "Cleaning", "zh": "清洁电器"}', 'home-cleaning', 2),
((SELECT id FROM categories WHERE slug = 'computers-office'),
'{"en": "Laptops", "zh": "笔记本电脑"}', 'computers-laptops', 1),
((SELECT id FROM categories WHERE slug = 'computers-office'),
'{"en": "Peripherals", "zh": "外设产品"}', 'computers-peripherals', 2),
((SELECT id FROM categories WHERE slug = 'beauty-care'),
'{"en": "Skincare", "zh": "面部护肤"}', 'beauty-skincare', 1),
((SELECT id FROM categories WHERE slug = 'beauty-care'),
'{"en": "Grooming", "zh": "个人护理"}', 'beauty-grooming', 2),
((SELECT id FROM categories WHERE slug = 'grocery-fresh'),
'{"en": "Snacks", "zh": "休闲零食"}', 'grocery-snacks', 1),
((SELECT id FROM categories WHERE slug = 'grocery-fresh'),
'{"en": "Fresh Produce", "zh": "生鲜果蔬"}', 'grocery-produce', 2);
-- Third level: two grandchildren under every second-level category.
INSERT INTO categories (parent_id, name, slug, position)
VALUES
((SELECT id FROM categories WHERE slug = 'electronics-phones'),
'{"en": "Flagship Phones", "zh": "旗舰机型"}', 'electronics-flagship', 1),
((SELECT id FROM categories WHERE slug = 'electronics-phones'),
'{"en": "Budget Phones", "zh": "千元机"}', 'electronics-budget', 2),
((SELECT id FROM categories WHERE slug = 'electronics-audio'),
'{"en": "Earbuds", "zh": "真无线耳机"}', 'electronics-earbuds', 1),
((SELECT id FROM categories WHERE slug = 'electronics-audio'),
'{"en": "Speakers", "zh": "蓝牙音箱"}', 'electronics-speakers', 2),
((SELECT id FROM categories WHERE slug = 'fashion-menswear'),
'{"en": "Jackets", "zh": "夹克外套"}', 'fashion-jackets', 1),
((SELECT id FROM categories WHERE slug = 'fashion-menswear'),
'{"en": "Shirts", "zh": "衬衫"}', 'fashion-shirts', 2),
((SELECT id FROM categories WHERE slug = 'fashion-bags'),
'{"en": "Backpacks", "zh": "双肩包"}', 'fashion-backpacks', 1),
((SELECT id FROM categories WHERE slug = 'fashion-bags'),
'{"en": "Luggage", "zh": "旅行箱"}', 'fashion-luggage', 2),
((SELECT id FROM categories WHERE slug = 'home-kitchen'),
'{"en": "Cookers", "zh": "电饭煲"}', 'home-cookers', 1),
((SELECT id FROM categories WHERE slug = 'home-kitchen'),
'{"en": "Blenders", "zh": "破壁机"}', 'home-blenders', 2),
((SELECT id FROM categories WHERE slug = 'home-cleaning'),
'{"en": "Vacuums", "zh": "吸尘器"}', 'home-vacuums', 1),
((SELECT id FROM categories WHERE slug = 'home-cleaning'),
'{"en": "Air Purifiers", "zh": "空气净化器"}', 'home-purifiers', 2),
((SELECT id FROM categories WHERE slug = 'computers-laptops'),
'{"en": "Ultrabooks", "zh": "轻薄本"}', 'computers-ultrabooks', 1),
((SELECT id FROM categories WHERE slug = 'computers-laptops'),
'{"en": "Gaming Laptops", "zh": "游戏本"}', 'computers-gaming', 2),
((SELECT id FROM categories WHERE slug = 'computers-peripherals'),
'{"en": "Keyboards", "zh": "键盘"}', 'computers-keyboards', 1),
((SELECT id FROM categories WHERE slug = 'computers-peripherals'),
'{"en": "Monitors", "zh": "显示器"}', 'computers-monitors', 2),
((SELECT id FROM categories WHERE slug = 'beauty-skincare'),
'{"en": "Serums", "zh": "精华"}', 'beauty-serums', 1),
((SELECT id FROM categories WHERE slug = 'beauty-skincare'),
'{"en": "Creams", "zh": "面霜"}', 'beauty-creams', 2),
((SELECT id FROM categories WHERE slug = 'beauty-grooming'),
'{"en": "Shavers", "zh": "剃须刀"}', 'beauty-shavers', 1),
((SELECT id FROM categories WHERE slug = 'beauty-grooming'),
'{"en": "Hair Care", "zh": "洗发护发"}', 'beauty-haircare', 2),
((SELECT id FROM categories WHERE slug = 'grocery-snacks'),
'{"en": "Nuts", "zh": "坚果"}', 'grocery-nuts', 1),
((SELECT id FROM categories WHERE slug = 'grocery-snacks'),
'{"en": "Chocolate", "zh": "巧克力"}', 'grocery-chocolate', 2),
((SELECT id FROM categories WHERE slug = 'grocery-produce'),
'{"en": "Fruit", "zh": "水果"}', 'grocery-fruit', 1),
((SELECT id FROM categories WHERE slug = 'grocery-produce'),
'{"en": "Vegetables", "zh": "蔬菜"}', 'grocery-vegetables', 2);
+66 -11
View File
@@ -64,8 +64,50 @@ struct ListQuery {
category_id: Option<Uuid>, category_id: Option<Uuid>,
shop_id: Option<Uuid>, shop_id: Option<Uuid>,
q: Option<String>, q: Option<String>,
sort: Option<String>,
order: Option<String>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SortBy {
Newest,
Price,
}
impl ListQuery {
/// Only `price` is a supported sort; anything else is a client error rather
/// than being silently ignored. An absent `sort` keeps newest-first.
fn sort_by(&self) -> ApiResult<SortBy> {
match self.sort.as_deref() {
None => Ok(SortBy::Newest),
Some("price") => Ok(SortBy::Price),
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
}
}
fn ascending(&self) -> ApiResult<bool> {
match self.order.as_deref() {
None | Some("asc") => Ok(true),
Some("desc") => Ok(false),
Some(other) => Err(ApiError::BadRequest(format!("unsupported order: {other}"))),
}
}
}
/// Resolves the requested category to itself plus every descendant, so a parent
/// category lists its children's and grandchildren's products too. Shared by the
/// count and page queries so `total` cannot drift from `items`.
const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS (
SELECT id FROM categories WHERE id = $1::uuid
UNION ALL
SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id
) ";
/// Lowest active SKU price, used only when sorting by price. Products without a
/// sellable SKU sort last in both directions.
const MIN_PRICE: &str =
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
/// Public catalog: only published products of active shops. /// Public catalog: only published products of active shops.
async fn list_products( async fn list_products(
State(state): State<AppState>, State(state): State<AppState>,
@@ -74,27 +116,39 @@ async fn list_products(
let page = clamp_page(q.page); let page = clamp_page(q.page);
let per_page = clamp_per_page(q.per_page); let per_page = clamp_per_page(q.per_page);
let pattern = q.q.as_ref().map(|s| format!("%{s}%")); let pattern = q.q.as_ref().map(|s| format!("%{s}%"));
let total: i64 = sqlx::query_scalar( let sort_by = q.sort_by()?;
"SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id let ascending = q.ascending()?;
let total: i64 = sqlx::query_scalar(&format!(
"{SUBTREE_CTE}
SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.status = 'published' AND s.status = 'active' WHERE p.status = 'published' AND s.status = 'active'
AND ($1::uuid IS NULL OR p.category_id = $1) AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
AND ($2::uuid IS NULL OR p.shop_id = $2) AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3)", AND ($3::text IS NULL OR p.name::text ILIKE $3)"
) ))
.bind(q.category_id) .bind(q.category_id)
.bind(q.shop_id) .bind(q.shop_id)
.bind(&pattern) .bind(&pattern)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
let products = sqlx::query_as::<_, Product>(
"SELECT p.* FROM products p JOIN shops s ON s.id = p.shop_id // Interpolates only from the validated SortBy/order pair, never from input.
let order_clause = match (sort_by, ascending) {
(SortBy::Newest, _) => "p.created_at DESC".to_string(),
(SortBy::Price, true) => format!("{MIN_PRICE} ASC NULLS LAST, p.created_at DESC"),
(SortBy::Price, false) => format!("{MIN_PRICE} DESC NULLS LAST, p.created_at DESC"),
};
let products = sqlx::query_as::<_, Product>(&format!(
"{SUBTREE_CTE}
SELECT p.* FROM products p JOIN shops s ON s.id = p.shop_id
WHERE p.status = 'published' AND s.status = 'active' WHERE p.status = 'published' AND s.status = 'active'
AND ($1::uuid IS NULL OR p.category_id = $1) AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
AND ($2::uuid IS NULL OR p.shop_id = $2) AND ($2::uuid IS NULL OR p.shop_id = $2)
AND ($3::text IS NULL OR p.name::text ILIKE $3) AND ($3::text IS NULL OR p.name::text ILIKE $3)
ORDER BY p.created_at DESC ORDER BY {order_clause}
LIMIT $4 OFFSET $5", LIMIT $4 OFFSET $5"
) ))
.bind(q.category_id) .bind(q.category_id)
.bind(q.shop_id) .bind(q.shop_id)
.bind(&pattern) .bind(&pattern)
@@ -102,6 +156,7 @@ async fn list_products(
.bind((page - 1) * per_page) .bind((page - 1) * per_page)
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
let items = attach_skus(&state.db, products, true).await?; let items = attach_skus(&state.db, products, true).await?;
Ok(Json(Paged { Ok(Json(Paged {
items, items,
+96 -2
View File
@@ -1,8 +1,8 @@
mod common; mod common;
use common::{ use common::{
client, create_product_with_sku, create_shop, login_admin, make_shop_owner, register_customer, category_id_by_slug, client, create_product_with_sku, create_product_with_sku_in_category,
spawn_app, create_shop, login_admin, make_shop_owner, publish_product, register_customer, spawn_app,
}; };
use serial_test::serial; use serial_test::serial;
@@ -180,6 +180,100 @@ async fn currency_conversion_math() {
assert_eq!(res.status(), 400); assert_eq!(res.status(), 400);
} }
#[tokio::test]
#[serial]
async fn category_subtree_listing_and_price_sort() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let shop_id = create_shop(&app, &admin, "shop-browse").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let electronics = category_id_by_slug(&app, "electronics").await;
let phones = category_id_by_slug(&app, "electronics-phones").await;
let flagship = category_id_by_slug(&app, "electronics-flagship").await;
let audio = category_id_by_slug(&app, "electronics-audio").await;
let fashion = category_id_by_slug(&app, "fashion").await;
// A grandchild, a sibling branch under the same root, and an unrelated root.
let (leaf, _) =
create_product_with_sku_in_category(&app, &owner, "leaf", 5000, 5, Some(&flagship)).await;
let (sibling, _) =
create_product_with_sku_in_category(&app, &owner, "sib", 1000, 5, Some(&audio)).await;
let (unrelated, _) =
create_product_with_sku_in_category(&app, &owner, "other", 3000, 5, Some(&fashion)).await;
for id in [&leaf, &sibling, &unrelated] {
publish_product(&app, &owner, id).await;
}
fn listed_ids(body: &serde_json::Value) -> Vec<String> {
body["items"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_str().unwrap().to_string())
.collect()
}
// A root category must include its children's and grandchildren's products.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50"
)))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
let listed = listed_ids(&body);
assert!(listed.contains(&leaf), "grandchild product missing from root listing");
assert!(listed.contains(&sibling), "child product missing from root listing");
assert!(!listed.contains(&unrelated), "product from another root category leaked in");
assert_eq!(body["total"], 2, "total must count the subtree, not only the root");
// A mid-level category covers its own subtree and nothing else.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={phones}&shop_id={shop_id}"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![leaf.clone()]);
// Price sort orders by the product's lowest active SKU price.
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=asc"
)))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![sibling.clone(), leaf.clone()]);
let res = client()
.get(app.url(&format!(
"/api/products?category_id={electronics}&shop_id={shop_id}&per_page=50&sort=price&order=desc"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(listed_ids(&body), vec![leaf.clone(), sibling.clone()]);
// An unsupported sort is a client error rather than being silently ignored.
let res = client()
.get(app.url("/api/products?sort=bogus"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["error"]["code"], "BAD_REQUEST");
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn suspended_shop_hidden_from_public_catalog() { async fn suspended_shop_hidden_from_public_catalog() {
+43
View File
@@ -149,6 +149,18 @@ pub async fn create_product_with_sku(
slug: &str, slug: &str,
price_minor: i64, price_minor: i64,
stock: i32, stock: i32,
) -> (String, String) {
create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await
}
/// Same, but placed in `category_id` so category filtering can be exercised.
pub async fn create_product_with_sku_in_category(
app: &TestApp,
owner_token: &str,
slug: &str,
price_minor: i64,
stock: i32,
category_id: Option<&str>,
) -> (String, String) { ) -> (String, String) {
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let res = client() let res = client()
@@ -158,6 +170,7 @@ pub async fn create_product_with_sku(
"slug": slug, "slug": slug,
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")}, "name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
"description": {"en": "desc en", "zh": "描述"}, "description": {"en": "desc en", "zh": "描述"},
"category_id": category_id,
})) }))
.send() .send()
.await .await
@@ -183,6 +196,36 @@ pub async fn create_product_with_sku(
(product_id, slug) (product_id, slug)
} }
/// Publish a draft product so it appears in the public catalog.
pub async fn publish_product(app: &TestApp, owner_token: &str, product_id: &str) {
let res = client()
.post(app.url(&format!("/api/shop/products/{product_id}/publish")))
.bearer_auth(owner_token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "publish: {:?}", res.text().await);
}
/// Look up a seeded reference category id by slug.
pub async fn category_id_by_slug(app: &TestApp, slug: &str) -> String {
let res = client()
.get(app.url("/api/categories"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let cats: serde_json::Value = res.json().await.unwrap();
cats.as_array()
.unwrap()
.iter()
.find(|c| c["slug"] == slug)
.unwrap_or_else(|| panic!("seeded category {slug} missing"))["id"]
.as_str()
.unwrap()
.to_string()
}
/// Full sellable fixture: shop + owner + published product with one SKU. /// Full sellable fixture: shop + owner + published product with one SKU.
/// Returns (owner_token, shop_id, product_id, sku_id). /// Returns (owner_token, shop_id, product_id, sku_id).
pub async fn setup_sellable( pub async fn setup_sellable(
+19 -5
View File
@@ -1,18 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { topCategories, childCategories } from "~/mock/data"; import type { Category } from "@vmall/shared";
import { t as pick } from "@vmall/shared"; import { t as pick } from "@vmall/shared";
const props = withDefaults(defineProps<{ pinned?: boolean }>(), { pinned: false }); const props = withDefaults(defineProps<{ pinned?: boolean }>(), { pinned: false });
const { locale } = useI18n(); const { locale } = useI18n();
const { $api } = useNuxtApp();
const open = ref(false); const open = ref(false);
const hoverId = ref<string | null>(null); const hoverId = ref<string | null>(null);
// The catalog domain serves this live in Wave 1; the fixed-data adapter answers
// the same call when it is rolled back. Errors are captured rather than thrown,
// so a backend outage leaves the menu empty instead of breaking the shell.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const childrenOf = (parentId: string): Category[] =>
(categories.value ?? [])
.filter((category) => category.parent_id === parentId)
.sort((a, b) => a.position - b.position);
const cats = computed(() => const cats = computed(() =>
topCategories().map((c) => ({ (categories.value ?? [])
cat: c, .filter((category) => category.parent_id === null)
children: childCategories(c.id).map((cc) => ({ cat: cc, grandchildren: childCategories(cc.id) })), .sort((a, b) => a.position - b.position)
})), .map((cat) => ({
cat,
children: childrenOf(cat.id).map((cc) => ({ cat: cc, grandchildren: childrenOf(cc.id) })),
})),
); );
const active = computed(() => cats.value.find((g) => g.cat.id === hoverId.value) ?? null); const active = computed(() => cats.value.find((g) => g.cat.id === hoverId.value) ?? null);
+15 -54
View File
@@ -19,11 +19,6 @@ const L = (en: string, zh: string): LocalizedText => ({ en, zh });
// ---------- mall-local mock types ---------- // ---------- mall-local mock types ----------
export interface MockBrand {
id: string;
name: string;
}
export interface MockStore { export interface MockStore {
id: string; id: string;
slug: string; slug: string;
@@ -55,14 +50,6 @@ export interface MockPromo {
url: string; url: string;
} }
export interface HomeFloor {
categoryId: string;
name: LocalizedText;
advImage: string;
advUrl: string;
products: Product[];
}
export interface MockComment { export interface MockComment {
id: string; id: string;
productId: string; productId: string;
@@ -343,32 +330,10 @@ export function categorySubtreeIds(rootId: string): Set<string> {
return ids; return ids;
} }
export function topCategories(): Category[] {
return MOCK_CATEGORIES.filter((c) => c.parent_id === null).sort((a, b) => a.position - b.position);
}
export function childCategories(parentId: string): Category[] {
return MOCK_CATEGORIES.filter((c) => c.parent_id === parentId).sort((a, b) => a.position - b.position);
}
// ---------- brands ---------- // ---------- brands ----------
export const MOCK_BRANDS: MockBrand[] = [
{ id: "b1", name: "Aurora" },
{ id: "b2", name: "Nordwind" },
{ id: "b3", name: "Hexon" },
{ id: "b4", name: "Mikado" },
{ id: "b5", name: "Solace" },
{ id: "b6", name: "Terra" },
];
const PRODUCT_BRAND: Record<string, string> = {}; const PRODUCT_BRAND: Record<string, string> = {};
export function brandOf(productId: string): MockBrand | null {
const id = PRODUCT_BRAND[productId];
return MOCK_BRANDS.find((b) => b.id === id) ?? null;
}
// ---------- stores ---------- // ---------- stores ----------
export const MOCK_STORES: MockStore[] = [ export const MOCK_STORES: MockStore[] = [
@@ -547,12 +512,20 @@ export interface MockSearchQuery {
perPage?: number; perPage?: number;
} }
// deterministic pseudo stats so sort orders are stable // Deterministic pseudo stats so sort orders are stable. Seeded by hashing the
// id, not by parsing digits out of it: mock ids are "p1" but live ids are UUIDs,
// and `Number("f8a1...")` is NaN, which rendered as "NaN sold".
function seedOf(id: string): number {
let hash = 0;
for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
return hash;
}
export function salesOf(p: Product): number { export function salesOf(p: Product): number {
return 50 + ((Number(p.id.slice(1)) * 137) % 950); return 50 + ((seedOf(p.id) * 137) % 950);
} }
export function commentCountOf(p: Product): number { export function commentCountOf(p: Product): number {
return 5 + ((Number(p.id.slice(1)) * 61) % 240); return 5 + ((seedOf(p.id) * 61) % 240);
} }
export function searchMockProducts(query: MockSearchQuery): { items: Product[]; total: number; page: number; per_page: number } { export function searchMockProducts(query: MockSearchQuery): { items: Product[]; total: number; page: number; per_page: number } {
@@ -585,7 +558,7 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[];
list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b))); list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b)));
break; break;
default: default:
list = [...list].sort((a, b) => Number(a.id.slice(1)) - Number(b.id.slice(1))); list = [...list].sort((a, b) => seedOf(a.id) - seedOf(b.id));
} }
const total = list.length; const total = list.length;
const items = list.slice((page - 1) * perPage, page * perPage); const items = list.slice((page - 1) * perPage, page * perPage);
@@ -624,20 +597,6 @@ export const MOCK_PROMOS: MockPromo[] = [
{ image: "/mock/promo-3.svg", url: "/search?category=c6" }, { image: "/mock/promo-3.svg", url: "/search?category=c6" },
]; ];
export function homeFloors(): HomeFloor[] {
return topCategories().map((cat, i) => {
const ids = categorySubtreeIds(cat.id);
const products = MOCK_PRODUCTS.filter((p) => p.category_id !== null && ids.has(p.category_id)).slice(0, 8);
return {
categoryId: cat.id,
name: cat.name,
advImage: `/mock/floor-adv-${i + 1}.svg`,
advUrl: `/search?category=${cat.id}`,
products,
};
});
}
// ---------- product detail extras ---------- // ---------- product detail extras ----------
export const MOCK_COUPONS: MockCoupon[] = [ export const MOCK_COUPONS: MockCoupon[] = [
@@ -677,7 +636,9 @@ export function commentsFor(productId: string): MockComment[] {
} }
export function commentStats(productId: string): { all: number; good: number; medium: number; bad: number; goodRate: number } { export function commentStats(productId: string): { all: number; good: number; medium: number; bad: number; goodRate: number } {
const all = commentCountOf(productById(productId) ?? MOCK_PRODUCTS[0]); // Derived from the id itself so it matches commentCountOf for live UUID ids
// too, instead of falling back to an arbitrary mock product.
const all = 5 + ((seedOf(productId) * 61) % 240);
const good = Math.round(all * 0.92); const good = Math.round(all * 0.92);
const medium = Math.round(all * 0.06); const medium = Math.round(all * 0.06);
const bad = all - good - medium; const bad = all - good - medium;
+4 -1
View File
@@ -6,7 +6,10 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
public: { public: {
apiBase: "http://localhost:8080/api", apiBase: "http://localhost:8080/api",
mockApi: true, // 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.
liveDomains: ["catalog", "currency"],
appName: "mall", appName: "mall",
}, },
}, },
+51 -6
View File
@@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { t as pick } from "@vmall/shared"; import { t as pick } from "@vmall/shared";
import type { Category, Sku } from "@vmall/shared"; import type { Category, Product, Sku } from "@vmall/shared";
import { import {
MOCK_CATEGORIES, MOCK_COUPONS,
MOCK_STORES,
commentStats,
commentsFor,
lowestSku, lowestSku,
productDetail,
salesOf, salesOf,
storeById,
} from "~/mock/data"; } from "~/mock/data";
import { useCartStore } from "~/stores/cart"; import { useCartStore } from "~/stores/cart";
@@ -21,8 +24,47 @@ const routeId = computed(() => {
const value = route.params.id; const value = route.params.id;
return Array.isArray(value) ? value[0] ?? "" : value ?? ""; return Array.isArray(value) ? value[0] ?? "" : value ?? "";
}); });
const detail = computed(() => productDetail(routeId.value)); // One request key for the whole page: the ranking rail must be derived from the
const product = computed(() => detail.value?.product ?? null); // product that was just fetched, and chaining two useAsyncData calls left the
// second handler running before the first had data.
const { data: pageData } = await useAsyncData(
"product-detail",
async () => {
const current = await $api.getProduct(routeId.value);
// The rail uses live catalogue products so its links resolve; the store
// card, comments and coupons stay local display-only content (non-goals).
// Store best sellers, mirroring the mock's salesRankFor(shopId). Ranking by
// category would empty the rail for any product alone in its leaf category.
const siblings = await $api.listProducts({
shop_id: current.shop_id,
per_page: 6,
});
return {
product: current,
related: siblings.items.filter((item) => item.id !== current.id).slice(0, 5),
};
},
{ watch: [routeId], default: () => null },
);
const product = computed(() => pageData.value?.product ?? null);
const related = computed<Product[]>(() => pageData.value?.related ?? []);
const detail = computed(() => {
const current = product.value;
if (!current) return null;
return {
product: current,
store: storeById(current.shop_id) ?? MOCK_STORES[0],
comments: commentsFor(current.id),
commentStats: commentStats(current.id),
coupons: MOCK_COUPONS,
salesRank: related.value,
};
});
/// Display-only sales figure; `salesOf` hashes the id so it is safe for UUIDs.
const store = computed(() => detail.value?.store ?? null); const store = computed(() => detail.value?.store ?? null);
const selectedAttributes = reactive<Record<string, string>>({}); const selectedAttributes = reactive<Record<string, string>>({});
const quantity = ref(1); const quantity = ref(1);
@@ -76,7 +118,10 @@ const marketPrice = computed(() => {
}); });
const currentImage = computed(() => product.value?.images[galleryIndex.value] ?? product.value?.images[0] ?? "/mock/product-1.svg"); const currentImage = computed(() => product.value?.images[galleryIndex.value] ?? product.value?.images[0] ?? "/mock/product-1.svg");
const categoryById = (id: string): Category | undefined => MOCK_CATEGORIES.find((category) => category.id === id); // Shared with the shell's category menu, so the tree is not refetched.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const categoryById = (id: string): Category | undefined =>
(categories.value ?? []).find((category) => category.id === id);
const categoryPath = computed(() => { const categoryPath = computed(() => {
const path: Category[] = []; const path: Category[] = [];
let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined; let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined;
+40 -3
View File
@@ -1,9 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Category, Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared"; import { t as pick } from "@vmall/shared";
import { MOCK_BANNERS, MOCK_PROMOS, MOCK_QUICK_LINKS, homeFloors } from "~/mock/data"; import { MOCK_BANNERS, MOCK_PROMOS, MOCK_QUICK_LINKS } from "~/mock/data";
interface HomeFloor {
categoryId: string;
name: Record<string, string>;
advImage: string;
advUrl: string;
products: Product[];
}
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const floors = computed(() => homeFloors().filter((f) => f.products.length > 0)); 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.
const { data: floors } = await useAsyncData("home-floors", async () => {
const roots: Category[] = categories.value ?? (await $api.listCategories());
const tops = roots
.filter((category) => category.parent_id === null)
.sort((a, b) => a.position - b.position);
const built = await Promise.all(
tops.map(async (category, index) => {
const page = await $api.listProducts({ category_id: category.id, per_page: 8 });
const floor: HomeFloor = {
categoryId: category.id,
name: category.name,
advImage: `/mock/floor-adv-${(index % 6) + 1}.svg`,
advUrl: `/search?category=${category.id}`,
products: page.items,
};
return floor;
}),
);
return built.filter((floor) => floor.products.length > 0);
});
const visibleFloors = computed(() => floors.value ?? []);
</script> </script>
<template> <template>
@@ -32,7 +69,7 @@ const floors = computed(() => homeFloors().filter((f) => f.products.length > 0))
</div> </div>
<div class="section-bg"> <div class="section-bg">
<section v-for="floor in floors" :key="floor.categoryId" class="w1200 floor"> <section v-for="floor in visibleFloors" :key="floor.categoryId" class="w1200 floor">
<header class="floor-head"> <header class="floor-head">
<h2>{{ pick(floor.name, locale) }}</h2> <h2>{{ pick(floor.name, locale) }}</h2>
<NuxtLink :to="`/search?category=${floor.categoryId}`" class="more">{{ t("home.viewMore") }} </NuxtLink> <NuxtLink :to="`/search?category=${floor.categoryId}`" class="more">{{ t("home.viewMore") }} </NuxtLink>
+38 -51
View File
@@ -1,20 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { t as pick } from "@vmall/shared"; import { t as pick } from "@vmall/shared";
import type { Category } from "@vmall/shared"; import type { Category, Paged, Product } from "@vmall/shared";
import {
MOCK_BRANDS,
MOCK_CATEGORIES,
childCategories,
searchMockProducts,
topCategories,
} from "~/mock/data";
type SortType = "default" | "price" | "sales" | "comments"; // Only sorts the catalog model can answer. Brand and sales/comment facets were
// removed in Wave 1: no backend model backs them.
type SortType = "default" | "price";
type OrderType = "asc" | "desc"; type OrderType = "asc" | "desc";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const queryValue = (value: unknown): string => { const queryValue = (value: unknown): string => {
if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : ""; if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : "";
@@ -22,33 +18,41 @@ const queryValue = (value: unknown): string => {
}; };
const state = computed(() => { const state = computed(() => {
const sortValue = queryValue(route.query.sort); const sort: SortType = queryValue(route.query.sort) === "price" ? "price" : "default";
const orderValue = queryValue(route.query.order); const order: OrderType = queryValue(route.query.order) === "asc" ? "asc" : "desc";
const sort: SortType = ["default", "price", "sales", "comments"].includes(sortValue)
? (sortValue as SortType)
: "default";
const order: OrderType = orderValue === "asc" ? "asc" : "desc";
const pageValue = Number.parseInt(queryValue(route.query.page), 10); const pageValue = Number.parseInt(queryValue(route.query.page), 10);
return { return {
q: queryValue(route.query.q), q: queryValue(route.query.q),
category: queryValue(route.query.category), category: queryValue(route.query.category),
brand: queryValue(route.query.brand),
sort, sort,
order, order,
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1, page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
}; };
}); });
const result = computed(() => const emptyPage: Paged<Product> = { items: [], total: 0, page: 1, per_page: 20 };
searchMockProducts({
q: state.value.q, const { data: result } = await useAsyncData(
categoryId: state.value.category || undefined, "search-results",
brandId: state.value.brand || undefined, () =>
sort: state.value.sort, $api.listProducts({
order: state.value.order, q: state.value.q || undefined,
page: state.value.page, category_id: state.value.category || undefined,
perPage: 20, sort: state.value.sort === "price" ? "price" : undefined,
}), order: state.value.sort === "price" ? state.value.order : undefined,
page: state.value.page,
per_page: 20,
}),
{ watch: [state], default: () => emptyPage },
);
// Shared with the shell's category menu, so the tree is not refetched.
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
const allCategories = computed(() => categories.value ?? []);
const byPosition = (a: Category, b: Category): number => a.position - b.position;
const topLevelCategories = computed(() =>
allCategories.value.filter((category) => category.parent_id === null).sort(byPosition),
); );
const replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => { const replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => {
@@ -56,7 +60,6 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
for (const [key, value] of Object.entries({ for (const [key, value] of Object.entries({
q: state.value.q, q: state.value.q,
category: state.value.category, category: state.value.category,
brand: state.value.brand,
sort: state.value.sort, sort: state.value.sort,
order: state.value.order, order: state.value.order,
page: String(state.value.page), page: String(state.value.page),
@@ -64,9 +67,9 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
})) { })) {
if (value) next[key] = value; if (value) next[key] = value;
} }
if (next.sort === "default") { if (next.sort === "default" || next.sort === undefined) {
delete next.sort; delete next.sort;
if (next.order === "desc") delete next.order; delete next.order;
} }
if (next.page === "1") delete next.page; if (next.page === "1") delete next.page;
await router.replace({ query: next }); await router.replace({ query: next });
@@ -76,10 +79,6 @@ const chooseCategory = (id?: string): void => {
void replaceQuery({ category: id, page: "1" }); void replaceQuery({ category: id, page: "1" });
}; };
const chooseBrand = (id?: string): void => {
void replaceQuery({ brand: id, page: "1" });
};
const chooseSort = (sort: SortType): void => { const chooseSort = (sort: SortType): void => {
const order: OrderType = state.value.sort === sort && sort !== "default" const order: OrderType = state.value.sort === sort && sort !== "default"
? state.value.order === "asc" ? "desc" : "asc" ? state.value.order === "asc" ? "desc" : "asc"
@@ -87,7 +86,8 @@ const chooseSort = (sort: SortType): void => {
void replaceQuery({ sort, order, page: "1" }); void replaceQuery({ sort, order, page: "1" });
}; };
const categoryById = (id: string): Category | undefined => MOCK_CATEGORIES.find((category) => category.id === id); const categoryById = (id: string): Category | undefined =>
allCategories.value.find((category) => category.id === id);
const selectedPath = computed(() => { const selectedPath = computed(() => {
const path: Category[] = []; const path: Category[] = [];
@@ -101,7 +101,9 @@ const selectedPath = computed(() => {
const categoryChildren = computed(() => { const categoryChildren = computed(() => {
const selected = selectedPath.value[selectedPath.value.length - 1]; const selected = selectedPath.value[selectedPath.value.length - 1];
return selected ? childCategories(selected.id) : []; return selected
? allCategories.value.filter((category) => category.parent_id === selected.id).sort(byPosition)
: [];
}); });
const breadcrumbItems = computed(() => [ const breadcrumbItems = computed(() => [
@@ -116,8 +118,6 @@ const breadcrumbItems = computed(() => [
const sortOptions = computed(() => [ const sortOptions = computed(() => [
{ key: "default" as SortType, label: t("search.defaultSort") }, { key: "default" as SortType, label: t("search.defaultSort") },
{ key: "price" as SortType, label: t("search.price") }, { key: "price" as SortType, label: t("search.price") },
{ key: "sales" as SortType, label: t("search.sales") },
{ key: "comments" as SortType, label: t("search.comments") },
]); ]);
</script> </script>
@@ -131,7 +131,7 @@ const sortOptions = computed(() => [
<div class="filter-options"> <div class="filter-options">
<button type="button" :class="{ active: !state.category }" @click="chooseCategory()">{{ t("search.all") }}</button> <button type="button" :class="{ active: !state.category }" @click="chooseCategory()">{{ t("search.all") }}</button>
<button <button
v-for="category in topCategories()" v-for="category in topLevelCategories"
:key="category.id" :key="category.id"
type="button" type="button"
:class="{ active: category.id === state.category }" :class="{ active: category.id === state.category }"
@@ -155,19 +155,6 @@ const sortOptions = computed(() => [
>{{ pick(category.name, locale) }}</button> >{{ pick(category.name, locale) }}</button>
</div> </div>
</div> </div>
<div class="filter-row">
<strong>{{ t("search.brand") }}</strong>
<div class="filter-options">
<button type="button" :class="{ active: !state.brand }" @click="chooseBrand()">{{ t("search.all") }}</button>
<button
v-for="brand in MOCK_BRANDS"
:key="brand.id"
type="button"
:class="{ active: brand.id === state.brand }"
@click="chooseBrand(brand.id)"
>{{ brand.name }}</button>
</div>
</div>
<div class="filter-row sort-row"> <div class="filter-row sort-row">
<strong>{{ t("search.sort") }}</strong> <strong>{{ t("search.sort") }}</strong>
<div class="filter-options"> <div class="filter-options">
+67 -9
View File
@@ -1,16 +1,74 @@
import { createApi } from "@vmall/shared"; import { createApi } from "@vmall/shared";
import type { ApiClient } from "@vmall/shared";
import { createMockApi } from "~/mock/api"; import { createMockApi } from "~/mock/api";
/**
* Domains the live backend serves. Every other domain stays on the fixed-data
* 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";
/**
* Explicit per-domain method picks rather than a string allowlist: indexing
* `ApiClient` by a union of method names would need an unsafe cast, and this
* keeps the compiler checking that every picked key exists.
*/
const LIVE_PICKS = {
auth: (a: ApiClient) => ({ register: a.register, login: a.login, me: a.me }),
catalog: (a: ApiClient) => ({
listProducts: a.listProducts,
getProduct: a.getProduct,
listCategories: a.listCategories,
}),
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
cart: (a: ApiClient) => ({
getCart: a.getCart,
addCartItem: a.addCartItem,
updateCartItem: a.updateCartItem,
removeCartItem: a.removeCartItem,
}),
orders: (a: ApiClient) => ({
checkout: a.checkout,
listMyOrders: a.listMyOrders,
getOrder: a.getOrder,
cancelOrder: a.cancelOrder,
payOrder: a.payOrder,
}),
shipments: (a: ApiClient) => ({
confirmDelivered: a.confirmDelivered,
listMyShipments: a.listMyShipments,
}),
invoices: (a: ApiClient) => ({
requestInvoice: a.requestInvoice,
listMyInvoices: a.listMyInvoices,
}),
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
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"];
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
// Mock-first MVP: default to the fixed-data adapter; set NUXT_PUBLIC_MOCK_API=false const mock = createMockApi();
// (or runtimeConfig.public.mockApi) to talk to the live backend again. const live = createApi({
const useMock = (config.public.mockApi as boolean | undefined) !== false; baseUrl: config.public.apiBase as string,
const api = useMock getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null),
? createMockApi() });
: createApi({
baseUrl: config.public.apiBase as string, const configured = config.public.liveDomains as string[] | undefined;
getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null), const liveDomains = (configured ?? DEFAULT_LIVE_DOMAINS).filter((domain): domain is LiveDomain =>
}); KNOWN_DOMAINS.includes(domain as LiveDomain),
);
// The fixed-data adapter is the base object, so an unmigrated domain cannot
// regress and a live domain can be rolled back by removing one entry.
let api: ApiClient = mock;
for (const domain of liveDomains) {
api = { ...api, ...LIVE_PICKS[domain](live) };
}
return { provide: { api } }; return { provide: { api } };
}); });
+59
View File
@@ -0,0 +1,59 @@
# TBD — migrate the mall off the mock API (waves 2+)
Wave 1 is planned in the OpenSpec change `openspec/changes/replace-mock-api-wave-1/`
(catalog + currency). This file tracks everything after it.
**How to use:** check a box only once the behaviour is implemented *and* verified against
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`). Wave 1 leaves a
per-domain switch in `apps/mall/plugins/api.ts`, so most of these are adding a domain name
to the live list — each still needs its own verification below.
**Delete this file** once every box in Waves 2 and 3 is checked. Wave 4 is optional: if you
decide against it, delete this file anyway and record that decision wherever you like.
---
## Wave 2 — auth + cart
- [ ] Flip `auth` to live and verify `login` / `register` / `me` against `:8080` using the seeded `customer@vmall.local` / `customer123`.
- [ ] Confirm bad credentials now produce a real 401 — the mock accepted any input (`apps/mall/mock/api.ts:125`), so this is a deliberate UX change.
- [ ] Confirm the JWT round-trips through the `vmall.token` localStorage key, shared with shop-admin and admin, and that logout clears it.
- [ ] Flip `cart` to live. Cart requires a token (`AuthUser` on all four handlers in `apps/api/src/routes/cart.rs`) and real SKU ids from Wave 1 — both are prerequisites, not optional.
- [ ] Remove cart's mock display coupling: `apps/mall/pages/cart.vue:4,34,41,56,60` uses `productById` / `storeById` for shop name, image and stock. The live `CartView` already returns `product_name`, `image` and `unit_price_minor` (`apps/api/src/cart.rs:44-53`).
- [ ] Verify: add / qty update / remove / empty cart for a logged-out user, and the out-of-stock 409 path.
## Wave 3 — orders + shipments + invoices
- [ ] Flip `orders` to live and verify checkout splits one cart into per-shop orders with a price snapshot and stock decrement.
- [ ] Checkout still sources `shipping_address` from `MOCK_ADDRESSES` (`apps/mall/pages/checkout/index.vue:4,23,37,127`) — that is intentional; see the out-of-scope note below.
- [ ] Verify cancel restores stock and `payOrder` only accepts `pending_payment` (both are enforced live; the mock only mimicked them).
- [ ] Flip `shipments` to live and verify `confirmDelivered` moves the order to `completed`.
- [ ] Flip `invoices` to live and verify a company invoice requires a tax number and that one order can hold only one active invoice.
- [ ] Fix contract debt so the TS types stop lying: `Shipment.items` is required in `packages/shared/src/types.ts` but the live struct has no `items` field (`apps/api/src/models.rs:172-182`), and `Invoice.invoice_no` is nullable live (`models.rs:194`) but non-null `string` in TS.
- [ ] Remove the remaining `storeById` mock usage on the order pages (`apps/mall/pages/user/orders/index.vue:4`).
- [ ] Confirm the mall still renders when the live API is down (mock mode remains the fallback for local UI work).
## Wave 4 — optional new backend capabilities
Only if you want more of the storefront backed by real data. Each is a new capability, not a flip.
- [ ] **Storefront content** — banners, promos, quick links, home floors 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.
- [ ] **Public store read** — a buyer-facing shop endpoint so `stores/index` and `stores/[id]` 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.
---
## Deliberately out of scope — not tracked here, and they do not block deleting this file
These have no API contract and no backend model. Leaving them on `~/mock/data` is a decision, not a backlog item.
- **Addresses** — never a blocker: `Address` is embedded in the order and live checkout takes it in the request body, so no addresses table is needed. `MOCK_ADDRESSES` can stay behind checkout indefinitely.
- **Favorites, coupons, account stats** — pure presentation, no transactional impact.
- **seckill / collective / integral marketing pages** — display-only mock content.
## Decisions already made (do not relitigate)
- Category filtering is by **subtree**; the backend exact-match filter was the bug (fixed in Wave 1).
- A migration wave must not change what the UI claims: facets without a backing model are removed rather than left matching nothing.
- The mall is the last mock holdout; `shop-admin` and `admin` already run live against the same backend.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-17
@@ -0,0 +1,67 @@
# Design
## Context
See `proposal.md` — Why. Three facts shape the approach:
- `apps/mall/plugins/api.ts` chooses one adapter for the entire app from a single boolean, so nothing can migrate domain-by-domain today.
- The mall's browse surfaces never call the `ApiClient` catalog methods at all; 18 files import `~/mock/data` directly (`apps/mall/pages/{index,search}.vue`, `pages/goods/[id].vue`, `components/shell/CategoryMenu.vue`, and others).
- The live catalog is far thinner than the mock: `apps/api/migrations/0003_catalog.sql` seeds 3 childless categories, and `scripts/seed-demo.mjs` seeds 4 products and 1 shop, against the mock's 6 categories with children/grandchildren, 24 products, 4 shops and 6 brands.
`packages/shared/src/api.ts` defines `ApiClient` as flat top-level methods plus `shop` and `admin` sub-objects, which is what makes per-domain composition cheap. `ProductListQuery` currently carries only `page`, `per_page`, `category_id`, `q`, `shop_id`.
## Goals / Non-Goals
**Goals:**
- Make the adapter choice per domain, so `catalog` and `currency` run live while the rest stays on fixed data.
- Move every browse surface onto the catalog contract without changing what the UI claims to show.
- Make the live catalog good enough that the home page, search and product detail render convincingly.
**Non-Goals:**
- No change to the `shop`/`admin` surfaces — both apps already run live.
- No new content model. Banners, promos, quick links, floor advert art and the goods-page comment/coupon/sales rails stay local display-only content.
- No category CRUD API. Categories remain read-only reference data.
## Decisions
**1. Compose the client from a typed per-domain pick map, not a string allowlist.**
`plugins/api.ts` builds both clients and overlays the live one, domain by domain:
```ts
const LIVE_PICKS = {
auth: (a: ApiClient) => ({ register: a.register, login: a.login, me: a.me }),
catalog: (a: ApiClient) => ({ listProducts: a.listProducts, getProduct: a.getProduct, listCategories: a.listCategories }),
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
// cart, orders, shipments, invoices
} satisfies Record<string, (a: ApiClient) => Partial<ApiClient>>;
```
`liveDomains` comes from `runtimeConfig.public.liveDomains` (default `["catalog", "currency"]`). The mock client stays whole and is the base object, so an unmigrated domain cannot regress and a live domain can be rolled back by removing one string.
*Alternatives:* a bare `keyof ApiClient[]` string list needs an unsafe index into a union of methods (AGENTS.md forbids `any`); a per-domain boolean in each page pushes the choice into 18 files. The pick map keeps the type checker honest and the config in one place.
**2. Category subtree filtering becomes a recursive CTE inside the existing query.**
`list_products` currently matches `p.category_id = $1` exactly (`apps/api/src/routes/catalog.rs:80`). Replace it with a `WITH RECURSIVE subtree AS (...)` CTE seeded from the requested category, referenced by both the count and the page query, so `items` and `total` cannot drift apart. This is the first recursive CTE in the codebase; at this tree size (3 levels, tens of rows) it is cheaper than a second round trip to resolve ids in Rust.
*Alternative:* resolve subtree ids in Rust then bind `= ANY($ids)` — mirrors the mock's `categorySubtreeIds`, but issues two queries and risks the count/list disagreeing.
**3. Price sort, validated by hand so errors keep the project's shape.**
`sort=price` orders by a correlated `(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active)`, with `order` of `asc`/`desc` and `NULLS LAST` so a product without a sellable SKU never sorts to the top. Parse `sort` and `order` as `Option<String>` and whitelist them, returning `ApiError` 400 on anything else — deserializing straight into a serde enum would make axum's own `Query` rejection answer with a body that is not `{"error":{"code","message"}}`, which AGENTS.md requires.
**4. Child categories ship as a migration, not as demo seed.**
The proposal said to grow `seed-demo.mjs` with categories, but there is no category write endpoint (`GET /categories` is the only route), so the script cannot create them. `0003_catalog.sql` already seeds categories as reference data, so a new append-only migration adds the children/grandchildren on the same terms. This is taxonomy, not demo data: it also gives shop-admin a usable category picker for the first time. Products and shops stay in `seed-demo.mjs`, which is idempotent by slug. No test asserts category contents (`apps/api/tests/catalog.rs` does not mention them), so this cannot break the suite.
**5. Facets without a model are removed, not faked.**
The brand facet and the `sales`/`comments` sorts are deleted from `/search`; only newest-first and price remain. Behaviour the spec does claim — the goods-page comment, coupon and sales-rail sections — is preserved and re-declared as local display-only content rather than dropped.
## Risks / Trade-offs
- **A per-domain switch can produce incoherent intermediate states** (e.g. live cart with mock catalog posts mock SKU ids that the live DB rejects) → `liveDomains` defaults to `["catalog", "currency"]` only, and `docs/TBD-migrate-wave.md` records that auth and catalog must be live before cart.
- **Rewiring 18 files can silently lose page behaviour that relied on mock-only fields** (sales counts, ratings, store cards) → keep those sections on local content, and verify each touched route in a real browser rather than trusting the build.
- **The live catalog may still look sparse after the migration** (3 top-level categories until the new migration lands) → seed parity is a Wave 1 task with the same weight as the wiring, not a follow-up.
- **Removing the brand facet is a visible regression** → deliberate; recorded as a Wave 4 capability in `docs/TBD-migrate-wave.md`.
- **`sort=price` adds a correlated subquery per row** → acceptable at MVP scale; if it becomes hot, denormalise a `min_price_minor` onto `products`.
## Migration Plan
1. Ship the category-tree migration and the catalog route changes first; the backend stays additive and backwards compatible (unsorted, exact-category requests keep working).
2. Re-run `scripts/seed-demo.mjs` to fill products and shops.
3. Flip `liveDomains` to `["catalog", "currency"]`. Rollback is removing a string from that list — no data migration to reverse, because the fixed-data adapter remains complete.
@@ -0,0 +1,33 @@
# Proposal
## Why
The mall is the only frontend still on the fixed-data mock adapter; `shop-admin` and `admin` run live. Every mall catalog surface also bypasses the `@vmall/shared` contract — 18 files import `~/mock/data` and no `$api` catalog call exists. The storefront cannot outgrow demo data, and no later domain can flip until real SKU ids come from the live catalog.
## What Changes
- Replace the all-or-nothing `mockApi` boolean with a per-domain switch; Wave 1 flips `catalog` and `currency`, leaving auth, cart, orders, shipments and invoices on mock.
- Rewire the browse surfaces (home floors, `/search`, `/goods/[id]`, category menu) from `~/mock/data` onto `listCategories`, `listProducts` and `getProduct`.
- **BREAKING** (browse UX): remove the brand facet and the `sales`/`comments` sorts, which have no backing model; add `sort=price` with `order=asc|desc`.
- Filter public product listing by category **subtree**, as the mock does today, instead of exact `category_id`.
- Grow `scripts/seed-demo.mjs` to parity: categories with children and grandchildren, ~24 bilingual products, 4 shops (live: 3 childless categories, 4 products, 1 shop).
- Banners, promos, quick links and floor art stay local.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `catalog`: add a public browse requirement — category subtree filtering and an optional price sort on `/api/products`.
- `frontend-mall`: "Mock API adapter" becomes a per-domain switch with catalog and currency live; browse surfaces re-sourced from the API without the brand facet or non-price sorts.
## Impact
`apps/mall/plugins/api.ts`; 18 files under `apps/mall/{pages,components}`; `apps/mall/mock/data.ts` (marketing content only); `packages/shared/src/api.ts` (`ProductListQuery` gains `sort`/`order`); `apps/api/src/routes/catalog.rs`; `scripts/seed-demo.mjs`. The shared contract change means all three frontends must rebuild.
## Non-goals
Auth, cart, orders, shipments and invoices stay mock. No backend capability for brands, storefront content, store directory, favorites, coupons or addresses; those stay mock. No change to money handling, i18n storage or the other apps.
@@ -0,0 +1,22 @@
# Spec Delta
## ADDED Requirements
### Requirement: Public product browse
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. The listing SHALL accept an optional `sort` of `price` together with an `order` of `asc` or `desc`, ordering by each product's lowest active SKU price; any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
#### Scenario: parent category includes descendant products
- **WHEN** a shopper requests products for a category that has child categories holding published products
- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category
#### Scenario: sort by lowest active SKU price
- **WHEN** a shopper requests the product list with `sort=price` and `order=asc`
- **THEN** products come back ordered by their lowest active SKU price ascending
#### Scenario: unsupported sort is rejected
- **WHEN** a client requests a `sort` value that is not `price`
- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter
#### Scenario: unpublished products never appear
- **WHEN** any public listing or filter is applied
- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total`
@@ -0,0 +1,36 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Mock API adapter
The mall SHALL select its API adapter per domain, so one domain can be served by the live backend while the others stay on fixed data. The mall SHALL still ship a fixed-data adapter implementing the whole `@vmall/shared` API client surface, and the live/fixed choice SHALL be configurable per domain without changing page call sites. The fixed-data adapter SHALL remain able to serve every domain when the live backend is unavailable.
#### Scenario: mall runs without backend
- **WHEN** the mall starts with the API service unavailable and every domain configured to fixed data
- **THEN** browsing, cart, checkout, payment, orders and invoice pages return deterministic fixed data and remain functional
#### Scenario: domains migrate independently
- **WHEN** the live backend serves the catalog and currency domains while auth, cart, orders, shipments and invoices remain on fixed data
- **THEN** browsing and prices come from the backend while those other flows keep working against fixed data
### 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 while the banner, promotion, quick-link and floor advert assets remain local content.
#### 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 sourced from the catalog 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
### Requirement: Product discovery pages
The mall SHALL provide `/search` with breadcrumb, category and sort controls, a five-column desktop product grid, pagination and an empty state, listing products from the catalog API filtered by the selected category's subtree. The sort control SHALL offer newest-first and price ascending/descending only. It SHALL provide `/goods/[id]` rendering product and SKU data from the catalog API with image gallery/zoom, bilingual name/subtitle, integer-minor-unit prices, attribute and SKU selection, stock-aware quantity, store card, and detail/comments/after-sale tabs whose comment, coupon and sales content stays local display-only content.
#### Scenario: filter and inspect a product
- **WHEN** a shopper filters the search page by a parent category and opens a product
- **THEN** products from that category and its descendants are listed, and selecting an in-stock SKU updates the displayed price, stock and cart target from the catalog API
@@ -0,0 +1,34 @@
# Tasks
## 1. Backend: catalog browse
- [x] 1.1 Add an append-only migration seeding child and grandchild categories under the existing `electronics`, `fashion` and `home-living` rows, matching the mock's 3-level shape; verify `GET /api/categories` returns more than 3 rows with populated `parent_id`
- [x] 1.2 Replace the exact `p.category_id = $1` filter in `apps/api/src/routes/catalog.rs` with a `WITH RECURSIVE` subtree CTE shared by the count and page queries; verify a parent-category request returns its descendants' products
- [x] 1.3 Add optional `sort=price` and `order=asc|desc` to `list_products`, ordering by each product's lowest active SKU price with `NULLS LAST`, and reject any other value with an `ApiError` 400 whose body is `{"error":{"code","message"}}`; verify the 400 shape with curl
- [x] 1.4 Extend `apps/api/tests/catalog.rs` with cases for subtree listing, ascending/descending price order and the rejected-sort 400; verify `cargo test -p vmall-api` is green and repeatable
## 2. Shared contract
- [x] 2.1 Add optional `sort` and `order` to `ProductListQuery` in `packages/shared/src/api.ts` and pass them through in `createApi.listProducts`; verify `pnpm --filter @vmall/mall build` still type-checks
## 3. Demo data
- [x] 3.1 Grow `scripts/seed-demo.mjs` to roughly 24 bilingual products spread across the new category tree plus 4 shops, keeping the slug-lookup idempotency; verify a second run reports no duplicate creates and `GET /api/products?per_page=100` returns a full first page
## 4. Mall: per-domain adapter switch
- [x] 4.1 Replace the `mockApi` boolean in `apps/mall/plugins/api.ts` with a `liveDomains` list defaulting to `["catalog", "currency"]`, composing the live client over the fixed-data base through a typed per-domain pick map (no `any`); verify catalog requests hit `:8080` while `getCart` still resolves from fixed data with the backend stopped
## 5. Mall: rewire browse surfaces
- [x] 5.1 `apps/mall/pages/index.vue`: build floors from `listCategories` plus `listProducts` per category, leaving banners, promos, quick links and floor advert art local; verify every non-empty floor renders live products
- [x] 5.2 `apps/mall/components/shell/CategoryMenu.vue`: source the tree from `listCategories`, keeping the header dropdown mode and the pinned home mode intact; verify the pinned sidebar shows children from the API
- [x] 5.3 `apps/mall/pages/search.vue`: list through `listProducts` with the selected category's subtree and the new sort, and delete the brand facet plus the `sales`/`comments` sort options; verify filtering by a parent category lists descendant products
- [x] 5.4 `apps/mall/pages/goods/[id].vue`: load the product and SKUs through `getProduct`, keeping the comment, coupon and sales-rail sections on local display-only content; verify an in-stock SKU selection updates price, stock and the cart target
- [x] 5.5 Remove only the catalog helpers that lost their last consumer (`topCategories`, `childCategories`, `homeFloors`, `MOCK_BRANDS`, `brandOf`, `HomeFloor`, `MockBrand`) from `apps/mall/mock/data.ts`; amended from the original wording, which also removed `MOCK_PRODUCTS` and would have broken the rollback the `Mock API adapter` requirement promises, so `MOCK_PRODUCTS`/`searchMockProducts`/`productById` stay; verify `pnpm --filter @vmall/mall build` passes with no page importing a removed symbol
## 6. Verification
- [x] 6.1 Run `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build` and `pnpm --filter @vmall/admin build`, since the shared contract changed; verify all three pass
- [x] 6.2 With the live backend and seeds running, verify in a browser that home, search, product detail and the pinned category menu render live data, and that no console errors appear
- [x] 6.3 Stop the backend and confirm the mall still starts and the unmigrated domains (auth, cart, orders, invoices) keep working from fixed data; verify the browse pages degrade without crashing
+3
View File
@@ -41,6 +41,9 @@ export interface ProductListQuery {
category_id?: string; category_id?: string;
q?: string; q?: string;
shop_id?: string; shop_id?: string;
/** `price` orders by each product's lowest active SKU price. */
sort?: "price";
order?: "asc" | "desc";
} }
export interface ProductUpsertBody { export interface ProductUpsertBody {
+119 -81
View File
@@ -2,7 +2,12 @@
/** /**
* Seed demo data into a running vmall-api (dev). * Seed demo data into a running vmall-api (dev).
* Usage: node scripts/seed-demo.mjs [baseUrl] * Usage: node scripts/seed-demo.mjs [baseUrl]
* Idempotent: existing slugs/emails are reused, products are re-published. * Idempotent: existing slugs/emails are reused, products are re-published,
* and an owner's role is only reassigned when it does not already point at
* the right shop.
*
* Categories are NOT created here - they are reference data seeded by the
* migrations, because the API exposes no category write route.
*/ */
const base = process.argv[2] ?? "http://localhost:8080/api"; const base = process.argv[2] ?? "http://localhost:8080/api";
@@ -37,117 +42,149 @@ let r = await call("POST", "/auth/login", {
if (r.status !== 200) fail("admin login", r); if (r.status !== 200) fail("admin login", r);
const admin = r.data.token; const admin = r.data.token;
// 2. demo shop // 2. shops with their own owner accounts
let shopId; const SHOPS = [
r = await call("POST", "/admin/shops", { {
token: admin,
body: {
name: { en: "Demo Store", zh: "演示店铺" },
slug: "demo-store", slug: "demo-store",
name: { en: "Demo Store", zh: "演示店铺" },
owner: { email: "shop@vmall.local", password: "shop12345", displayName: "Demo Owner" },
}, },
}); {
if (r.status === 201) shopId = r.data.id; slug: "aurora-digital",
else if (r.status === 409) { name: { en: "Aurora Digital", zh: "极光数码" },
const shops = await call("GET", "/admin/shops", { token: admin }); owner: { email: "aurora@vmall.local", password: "shop12345", displayName: "Aurora Owner" },
shopId = shops.data.find((s) => s.slug === "demo-store").id; },
} else fail("create shop", r); {
slug: "nordwind-home",
name: { en: "Nordwind Home", zh: "北风家居" },
owner: { email: "nordwind@vmall.local", password: "shop12345", displayName: "Nordwind Owner" },
},
{
slug: "terra-grocery",
name: { en: "Terra Grocery", zh: "大地生鲜" },
owner: { email: "terra@vmall.local", password: "shop12345", displayName: "Terra Owner" },
},
];
// 3. demo shop owner (register may 409 if exists) /** Create the shop if missing and return a token for its owner. */
const ownerEmail = "shop@vmall.local"; async function ensureShop(def) {
await call("POST", "/auth/register", { let shopId;
body: { email: ownerEmail, password: "shop12345", display_name: "Demo Owner" }, r = await call("POST", "/admin/shops", {
});
r = await call("POST", "/auth/login", {
body: { email: ownerEmail, password: "shop12345" },
});
if (r.status !== 200) fail("owner login", r);
let owner = r.data.token;
if (r.data.user.role !== "shop_owner") {
r = await call("PUT", `/admin/users/${r.data.user.id}/role`, {
token: admin, token: admin,
body: { role: "shop_owner", shop_id: shopId }, body: { name: def.name, slug: def.slug },
});
if (r.status === 201) shopId = r.data.id;
else if (r.status === 409) {
const shops = await call("GET", "/admin/shops", { token: admin });
shopId = shops.data.find((s) => s.slug === def.slug).id;
} else fail(`create shop ${def.slug}`, r);
await call("POST", "/auth/register", {
body: {
email: def.owner.email,
password: def.owner.password,
display_name: def.owner.displayName,
},
}); });
if (r.status !== 200) fail("assign owner", r);
r = await call("POST", "/auth/login", { r = await call("POST", "/auth/login", {
body: { email: ownerEmail, password: "shop12345" }, body: { email: def.owner.email, password: def.owner.password },
}); });
owner = r.data.token; if (r.status !== 200) fail(`owner login ${def.owner.email}`, r);
let token = r.data.token;
const ownsThisShop = r.data.user.role === "shop_owner" && r.data.user.shop_id === shopId;
if (!ownsThisShop) {
r = await call("PUT", `/admin/users/${r.data.user.id}/role`, {
token: admin,
body: { role: "shop_owner", shop_id: shopId },
});
if (r.status !== 200) fail(`assign owner ${def.slug}`, r);
r = await call("POST", "/auth/login", {
body: { email: def.owner.email, password: def.owner.password },
});
if (r.status !== 200) fail(`owner re-login ${def.owner.email}`, r);
token = r.data.token;
}
console.log(`shop ready: ${def.slug}`);
return token;
} }
// 4. demo customer const ownerTokens = new Map();
for (const def of SHOPS) {
ownerTokens.set(def.slug, await ensureShop(def));
}
// 3. demo customer
await call("POST", "/auth/register", { await call("POST", "/auth/register", {
body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" }, body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" },
}); });
// 5. categories // 4. categories (reference data from the migrations)
const cats = (await call("GET", "/categories")).data; const cats = (await call("GET", "/categories")).data;
const catBy = (slug) => cats.find((c) => c.slug === slug)?.id ?? null; const catBy = (slug) => cats.find((c) => c.slug === slug)?.id ?? null;
if (cats.length < 7) {
console.error(`expected the seeded category tree, found ${cats.length} categories`);
process.exit(1);
}
// 6. products // 5. products: 24 across 4 shops, covering all six top-level categories so
// every home floor is non-empty.
const products = [ const products = [
{ // demo-store
slug: "wireless-headphones", { shop: "demo-store", slug: "wireless-headphones", category: "electronics-audio", price: 7999, stock: 25, name: { en: "Wireless Headphones", zh: "无线耳机" }, description: { en: "Noise-cancelling over-ear headphones with 40h battery.", zh: "降噪头戴式耳机,40 小时续航。" }, img: "headphones" },
name: { en: "Wireless Headphones", zh: "无线耳机" }, { shop: "demo-store", slug: "mechanical-keyboard", category: "computers-keyboards", price: 12900, stock: 40, name: { en: "Mechanical Keyboard", zh: "机械键盘" }, description: { en: "Hot-swappable 75% keyboard, gasket mount.", zh: "热插拔 75% 配列机械键盘,Gasket 结构。" }, img: "keyboard" },
description: { { shop: "demo-store", slug: "linen-shirt", category: "fashion-shirts", price: 4599, stock: 60, name: { en: "Linen Shirt", zh: "亚麻衬衫" }, description: { en: "Breathable 100% linen shirt.", zh: "透气纯亚麻衬衫。" }, img: "shirt" },
en: "Noise-cancelling over-ear headphones with 40h battery.", { shop: "demo-store", slug: "ceramic-mug", category: "home-cookers", price: 1999, stock: 100, name: { en: "Ceramic Mug", zh: "陶瓷马克杯" }, description: { en: "Hand-glazed 350ml mug.", zh: "手工上釉 350ml 马克杯。" }, img: "mug" },
zh: "降噪头戴式耳机,40 小时续航。", { shop: "demo-store", slug: "canvas-backpack", category: "fashion-backpacks", price: 3999, stock: 45, name: { en: "Canvas Backpack", zh: "帆布双肩包" }, description: { en: "Water-resistant 22L everyday backpack.", zh: "防泼水 22L 通勤双肩包。" }, img: "backpack" },
}, { shop: "demo-store", slug: "merino-sweater", category: "fashion-menswear", price: 6900, stock: 30, name: { en: "Merino Sweater", zh: "美利奴羊毛衫" }, description: { en: "Fine-gauge merino crew neck.", zh: "细针美利奴圆领毛衣。" }, img: "sweater" },
category: "electronics",
price: 7999, // aurora-digital
stock: 25, { shop: "aurora-digital", slug: "aurora-x1-pro", category: "electronics-flagship", price: 99900, stock: 12, name: { en: "Aurora X1 Pro", zh: "Aurora X1 Pro 旗舰手机" }, description: { en: "6.7in flagship with triple camera and 120Hz display.", zh: "6.7 英寸三摄旗舰,120Hz 屏幕。" }, img: "aurora-x1" },
img: "https://picsum.photos/seed/headphones/600/600", { shop: "aurora-digital", slug: "aurora-a3", category: "electronics-budget", price: 19900, stock: 60, name: { en: "Aurora A3", zh: "Aurora A3 手机" }, description: { en: "Everyday 5G phone with 5000mAh battery.", zh: "日常 5G 手机,5000mAh 电池。" }, img: "aurora-a3" },
}, { shop: "aurora-digital", slug: "aurora-buds-air", category: "electronics-earbuds", price: 12900, stock: 80, name: { en: "Aurora Buds Air", zh: "Aurora Buds Air 耳机" }, description: { en: "Active noise cancelling true wireless earbuds.", zh: "主动降噪真无线耳机。" }, img: "aurora-buds" },
{ { shop: "aurora-digital", slug: "aurora-boom-speaker", category: "electronics-speakers", price: 8900, stock: 35, name: { en: "Aurora Boom Speaker", zh: "Aurora Boom 音箱" }, description: { en: "Portable IPX7 speaker with 20h playback.", zh: "IPX7 防水便携音箱,20 小时播放。" }, img: "aurora-boom" },
slug: "mechanical-keyboard", { shop: "aurora-digital", slug: "aurora-ultrabook-14", category: "computers-ultrabooks", price: 129900, stock: 18, name: { en: "Aurora Ultrabook 14", zh: "Aurora 轻薄本 14" }, description: { en: "1.1kg magnesium chassis with 14in OLED panel.", zh: "1.1kg 镁合金机身,14 英寸 OLED 屏。" }, img: "aurora-ultrabook" },
name: { en: "Mechanical Keyboard", zh: "机械键盘" }, { shop: "aurora-digital", slug: "aurora-27-monitor", category: "computers-monitors", price: 34900, stock: 22, name: { en: "Aurora 27 Monitor", zh: "Aurora 27 英寸显示器" }, description: { en: "27in 4K USB-C monitor with 90W power delivery.", zh: "27 英寸 4K USB-C 显示器,90W 反向供电。" }, img: "aurora-monitor" },
description: {
en: "Hot-swappable 75% keyboard, gasket mount.", // nordwind-home
zh: "热插拔 75% 配列机械键盘,Gasket 结构。", { shop: "nordwind-home", slug: "nordwind-rice-cooker", category: "home-cookers", price: 8900, stock: 50, name: { en: "Nordwind Rice Cooker", zh: "北风智能电饭煲" }, description: { en: "IH rice cooker with 12 presets.", zh: "IH 电磁加热,12 种预设菜单。" }, img: "nordwind-cooker" },
}, { shop: "nordwind-home", slug: "nordwind-blender-pro", category: "home-blenders", price: 15900, stock: 28, name: { en: "Nordwind Blender Pro", zh: "北风破壁机 Pro" }, description: { en: "1400W high-speed blender with vacuum jar.", zh: "1400W 高速破壁机,配真空杯。" }, img: "nordwind-blender" },
category: "electronics", { shop: "nordwind-home", slug: "nordwind-stick-vacuum", category: "home-vacuums", price: 24900, stock: 24, name: { en: "Nordwind Stick Vacuum", zh: "北风无线吸尘器" }, description: { en: "Cordless vacuum with 60min runtime.", zh: "无线手持吸尘器,续航 60 分钟。" }, img: "nordwind-vacuum" },
price: 12900, { shop: "nordwind-home", slug: "nordwind-air-purifier", category: "home-purifiers", price: 32900, stock: 20, name: { en: "Nordwind Air Purifier", zh: "北风空气净化器" }, description: { en: "HEPA 13 purifier covering 60 square metres.", zh: "HEPA 13 滤网,适用 60 平方米。" }, img: "nordwind-purifier" },
stock: 40, { shop: "nordwind-home", slug: "nordwind-serum-c", category: "beauty-serums", price: 4900, stock: 70, name: { en: "Nordwind Vitamin C Serum", zh: "北风维C精华" }, description: { en: "15% vitamin C brightening serum.", zh: "15% 维C 提亮精华。" }, img: "nordwind-serum" },
img: "https://picsum.photos/seed/keyboard/600/600", { shop: "nordwind-home", slug: "nordwind-shave-9000", category: "beauty-shavers", price: 7900, stock: 40, name: { en: "Nordwind Shaver 9000", zh: "北风剃须刀 9000" }, description: { en: "Wet and dry rotary shaver with travel case.", zh: "干湿两用旋转剃须刀,含旅行盒。" }, img: "nordwind-shaver" },
},
{ // terra-grocery
slug: "linen-shirt", { shop: "terra-grocery", slug: "terra-mixed-nuts", category: "grocery-nuts", price: 1599, stock: 200, name: { en: "Terra Mixed Nuts", zh: "大地混合坚果" }, description: { en: "Roasted unsalted nut mix, 500g.", zh: "烘烤无盐混合坚果,500g。" }, img: "terra-nuts" },
name: { en: "Linen Shirt", zh: "亚麻衬衫" }, { shop: "terra-grocery", slug: "terra-dark-chocolate", category: "grocery-chocolate", price: 1299, stock: 180, name: { en: "Terra Dark Chocolate", zh: "大地黑巧克力" }, description: { en: "72% single-origin dark chocolate.", zh: "72% 单一产地黑巧克力。" }, img: "terra-chocolate" },
description: { en: "Breathable 100% linen shirt.", zh: "透气纯亚麻衬衫。" }, { shop: "terra-grocery", slug: "terra-orchard-apples", category: "grocery-fruit", price: 899, stock: 300, name: { en: "Terra Orchard Apples", zh: "大地果园苹果" }, description: { en: "Crisp orchard apples, 1kg box.", zh: "脆甜果园苹果,1kg 装。" }, img: "terra-apples" },
category: "fashion", { shop: "terra-grocery", slug: "terra-organic-kale", category: "grocery-vegetables", price: 699, stock: 260, name: { en: "Terra Organic Kale", zh: "大地有机羽衣甘蓝" }, description: { en: "Certified organic kale, 400g.", zh: "有机认证羽衣甘蓝,400g。" }, img: "terra-kale" },
price: 4599, { shop: "terra-grocery", slug: "terra-carry-on", category: "fashion-luggage", price: 42900, stock: 16, name: { en: "Terra Carry-On", zh: "大地登机箱" }, description: { en: "Polycarbonate carry-on with TSA lock.", zh: "聚碳酸酯登机箱,TSA 密码锁。" }, img: "terra-carryon" },
stock: 60, { shop: "terra-grocery", slug: "terra-leather-tote", category: "fashion-bags", price: 21900, stock: 26, name: { en: "Terra Leather Tote", zh: "大地真皮托特包" }, description: { en: "Full-grain leather tote with laptop sleeve.", zh: "头层牛皮托特包,含电脑隔层。" }, img: "terra-tote" },
img: "https://picsum.photos/seed/shirt/600/600",
},
{
slug: "ceramic-mug",
name: { en: "Ceramic Mug", zh: "陶瓷马克杯" },
description: { en: "Hand-glazed 350ml mug.", zh: "手工上釉 350ml 马克杯。" },
category: "home-living",
price: 1999,
stock: 100,
img: "https://picsum.photos/seed/mug/600/600",
},
]; ];
for (const p of products) { for (const p of products) {
const token = ownerTokens.get(p.shop);
r = await call("POST", "/shop/products", { r = await call("POST", "/shop/products", {
token: owner, token,
body: { body: {
slug: p.slug, slug: p.slug,
name: p.name, name: p.name,
description: p.description, description: p.description,
category_id: catBy(p.category), category_id: catBy(p.category),
images: [p.img], images: [`https://picsum.photos/seed/${p.img}/600/600`],
}, },
}); });
let id; let id;
if (r.status === 201) id = r.data.id; if (r.status === 201) id = r.data.id;
else if (r.status === 409) { else if (r.status === 409) {
const list = await call("GET", "/shop/products?per_page=100", { token: owner }); const list = await call("GET", "/shop/products?per_page=100", { token });
id = list.data.items.find((x) => x.slug === p.slug).id; id = list.data.items.find((x) => x.slug === p.slug)?.id;
if (!id) fail(`lookup product ${p.slug}`, r);
} else fail(`create product ${p.slug}`, r); } else fail(`create product ${p.slug}`, r);
r = await call("POST", `/shop/products/${id}/skus`, { r = await call("POST", `/shop/products/${id}/skus`, {
token: owner, token,
body: { body: {
sku_code: `${p.slug}-std`, sku_code: `${p.slug}-std`,
price_minor: p.price, price_minor: p.price,
@@ -156,12 +193,13 @@ for (const p of products) {
}, },
}); });
if (r.status !== 200) fail(`sku ${p.slug}`, r); if (r.status !== 200) fail(`sku ${p.slug}`, r);
r = await call("POST", `/shop/products/${id}/publish`, { token: owner }); r = await call("POST", `/shop/products/${id}/publish`, { token });
if (r.status !== 200) fail(`publish ${p.slug}`, r); if (r.status !== 200) fail(`publish ${p.slug}`, r);
console.log(`product ready: ${p.slug}`);
} }
console.log(`products ready: ${products.length} across ${SHOPS.length} shops`);
console.log("\nDemo data seeded."); console.log("\nDemo data seeded.");
console.log(" platform admin : admin@vmall.local / admin1234 (apps/admin :3002)"); console.log(" platform admin : admin@vmall.local / admin1234 (apps/admin :3002)");
console.log(" shop owner : shop@vmall.local / shop12345 (apps/shop-admin :3001)"); console.log(" shop owner : shop@vmall.local / shop12345 (apps/shop-admin :3001)");
console.log(" customer : customer@vmall.local / customer123 (apps/mall :3000)"); console.log(" customer : customer@vmall.local / customer123 (apps/mall :3000)");
console.log(" extra owners : aurora@ / nordwind@ / terra@vmall.local (password shop12345)");