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);