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:
@@ -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);
|
||||
@@ -64,8 +64,50 @@ struct ListQuery {
|
||||
category_id: Option<Uuid>,
|
||||
shop_id: Option<Uuid>,
|
||||
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.
|
||||
async fn list_products(
|
||||
State(state): State<AppState>,
|
||||
@@ -74,27 +116,39 @@ async fn list_products(
|
||||
let page = clamp_page(q.page);
|
||||
let per_page = clamp_per_page(q.per_page);
|
||||
let pattern = q.q.as_ref().map(|s| format!("%{s}%"));
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM products p JOIN shops s ON s.id = p.shop_id
|
||||
let sort_by = q.sort_by()?;
|
||||
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'
|
||||
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 ($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.shop_id)
|
||||
.bind(&pattern)
|
||||
.fetch_one(&state.db)
|
||||
.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'
|
||||
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 ($3::text IS NULL OR p.name::text ILIKE $3)
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $4 OFFSET $5",
|
||||
)
|
||||
ORDER BY {order_clause}
|
||||
LIMIT $4 OFFSET $5"
|
||||
))
|
||||
.bind(q.category_id)
|
||||
.bind(q.shop_id)
|
||||
.bind(&pattern)
|
||||
@@ -102,6 +156,7 @@ async fn list_products(
|
||||
.bind((page - 1) * per_page)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let items = attach_skus(&state.db, products, true).await?;
|
||||
Ok(Json(Paged {
|
||||
items,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
mod common;
|
||||
|
||||
use common::{
|
||||
client, create_product_with_sku, create_shop, login_admin, make_shop_owner, register_customer,
|
||||
spawn_app,
|
||||
category_id_by_slug, client, create_product_with_sku, create_product_with_sku_in_category,
|
||||
create_shop, login_admin, make_shop_owner, publish_product, register_customer, spawn_app,
|
||||
};
|
||||
use serial_test::serial;
|
||||
|
||||
@@ -180,6 +180,100 @@ async fn currency_conversion_math() {
|
||||
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]
|
||||
#[serial]
|
||||
async fn suspended_shop_hidden_from_public_catalog() {
|
||||
|
||||
@@ -149,6 +149,18 @@ pub async fn create_product_with_sku(
|
||||
slug: &str,
|
||||
price_minor: i64,
|
||||
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) {
|
||||
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
let res = client()
|
||||
@@ -158,6 +170,7 @@ pub async fn create_product_with_sku(
|
||||
"slug": slug,
|
||||
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
|
||||
"description": {"en": "desc en", "zh": "描述"},
|
||||
"category_id": category_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -183,6 +196,36 @@ pub async fn create_product_with_sku(
|
||||
(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.
|
||||
/// Returns (owner_token, shop_id, product_id, sku_id).
|
||||
pub async fn setup_sellable(
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { topCategories, childCategories } from "~/mock/data";
|
||||
import type { Category } from "@vmall/shared";
|
||||
import { t as pick } from "@vmall/shared";
|
||||
|
||||
const props = withDefaults(defineProps<{ pinned?: boolean }>(), { pinned: false });
|
||||
|
||||
const { locale } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
const open = ref(false);
|
||||
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(() =>
|
||||
topCategories().map((c) => ({
|
||||
cat: c,
|
||||
children: childCategories(c.id).map((cc) => ({ cat: cc, grandchildren: childCategories(cc.id) })),
|
||||
})),
|
||||
(categories.value ?? [])
|
||||
.filter((category) => category.parent_id === null)
|
||||
.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);
|
||||
|
||||
+15
-54
@@ -19,11 +19,6 @@ const L = (en: string, zh: string): LocalizedText => ({ en, zh });
|
||||
|
||||
// ---------- mall-local mock types ----------
|
||||
|
||||
export interface MockBrand {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MockStore {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -55,14 +50,6 @@ export interface MockPromo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface HomeFloor {
|
||||
categoryId: string;
|
||||
name: LocalizedText;
|
||||
advImage: string;
|
||||
advUrl: string;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export interface MockComment {
|
||||
id: string;
|
||||
productId: string;
|
||||
@@ -343,32 +330,10 @@ export function categorySubtreeIds(rootId: string): Set<string> {
|
||||
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 ----------
|
||||
|
||||
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> = {};
|
||||
|
||||
export function brandOf(productId: string): MockBrand | null {
|
||||
const id = PRODUCT_BRAND[productId];
|
||||
return MOCK_BRANDS.find((b) => b.id === id) ?? null;
|
||||
}
|
||||
|
||||
// ---------- stores ----------
|
||||
|
||||
export const MOCK_STORES: MockStore[] = [
|
||||
@@ -547,12 +512,20 @@ export interface MockSearchQuery {
|
||||
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 {
|
||||
return 50 + ((Number(p.id.slice(1)) * 137) % 950);
|
||||
return 50 + ((seedOf(p.id) * 137) % 950);
|
||||
}
|
||||
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 } {
|
||||
@@ -585,7 +558,7 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[];
|
||||
list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b)));
|
||||
break;
|
||||
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 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" },
|
||||
];
|
||||
|
||||
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 ----------
|
||||
|
||||
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 } {
|
||||
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 medium = Math.round(all * 0.06);
|
||||
const bad = all - good - medium;
|
||||
|
||||
@@ -6,7 +6,10 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { Category, Sku } from "@vmall/shared";
|
||||
import type { Category, Product, Sku } from "@vmall/shared";
|
||||
import {
|
||||
MOCK_CATEGORIES,
|
||||
MOCK_COUPONS,
|
||||
MOCK_STORES,
|
||||
commentStats,
|
||||
commentsFor,
|
||||
lowestSku,
|
||||
productDetail,
|
||||
salesOf,
|
||||
storeById,
|
||||
} from "~/mock/data";
|
||||
import { useCartStore } from "~/stores/cart";
|
||||
|
||||
@@ -21,8 +24,47 @@ const routeId = computed(() => {
|
||||
const value = route.params.id;
|
||||
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
||||
});
|
||||
const detail = computed(() => productDetail(routeId.value));
|
||||
const product = computed(() => detail.value?.product ?? null);
|
||||
// One request key for the whole page: the ranking rail must be derived from the
|
||||
// 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 selectedAttributes = reactive<Record<string, string>>({});
|
||||
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 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 path: Category[] = [];
|
||||
let current = product.value?.category_id ? categoryById(product.value.category_id) : undefined;
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { Category, Product } 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 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>
|
||||
|
||||
<template>
|
||||
@@ -32,7 +69,7 @@ const floors = computed(() => homeFloors().filter((f) => f.products.length > 0))
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h2>{{ pick(floor.name, locale) }}</h2>
|
||||
<NuxtLink :to="`/search?category=${floor.categoryId}`" class="more">{{ t("home.viewMore") }} ›</NuxtLink>
|
||||
|
||||
+38
-51
@@ -1,20 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { Category } from "@vmall/shared";
|
||||
import {
|
||||
MOCK_BRANDS,
|
||||
MOCK_CATEGORIES,
|
||||
childCategories,
|
||||
searchMockProducts,
|
||||
topCategories,
|
||||
} from "~/mock/data";
|
||||
import type { Category, Paged, Product } from "@vmall/shared";
|
||||
|
||||
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";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
const queryValue = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : "";
|
||||
@@ -22,33 +18,41 @@ const queryValue = (value: unknown): string => {
|
||||
};
|
||||
|
||||
const state = computed(() => {
|
||||
const sortValue = queryValue(route.query.sort);
|
||||
const orderValue = queryValue(route.query.order);
|
||||
const sort: SortType = ["default", "price", "sales", "comments"].includes(sortValue)
|
||||
? (sortValue as SortType)
|
||||
: "default";
|
||||
const order: OrderType = orderValue === "asc" ? "asc" : "desc";
|
||||
const sort: SortType = queryValue(route.query.sort) === "price" ? "price" : "default";
|
||||
const order: OrderType = queryValue(route.query.order) === "asc" ? "asc" : "desc";
|
||||
const pageValue = Number.parseInt(queryValue(route.query.page), 10);
|
||||
return {
|
||||
q: queryValue(route.query.q),
|
||||
category: queryValue(route.query.category),
|
||||
brand: queryValue(route.query.brand),
|
||||
sort,
|
||||
order,
|
||||
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
|
||||
};
|
||||
});
|
||||
|
||||
const result = computed(() =>
|
||||
searchMockProducts({
|
||||
q: state.value.q,
|
||||
categoryId: state.value.category || undefined,
|
||||
brandId: state.value.brand || undefined,
|
||||
sort: state.value.sort,
|
||||
order: state.value.order,
|
||||
page: state.value.page,
|
||||
perPage: 20,
|
||||
}),
|
||||
const emptyPage: Paged<Product> = { items: [], total: 0, page: 1, per_page: 20 };
|
||||
|
||||
const { data: result } = await useAsyncData(
|
||||
"search-results",
|
||||
() =>
|
||||
$api.listProducts({
|
||||
q: state.value.q || undefined,
|
||||
category_id: state.value.category || undefined,
|
||||
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> => {
|
||||
@@ -56,7 +60,6 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
|
||||
for (const [key, value] of Object.entries({
|
||||
q: state.value.q,
|
||||
category: state.value.category,
|
||||
brand: state.value.brand,
|
||||
sort: state.value.sort,
|
||||
order: state.value.order,
|
||||
page: String(state.value.page),
|
||||
@@ -64,9 +67,9 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
|
||||
})) {
|
||||
if (value) next[key] = value;
|
||||
}
|
||||
if (next.sort === "default") {
|
||||
if (next.sort === "default" || next.sort === undefined) {
|
||||
delete next.sort;
|
||||
if (next.order === "desc") delete next.order;
|
||||
delete next.order;
|
||||
}
|
||||
if (next.page === "1") delete next.page;
|
||||
await router.replace({ query: next });
|
||||
@@ -76,10 +79,6 @@ const chooseCategory = (id?: string): void => {
|
||||
void replaceQuery({ category: id, page: "1" });
|
||||
};
|
||||
|
||||
const chooseBrand = (id?: string): void => {
|
||||
void replaceQuery({ brand: id, page: "1" });
|
||||
};
|
||||
|
||||
const chooseSort = (sort: SortType): void => {
|
||||
const order: OrderType = state.value.sort === sort && sort !== "default"
|
||||
? state.value.order === "asc" ? "desc" : "asc"
|
||||
@@ -87,7 +86,8 @@ const chooseSort = (sort: SortType): void => {
|
||||
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 path: Category[] = [];
|
||||
@@ -101,7 +101,9 @@ const selectedPath = computed(() => {
|
||||
|
||||
const categoryChildren = computed(() => {
|
||||
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(() => [
|
||||
@@ -116,8 +118,6 @@ const breadcrumbItems = computed(() => [
|
||||
const sortOptions = computed(() => [
|
||||
{ key: "default" as SortType, label: t("search.defaultSort") },
|
||||
{ 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>
|
||||
|
||||
@@ -131,7 +131,7 @@ const sortOptions = computed(() => [
|
||||
<div class="filter-options">
|
||||
<button type="button" :class="{ active: !state.category }" @click="chooseCategory()">{{ t("search.all") }}</button>
|
||||
<button
|
||||
v-for="category in topCategories()"
|
||||
v-for="category in topLevelCategories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
:class="{ active: category.id === state.category }"
|
||||
@@ -155,19 +155,6 @@ const sortOptions = computed(() => [
|
||||
>{{ pick(category.name, locale) }}</button>
|
||||
</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">
|
||||
<strong>{{ t("search.sort") }}</strong>
|
||||
<div class="filter-options">
|
||||
|
||||
@@ -1,16 +1,74 @@
|
||||
import { createApi } from "@vmall/shared";
|
||||
import type { ApiClient } from "@vmall/shared";
|
||||
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(() => {
|
||||
const config = useRuntimeConfig();
|
||||
// Mock-first MVP: default to the fixed-data adapter; set NUXT_PUBLIC_MOCK_API=false
|
||||
// (or runtimeConfig.public.mockApi) to talk to the live backend again.
|
||||
const useMock = (config.public.mockApi as boolean | undefined) !== false;
|
||||
const api = useMock
|
||||
? createMockApi()
|
||||
: createApi({
|
||||
baseUrl: config.public.apiBase as string,
|
||||
getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null),
|
||||
});
|
||||
const mock = createMockApi();
|
||||
const live = createApi({
|
||||
baseUrl: config.public.apiBase as string,
|
||||
getToken: () => (import.meta.client ? localStorage.getItem("vmall.token") : null),
|
||||
});
|
||||
|
||||
const configured = config.public.liveDomains as string[] | undefined;
|
||||
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 } };
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user