feat(mall): restore real brand and sales facets

Wave 6, the last substantive piece of the mock-API migration. Wave 1 removed the
brand facet and the sales/comments sorts for want of a model; sales turn out to
be derivable from order_items and a brand model is a table plus a column.

- a `brands` table with a nullable `products.brand_id` and an ordered admin
  replace, mirroring categories and storefront content; a public `GET /api/brands`
  and a `brand_id` filter on the catalog, which the search page's facet uses
- `sold_count` per product, computed from `order_items` joined to orders that
  reached payment, so an abandoned or cancelled checkout cannot count as a sale.
  It is computed per read rather than stored, so it cannot drift from the orders
  that produced it
- `sort=sales` alongside `sort=price`; anything else is still a 400
- merchants can set a product's brand through the existing product upsert
- the review UI is gone: the card's review figure and the product detail page's
  reviews tab, summary and replies. There is no reviews model, and the mall
  attributed invented comments to named shoppers and showed a "good rate". The
  now-unreferenced fabrication helpers went with it (`salesOf`, `commentCountOf`,
  `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail`)

Two bugs found by checking rather than trusting: the fixed-data `listProducts`
had silently ignored `brand_id`, `sort` and `order`, so the restored facet
rendered but filtered nothing until the rollback check caught it; and the seed's
brand lookup read back through the shared `r` variable the product loop
reassigns, working once and then throwing.

Verified: 29 backend tests green including a new brand-and-sales case; all three
frontends build; searching filters by brand (24 to 6) and sorts by sales with
counts matching the API; a product page offers detail and after-sale tabs only,
with a real sold count; the fixed-data rollback filters by brand too.

OpenSpec change: openspec/changes/replace-mock-api-wave-6
This commit is contained in:
2026-09-17 17:35:28 +00:00
parent 65cea42da6
commit ce3e8db5b1
20 changed files with 487 additions and 193 deletions
+16
View File
@@ -0,0 +1,16 @@
-- Product brands: reference data like categories, administered centrally and
-- referenced by products. A brand can be retired without touching its products.
CREATE TABLE brands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name JSONB NOT NULL,
slug TEXT NOT NULL UNIQUE,
position INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE products
ADD COLUMN brand_id UUID REFERENCES brands (id) ON DELETE SET NULL;
CREATE INDEX products_brand_idx ON products (brand_id);
+10
View File
@@ -67,11 +67,21 @@ pub struct Category {
pub position: i32,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Brand {
pub id: Uuid,
pub name: serde_json::Value,
pub slug: String,
pub position: i32,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Product {
pub id: Uuid,
pub shop_id: Uuid,
pub category_id: Option<Uuid>,
pub brand_id: Option<Uuid>,
pub slug: String,
pub name: serde_json::Value,
pub description: serde_json::Value,
+101
View File
@@ -0,0 +1,101 @@
use axum::{
extract::State,
routing::{get, put},
Json, Router,
};
use serde::Deserialize;
use serde_json::Value;
use crate::auth::AuthUser;
use crate::error::{ApiError, ApiResult};
use crate::models::{Brand, UserRole};
use crate::state::AppState;
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route("/brands", get(list_brands))
}
pub fn admin_router(_state: AppState) -> Router<AppState> {
Router::new().route("/admin/brands", put(replace_brands))
}
async fn list_brands(State(state): State<AppState>) -> ApiResult<Json<Vec<Brand>>> {
let brands = sqlx::query_as::<_, Brand>(
"SELECT * FROM brands WHERE active = TRUE ORDER BY position, created_at",
)
.fetch_all(&state.db)
.await?;
Ok(Json(brands))
}
#[derive(Deserialize)]
struct BrandInput {
slug: String,
name: Value,
#[serde(default = "default_active")]
active: bool,
}
fn default_active() -> bool {
true
}
/// Replaces the whole ordered list, as storefront content does: small lists are
/// edited whole, and positions are reindexed from the submitted order.
async fn replace_brands(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<Vec<BrandInput>>,
) -> ApiResult<Json<Vec<Brand>>> {
auth.require(&[UserRole::PlatformAdmin])?;
for brand in &body {
if brand.slug.trim().is_empty()
|| !brand
.slug
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(ApiError::BadRequest(format!(
"invalid brand slug: {}",
brand.slug
)));
}
let ok = ["en", "zh"].iter().all(|code| {
brand
.name
.get(code)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
});
if !ok {
return Err(ApiError::BadRequest(format!(
"brand {} needs non-empty en and zh names",
brand.slug
)));
}
}
let mut tx = state.db.begin().await?;
sqlx::query("DELETE FROM brands").execute(&mut *tx).await?;
for (i, brand) in body.iter().enumerate() {
sqlx::query(
"INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)",
)
.bind(&brand.name)
.bind(brand.slug.trim())
.bind(i as i32)
.bind(brand.active)
.execute(&mut *tx)
.await
.map_err(|e| match e {
sqlx::Error::Database(d) if d.is_unique_violation() => {
ApiError::BadRequest(format!("duplicate brand slug: {}", brand.slug))
}
other => ApiError::from(other),
})?;
}
tx.commit().await?;
list_brands(State(state)).await
}
+56 -4
View File
@@ -16,6 +16,27 @@ pub struct ProductWithSkus {
#[serde(flatten)]
pub product: Product,
pub skus: Vec<Sku>,
/// Units sold across orders that reached payment. Zero when nothing sold.
pub sold_count: i64,
}
/// Orders that count as a sale: an abandoned or cancelled checkout does not.
const PAID_STATUSES: &str =
"('paid', 'fulfilling', 'shipped', 'completed')";
/// Units sold for one product, as a correlated subquery so it can also drive
/// the sales sort without a second round trip.
const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0)
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN skus sk ON sk.id = oi.sku_id
WHERE sk.product_id = p.id
AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))";
#[derive(sqlx::FromRow)]
struct SoldRow {
product_id: Uuid,
sold: i64,
}
pub async fn attach_skus(
@@ -41,10 +62,31 @@ pub async fn attach_skus(
.fetch_all(db)
.await?
};
let sold: Vec<SoldRow> = if ids.is_empty() {
Vec::new()
} else {
sqlx::query_as::<_, SoldRow>(&format!(
"SELECT sk.product_id, COALESCE(SUM(oi.qty), 0)::bigint AS sold
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN skus sk ON sk.id = oi.sku_id
WHERE sk.product_id = ANY($1)
AND o.status IN {PAID_STATUSES}
GROUP BY sk.product_id"
))
.bind(&ids)
.fetch_all(db)
.await?
};
Ok(products
.into_iter()
.map(|p| ProductWithSkus {
skus: skus.iter().filter(|s| s.product_id == p.id).cloned().collect(),
sold_count: sold
.iter()
.find(|row| row.product_id == p.id)
.map(|row| row.sold)
.unwrap_or(0),
product: p,
})
.collect())
@@ -62,6 +104,7 @@ struct ListQuery {
page: Option<i64>,
per_page: Option<i64>,
category_id: Option<Uuid>,
brand_id: Option<Uuid>,
shop_id: Option<Uuid>,
q: Option<String>,
sort: Option<String>,
@@ -72,15 +115,18 @@ struct ListQuery {
enum SortBy {
Newest,
Price,
Sales,
}
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.
/// Only `price` and `sales` are supported sorts; 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("sales") => Ok(SortBy::Sales),
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
}
}
@@ -125,11 +171,13 @@ async fn list_products(
WHERE p.status = 'published' AND s.status = 'active'
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)
AND ($4::uuid IS NULL OR p.brand_id = $4)"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.bind(q.brand_id)
.fetch_one(&state.db)
.await?;
@@ -138,6 +186,8 @@ async fn list_products(
(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"),
(SortBy::Sales, true) => format!("{SOLD_UNITS} ASC, p.created_at DESC"),
(SortBy::Sales, false) => format!("{SOLD_UNITS} DESC, p.created_at DESC"),
};
let products = sqlx::query_as::<_, Product>(&format!(
"{SUBTREE_CTE}
@@ -146,12 +196,14 @@ async fn list_products(
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 ($4::uuid IS NULL OR p.brand_id = $4)
ORDER BY {order_clause}
LIMIT $4 OFFSET $5"
LIMIT $5 OFFSET $6"
))
.bind(q.category_id)
.bind(q.shop_id)
.bind(&pattern)
.bind(q.brand_id)
.bind(per_page)
.bind((page - 1) * per_page)
.fetch_all(&state.db)
+3
View File
@@ -1,5 +1,6 @@
pub mod admin;
pub mod auth;
pub mod brands;
pub mod cart;
pub mod catalog;
pub mod content;
@@ -23,6 +24,8 @@ pub fn api_router(state: AppState) -> Router<AppState> {
.merge(auth::router(state.clone()))
.merge(currency::router(state.clone()))
.merge(catalog::router(state.clone()))
.merge(brands::router(state.clone()))
.merge(brands::admin_router(state.clone()))
.merge(content::router(state.clone()))
.merge(content::admin_router(state.clone()))
.merge(cart::router(state.clone()))
+7 -3
View File
@@ -100,6 +100,7 @@ async fn get_product(
#[derive(Deserialize)]
pub struct ProductBody {
category_id: Option<Uuid>,
brand_id: Option<Uuid>,
slug: String,
name: serde_json::Value,
description: Option<serde_json::Value>,
@@ -132,11 +133,12 @@ async fn create_product(
let shop_id = require_shop(&auth)?;
validate_product_body(&body)?;
let product = sqlx::query_as::<_, Product>(
"INSERT INTO products (shop_id, category_id, slug, name, description, images)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
)
.bind(shop_id)
.bind(body.category_id)
.bind(body.brand_id)
.bind(body.slug.trim())
.bind(&body.name)
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
@@ -164,11 +166,13 @@ async fn update_product(
validate_product_body(&body)?;
let product = sqlx::query_as::<_, Product>(
"UPDATE products
SET category_id = $2, slug = $3, name = $4, description = $5, images = $6, updated_at = now()
SET category_id = $2, brand_id = $3, slug = $4, name = $5, description = $6,
images = $7, updated_at = now()
WHERE id = $1 RETURNING *",
)
.bind(id)
.bind(body.category_id)
.bind(body.brand_id)
.bind(body.slug.trim())
.bind(&body.name)
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
+117 -2
View File
@@ -1,8 +1,9 @@
mod common;
use common::{
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,
add_to_cart, category_id_by_slug, checkout, client, create_product_full, create_product_with_sku,
create_product_with_sku_in_category, create_shop, login_admin, make_shop_owner, pay,
publish_product, register_customer, spawn_app,
};
use serial_test::serial;
@@ -274,6 +275,120 @@ async fn category_subtree_listing_and_price_sort() {
assert_eq!(body["error"]["code"], "BAD_REQUEST");
}
#[tokio::test]
#[serial]
async fn brand_filter_and_real_sales() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
// Brands are admin-managed reference data; replacing the list is idempotent.
let brands = serde_json::json!([
{"slug": "alpha", "name": {"en": "Alpha", "zh": "阿尔法"}},
{"slug": "beta", "name": {"en": "Beta", "zh": "贝塔"}}
]);
let res = client()
.put(app.url("/api/admin/brands"))
.bearer_auth(&admin)
.json(&brands)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200, "{:?}", res.text().await);
let list: serde_json::Value = res.json().await.unwrap();
let alpha = list[0]["id"].as_str().unwrap().to_string();
let beta = list[1]["id"].as_str().unwrap().to_string();
let shop_id = create_shop(&app, &admin, "shop-brands").await;
let owner = make_shop_owner(&app, &admin, &shop_id).await;
let (p_alpha, _) =
create_product_full(&app, &owner, "branded-a", 1000, 10, None, Some(&alpha)).await;
let (p_beta, _) =
create_product_full(&app, &owner, "branded-b", 2000, 10, None, Some(&beta)).await;
publish_product(&app, &owner, &p_alpha).await;
publish_product(&app, &owner, &p_beta).await;
let ids = |body: &serde_json::Value| -> Vec<String> {
body["items"]
.as_array()
.unwrap()
.iter()
.map(|p| p["id"].as_str().unwrap().to_string())
.collect()
};
let sold = |body: &serde_json::Value, id: &str| -> i64 {
body["items"]
.as_array()
.unwrap()
.iter()
.find(|p| p["id"] == id)
.unwrap()["sold_count"]
.as_i64()
.unwrap()
};
// The brand filter narrows within the shop.
let res = client()
.get(app.url(&format!(
"/api/products?shop_id={shop_id}&brand_id={alpha}&per_page=50"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(ids(&body), vec![p_alpha.clone()]);
assert_eq!(body["total"], 1);
// An unpaid order is not a sale.
let sku_alpha: String =
sqlx::query_scalar("SELECT id::text FROM skus WHERE product_id = $1::uuid")
.bind(&p_alpha)
.fetch_one(&app.db)
.await
.unwrap();
let (buyer, _) = register_customer(&app, "brand-buyer").await;
add_to_cart(&app, &buyer, &sku_alpha, 3).await;
let orders = checkout(&app, &buyer).await;
let res = client()
.get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50")))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(sold(&body, &p_alpha), 0, "pending payment must not count as sold");
assert_eq!(sold(&body, &p_beta), 0);
// Paying makes the units count, and the sales sort follows them.
for order in &orders {
pay(&app, &buyer, order["id"].as_str().unwrap()).await;
}
let res = client()
.get(app.url(&format!("/api/products?shop_id={shop_id}&per_page=50")))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(sold(&body, &p_alpha), 3);
let res = client()
.get(app.url(&format!(
"/api/products?shop_id={shop_id}&sort=sales&order=desc&per_page=50"
)))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(ids(&body)[0], p_alpha, "sales sort puts the sold product first");
// Comments still have no model, so that sort stays refused.
let res = client()
.get(app.url("/api/products?sort=comments"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
}
#[tokio::test]
#[serial]
async fn suspended_shop_hidden_from_public_catalog() {
+15 -1
View File
@@ -150,7 +150,7 @@ pub async fn create_product_with_sku(
price_minor: i64,
stock: i32,
) -> (String, String) {
create_product_with_sku_in_category(app, owner_token, slug, price_minor, stock, None).await
create_product_full(app, owner_token, slug, price_minor, stock, None, None).await
}
/// Same, but placed in `category_id` so category filtering can be exercised.
@@ -161,6 +161,19 @@ pub async fn create_product_with_sku_in_category(
price_minor: i64,
stock: i32,
category_id: Option<&str>,
) -> (String, String) {
create_product_full(app, owner_token, slug, price_minor, stock, category_id, None).await
}
/// Same, with a category and a brand so both filters can be exercised.
pub async fn create_product_full(
app: &TestApp,
owner_token: &str,
slug: &str,
price_minor: i64,
stock: i32,
category_id: Option<&str>,
brand_id: Option<&str>,
) -> (String, String) {
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
let res = client()
@@ -171,6 +184,7 @@ pub async fn create_product_with_sku_in_category(
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
"description": {"en": "desc en", "zh": "描述"},
"category_id": category_id,
"brand_id": brand_id,
}))
.send()
.await
+4 -4
View File
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { Product } from "@vmall/shared";
import { t as pick } from "@vmall/shared";
import { commentCountOf, lowestSku, salesOf } from "~/mock/data";
import { lowestSku } from "~/utils/product";
const props = defineProps<{ product: Product }>();
const { locale } = useI18n();
const sku = computed(() => lowestSku(props.product));
const sales = computed(() => salesOf(props.product));
const comments = computed(() => commentCountOf(props.product));
// Reported by the catalog API from paid orders; no reviews figure exists.
const sold = computed(() => props.product.sold_count);
</script>
<template>
@@ -23,7 +23,7 @@ const comments = computed(() => commentCountOf(props.product));
<span v-if="sku" class="price"><PriceText :amount-minor="sku.price_minor" :currency="sku.currency" /></span>
<s v-if="sku" class="market"><PriceText :amount-minor="sku.price_minor + 10000" :currency="sku.currency" /></s>
</p>
<p class="meta">{{ $t("product.salesCount", { n: sales }) }} · {{ $t("product.commentCount", { n: comments }) }}</p>
<p class="meta">{{ $t("product.salesCount", { n: sold }) }}</p>
</div>
</NuxtLink>
</template>
+8 -2
View File
@@ -21,6 +21,7 @@ import type {
import {
BASE_CURRENCY,
MOCK_BANNERS,
MOCK_BRANDS,
MOCK_CATEGORIES,
MOCK_CURRENCIES,
MOCK_PROMOS,
@@ -162,7 +163,10 @@ export function createMockApi(): ApiClient {
searchMockProducts({
q: q.q,
categoryId: q.category_id,
brandId: q.brand_id,
shopId: q.shop_id,
sort: q.sort,
order: q.order,
page: q.page,
perPage: q.per_page,
}),
@@ -175,6 +179,7 @@ export function createMockApi(): ApiClient {
},
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
listBrands: () => Promise.resolve(MOCK_BRANDS),
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
convert: (amountMinor, from, to) =>
Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }),
@@ -375,8 +380,7 @@ export function createMockApi(): ApiClient {
})),
}),
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)),
getShop: (slug) => {
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => {
const store = storeById(slug);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
return Promise.resolve(toShopProfile(store));
@@ -412,6 +416,8 @@ export function createMockApi(): ApiClient {
getContent: () => unsupported(),
replaceContent: () => unsupported(),
setShopProfile: () => unsupported(),
getBrands: () => unsupported(),
replaceBrands: () => unsupported(),
},
};
}
+20 -115
View File
@@ -3,6 +3,7 @@
import type {
Address,
Brand,
Category,
Invoice,
LocalizedText,
@@ -50,18 +51,6 @@ export interface MockPromo {
url: string;
}
export interface MockComment {
id: string;
productId: string;
author: string;
avatar: string;
rating: number; // 1-5
content: LocalizedText;
images: string[];
reply: LocalizedText | null;
createdAt: string;
}
export interface MockCoupon {
id: string;
title: LocalizedText;
@@ -105,21 +94,6 @@ export interface IntegralProduct {
stock: number;
}
export interface ProductDetail {
product: Product;
store: MockStore;
comments: MockComment[];
commentStats: { all: number; good: number; medium: number; bad: number; goodRate: number };
coupons: MockCoupon[];
salesRank: Product[];
}
export interface StoreDetail {
store: MockStore;
products: Product[];
salesRank: Product[];
}
// ---------- currencies ----------
export const MOCK_CURRENCIES = [
@@ -332,7 +306,15 @@ export function categorySubtreeIds(rootId: string): Set<string> {
// ---------- brands ----------
const PRODUCT_BRAND: Record<string, string> = {};
/** Mirrors the seeded `brands` table so the rollback path can still filter. */
export const MOCK_BRANDS: Brand[] = [
{ id: "b1", slug: "aurora", name: L("Aurora", "极光"), position: 0, active: true },
{ id: "b2", slug: "nordwind", name: L("Nordwind", "北风"), position: 1, active: true },
{ id: "b3", slug: "hexon", name: L("Hexon", "赫克森"), position: 2, active: true },
{ id: "b4", slug: "mikado", name: L("Mikado", "御门"), position: 3, active: true },
{ id: "b5", slug: "solace", name: L("Solace", "索莱斯"), position: 4, active: true },
{ id: "b6", slug: "terra", name: L("Terra", "大地"), position: 5, active: true },
];
// ---------- stores ----------
@@ -442,7 +424,6 @@ function buildProducts(): Product[] {
const now = "2026-09-01T00:00:00.000Z";
return PRODUCT_SPECS.map((spec, i) => {
const id = `p${i + 1}`;
PRODUCT_BRAND[id] = spec.brand;
const img = `/mock/product-${spec.n}.svg`;
const skus: Sku[] = [];
const combos = spec.attrs.reduce<string[][]>(
@@ -470,12 +451,16 @@ function buildProducts(): Product[] {
id,
shop_id: spec.store,
category_id: spec.cat,
brand_id: spec.brand,
slug: spec.slug,
name: spec.name,
description: spec.sub,
images: [img, img, img],
status: "published",
created_at: now,
// The fixed-data path has no order data to count, so it reports none
// rather than inventing a figure.
sold_count: 0,
skus,
} satisfies Product;
});
@@ -501,28 +486,19 @@ export interface MockSearchQuery {
categoryId?: string;
brandId?: string;
shopId?: string;
sort?: "default" | "price" | "sales" | "comments";
sort?: "default" | "price" | "sales";
order?: "asc" | "desc";
page?: number;
perPage?: number;
}
// 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".
/** Stable ordering for the fixed-data catalogue; not a claim about sales. */
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 + ((seedOf(p.id) * 137) % 950);
}
export function commentCountOf(p: Product): number {
return 5 + ((seedOf(p.id) * 61) % 240);
}
export function searchMockProducts(query: MockSearchQuery): { items: Product[]; total: number; page: number; per_page: number } {
const page = query.page && query.page > 0 ? query.page : 1;
const perPage = query.perPage && query.perPage > 0 ? query.perPage : 20;
@@ -532,7 +508,7 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[];
const ids = categorySubtreeIds(query.categoryId);
list = list.filter((p) => p.category_id !== null && ids.has(p.category_id));
}
if (query.brandId) list = list.filter((p) => PRODUCT_BRAND[p.id] === query.brandId);
if (query.brandId) list = list.filter((p) => p.brand_id === query.brandId);
const kw = query.q?.trim().toLowerCase();
if (kw) {
list = list.filter((p) =>
@@ -547,10 +523,9 @@ export function searchMockProducts(query: MockSearchQuery): { items: Product[];
list = [...list].sort((a, b) => dir * ((lowestSku(a)?.price_minor ?? 0) - (lowestSku(b)?.price_minor ?? 0)));
break;
case "sales":
list = [...list].sort((a, b) => dir * (salesOf(a) - salesOf(b)));
break;
case "comments":
list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b)));
// The fixed-data catalogue has no orders, so every product reports zero
// sold and this keeps the incoming order rather than inventing one.
list = [...list].sort((a, b) => dir * (a.sold_count - b.sold_count));
break;
default:
list = [...list].sort((a, b) => seedOf(a.id) - seedOf(b.id));
@@ -600,76 +575,6 @@ export const MOCK_COUPONS: MockCoupon[] = [
{ id: "cp3", title: L("$40 off over $499", "满 499 减 40"), amountMinor: 4000, thresholdMinor: 49900, currency: BASE_CURRENCY, expiresAt: "2026-10-31" },
];
export function commentsFor(productId: string): MockComment[] {
const seed = Number(productId.replace(/\D/g, "")) || 1;
const pool: { author: string; text: LocalizedText; rating: number }[] = [
{ author: "A***a", text: L("Great quality, exactly as described. Fast shipping!", "质量很好,和描述一致,发货快!"), rating: 5 },
{ author: "M***e", text: L("Good value for the price. Would buy again.", "性价比不错,会回购。"), rating: 5 },
{ author: "J***n", text: L("Decent, but packaging could be better.", "还行,包装可以更好。"), rating: 4 },
{ author: "S***y", text: L("Average experience overall.", "整体一般。"), rating: 3 },
];
const count = 2 + (seed % 3);
const out: MockComment[] = [];
for (let i = 0; i < count; i++) {
const p = pool[(seed + i) % pool.length];
out.push({
id: `${productId}-cm${i + 1}`,
productId,
author: p.author,
avatar: "/mock/avatar.svg",
rating: p.rating,
content: p.text,
images: [],
reply:
p.rating <= 3
? L("Sorry for the inconvenience, please contact support.", "很抱歉带来不便,请联系在线客服处理。")
: null,
createdAt: `2026-0${(seed % 8) + 1}-1${i}`,
});
}
return out;
}
export function commentStats(productId: string): { all: number; good: number; medium: number; bad: number; goodRate: number } {
// 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;
return { all, good, medium, bad, goodRate: Math.round((good / Math.max(1, all)) * 100) };
}
export function salesRankFor(shopId: string): Product[] {
return MOCK_PRODUCTS.filter((p) => p.shop_id === shopId)
.sort((a, b) => salesOf(b) - salesOf(a))
.slice(0, 5);
}
export function productDetail(idOrSlug: string): ProductDetail | null {
const product = productById(idOrSlug);
if (!product) return null;
const store = storeById(product.shop_id) ?? MOCK_STORES[0];
return {
product,
store,
comments: commentsFor(product.id),
commentStats: commentStats(product.id),
coupons: MOCK_COUPONS,
salesRank: salesRankFor(product.shop_id),
};
}
export function storeDetail(idOrSlug: string): StoreDetail | null {
const store = storeById(idOrSlug);
if (!store) return null;
return {
store,
products: MOCK_PRODUCTS.filter((p) => p.shop_id === store.id),
salesRank: salesRankFor(store.id),
};
}
// ---------- marketing ----------
export const SECKILL_SESSIONS: SeckillSession[] = [
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineNuxtConfig({
// Domains served by the live backend; every other domain stays on the
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
liveDomains: ["catalog", "currency", "content", "shops", "auth", "cart", "orders", "shipments", "invoices"],
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "cart", "orders", "shipments", "invoices"],
appName: "mall",
},
},
+6 -24
View File
@@ -2,7 +2,7 @@
import { t as pick } from "@vmall/shared";
import { ApiError } from "@vmall/shared";
import type { Category, Product, Sku } from "@vmall/shared";
import { MOCK_COUPONS, commentStats, commentsFor, salesOf } from "~/mock/data";
import { MOCK_COUPONS } from "~/mock/data";
import { lowestSku } from "~/utils/product";
import { useCartStore } from "~/stores/cart";
@@ -27,8 +27,8 @@ const { data: pageData } = await useAsyncData(
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.
// Store best sellers, ranking the shop's own products. 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,
@@ -54,15 +54,11 @@ const detail = computed(() => {
product: current,
// Null when the shop has no public profile; the card is then not rendered.
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
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);
@@ -174,9 +170,10 @@ const addCart = async (): Promise<void> => {
}
};
// No reviews tab: there is no reviews capability, and the mall will not present
// invented reviewers and ratings as fact. See the wave-6 design.
const tabs = computed(() => [
{ key: "detail", label: t("product.tabsDetail") },
{ key: "reviews", label: t("product.tabsReviews") },
{ key: "service", label: t("product.tabsAfterSale") },
]);
@@ -242,8 +239,7 @@ const detailImages = computed(() => product.value?.images ?? []);
<span v-if="matchedSku" class="market-label">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span>
</div>
<div class="summary-meta">
<span>{{ t("product.sold", { n: salesOf(detail.product) }) }}</span>
<span>{{ t("product.commentCount", { n: detail.commentStats.all }) }}</span>
<span>{{ t("product.sold", { n: detail.product.sold_count }) }}</span>
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
</div>
@@ -324,20 +320,6 @@ const detailImages = computed(() => product.value?.images ?? []);
<p>{{ pick(detail.product.description, locale) }}</p>
<img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" :src="image" :alt="t('product.detail')" loading="lazy" />
</div>
<div v-else-if="active === 'reviews'" class="reviews-content">
<div class="review-summary">
<strong>{{ t("product.reviewSummary", { rate: detail.commentStats.goodRate, n: detail.commentStats.all }) }}</strong>
<span>{{ t("product.reviewAll") }}</span>
</div>
<article v-for="comment in detail.comments" :key="comment.id" class="review">
<img :src="comment.avatar" :alt="comment.author" loading="lazy" />
<div class="review-body">
<header><strong>{{ comment.author }}</strong><UiRatingStars :value="comment.rating" :size="13" /><time>{{ comment.createdAt }}</time></header>
<p>{{ pick(comment.content, locale) }}</p>
<div v-if="comment.reply" class="reply"><strong>{{ t("product.reply") }}:</strong> {{ pick(comment.reply, locale) }}</div>
</div>
</article>
</div>
<div v-else class="service-content">
<h2>{{ t("product.tabsAfterSale") }}</h2>
<p v-if="detail.store?.after_sale">{{ pick(detail.store.after_sale, locale) }}</p>
+36 -7
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Category, Paged, Product } from "@vmall/shared";
import type { Brand, Category, Paged, Product } from "@vmall/shared";
// 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";
// Sorts the catalog model can answer: price and real sales. Comments still have
// no model, so that facet stays out.
type SortType = "default" | "price" | "sales";
type OrderType = "asc" | "desc";
const route = useRoute();
@@ -17,13 +17,17 @@ const queryValue = (value: unknown): string => {
return typeof value === "string" ? value : "";
};
const parseSort = (raw: string): SortType =>
raw === "price" || raw === "sales" ? raw : "default";
const state = computed(() => {
const sort: SortType = queryValue(route.query.sort) === "price" ? "price" : "default";
const sort = parseSort(queryValue(route.query.sort));
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,
@@ -38,8 +42,9 @@ const { data: result } = await useAsyncData(
$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,
brand_id: state.value.brand || undefined,
sort: state.value.sort === "default" ? undefined : state.value.sort,
order: state.value.sort === "default" ? undefined : state.value.order,
page: state.value.page,
per_page: 20,
}),
@@ -55,11 +60,17 @@ const topLevelCategories = computed(() =>
allCategories.value.filter((category) => category.parent_id === null).sort(byPosition),
);
// Shared with the product page; the fixed-data adapter serves the same list.
const { data: brands } = await useAsyncData("brands", () => $api.listBrands(), {
default: () => [] as Brand[],
});
const replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => {
const next: Record<string, string> = {};
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),
@@ -79,6 +90,10 @@ 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"
@@ -118,6 +133,7 @@ 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") },
]);
</script>
@@ -155,6 +171,19 @@ const sortOptions = computed(() => [
>{{ pick(category.name, locale) }}</button>
</div>
</div>
<div v-if="brands.length" 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 brands"
:key="brand.id"
type="button"
:class="{ active: brand.id === state.brand }"
@click="chooseBrand(brand.id)"
>{{ pick(brand.name, locale) }}</button>
</div>
</div>
<div class="filter-row sort-row">
<strong>{{ t("search.sort") }}</strong>
<div class="filter-options">
+3
View File
@@ -13,6 +13,7 @@ type LiveDomain =
| "currency"
| "content"
| "shops"
| "brands"
| "cart"
| "orders"
| "shipments"
@@ -33,6 +34,7 @@ const LIVE_PICKS = {
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
content: (a: ApiClient) => ({ getHomeContent: a.getHomeContent }),
shops: (a: ApiClient) => ({ listShops: a.listShops, getShop: a.getShop }),
brands: (a: ApiClient) => ({ listBrands: a.listBrands }),
cart: (a: ApiClient) => ({
getCart: a.getCart,
addCartItem: a.addCartItem,
@@ -64,6 +66,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
"currency",
"content",
"shops",
"brands",
"auth",
"cart",
"orders",
+3 -2
View File
@@ -50,8 +50,8 @@ Each is a new backend capability rather than a domain flip.
- [x] **Storefront content** — banners, promos, quick links and floor advert art. Done in `replace-mock-api-wave-4`: four tables seeded from the existing assets, a public `GET /api/content/home`, and an admin read/replace pair.
- [x] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Done in `replace-mock-api-wave-5`: a `shop_profiles` table, `GET /api/shops` + `GET /api/shops/{slug}`, an admin upsert, and the order surfaces now name their shop. Two things went rather than being faked: `distanceKm` (no geo model) and the store home's "best sellers" rail plus its sales/comments sorts (no sales model).
- [ ] **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.
- [x] **Brand model + sales/comments sorts**Done in `replace-mock-api-wave-6` for the two that have a model: a `brands` table with a product column, a public read and an ordered admin replace, so the search facet is back; and `sort=sales` computed from `order_items` over orders that reached payment, with a real `sold_count` on every product payload. **Comments are not done and cannot be**: there is no reviews model, so the review UI went rather than staying as invented reviewers and ratings. Reviews are future work — see the out-of-scope note below.
- [x] Extend the `ORDER BY` whitelist. Nothing more is wanted: `price` and `sales` are the two orderings with a model behind them, and any other value is a 400 by design.
- [ ] *(adjacent, not part of the migration)* Move the session token to a cookie so SSR knows whether anyone is signed in. Today a full page load of a guarded route renders the page and then redirects on the client, which logs a hydration mismatch; it is pre-existing (verified identical before Wave 2) and harmless, but it is the real fix for the `ClientOnly` workarounds in `components/shell/TopBar.vue` and `pages/user.vue`.
---
@@ -62,6 +62,7 @@ These have no API contract and no backend model. Leaving them on `~/mock/data` i
- **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.
- **Reviews** — the mall no longer presents any: the card's review count and the product detail page's reviews tab, summary and replies were removed in Wave 6 rather than kept as invented reviewers and ratings. Writing, moderating and displaying reviews is a feature with its own lifecycle, not a migration.
- **seckill / collective / integral marketing pages** — display-only mock content.
## Decisions already made (do not relitigate)
@@ -2,34 +2,35 @@
## 1. Schema and seed
- [ ] 1.1 Add a migration creating `brands` (bilingual name, slug, position, active) and `products.brand_id` nullable with `ON DELETE SET NULL`; verify the column and table exist after `cargo run -p vmall-api`
- [ ] 1.2 Seed the six demo brands and assign them to the demo products from `scripts/seed-demo.mjs`; verify a re-run is idempotent and `GET /api/brands` returns them in order
- [x] 1.1 Add a migration creating `brands` (bilingual name, slug, position, active) and `products.brand_id` nullable with `ON DELETE SET NULL`; verified the table and column exist after `cargo run -p vmall-api`
- [x] 1.2 Seed the six demo brands and assign them to the demo products; verified a re-run is idempotent and all 24 products end up with a brand. The seed re-applies each product body on the 409 path so a re-run converges the assignment, and the brand list is captured in a local const — reading it back through the shared `r` variable worked once and then broke, because the product loop reassigns it
## 2. Shared contract
- [ ] 2.1 Add `Brand` and `BrandInput` to `packages/shared/src/types.ts`, add the optional `brand_id` to the product payload and `ProductUpsertBody`, and add `brand_id` to `ProductListQuery` plus `"sales"` to its `sort`; verify all three frontends build
- [ ] 2.2 Add `listBrands()`, `admin.getBrands()` / `admin.replaceBrands(list)` to the `ApiClient` and `createApi`, and give the fixed-data adapter matching implementations so the rollback path still serves a brand list and a brand filter; register a `brands` domain in the per-domain switch
- [x] 2.1 Add `Brand` and `BrandInput`, the optional `brand_id` on the product payload and `ProductUpsertBody`, `brand_id` on `ProductListQuery`, `"sales"` on its `sort`, and `sold_count` on `Product`; verified all three frontends build
- [x] 2.2 Add `listBrands()`, `admin.getBrands()` / `admin.replaceBrands(list)` and fixed-data implementations, and register a `brands` domain in the per-domain switch. Also taught the fixed-data `listProducts` to pass `brand_id`, `sort` and `order` through — it had silently ignored all three, so the restored facet rendered but filtered nothing until the rollback check caught it
## 3. Catalog: brands
- [ ] 3.1 Add `GET /api/brands` (public, position-ordered) and `PUT /api/admin/brands` (admin, transactional replace, validating slug and non-empty `en`/`zh`); verify a rejected list changes nothing and a non-admin is refused
- [ ] 3.2 Add the `brand_id` filter to `listProducts`, composing with the category, shop and keyword filters, and return `brand_id` on the product payload; verify a brand plus category filter narrows correctly
- [ ] 3.3 Accept `brand_id` in the shop product upsert so a merchant, and the seed, can set it; verify a merchant can set and clear it on their own product only
- [x] 3.1 Add public `GET /api/brands` and admin `PUT /api/admin/brands` (transactional replace, slug and bilingual-name validation); verified a non-admin is refused and a duplicate slug is a 400
- [x] 3.2 Add the `brand_id` filter to `listProducts`, composing with the other filters, and return `brand_id` on the payload; verified a brand filter narrows 24 products to 6 and composes with the shop filter
- [x] 3.3 Accept `brand_id` in the shop product upsert, on both create and update, so a merchant — and the seed — can set or clear it
## 4. Catalog: real sales
- [ ] 4.1 Compute `sold_count` per product from `order_items` joined to orders that reached payment (`paid`, `fulfilling`, `shipped`, `completed`), exposed on the product list and detail payloads; verify a product with no paid orders reports zero
- [ ] 4.2 Accept `sort=sales` with `order=asc|desc`, keeping the 400 for any other sort value; verify descending order matches the reported counts and that an unpaid order does not move a product
- [ ] 4.3 Extend `apps/api/tests/catalog.rs` with brand filtering, the sales order and the unpaid-order exclusion; verify `cargo test -p vmall-api` is green and repeatable
- [x] 4.1 Compute `sold_count` per product from `order_items` joined to orders in `paid`, `fulfilling`, `shipped` or `completed`, exposed on the list and detail payloads; verified a product with no paid orders reports zero
- [x] 4.2 Accept `sort=sales` with `order`, keeping the 400 for any other value; verified the order matches the reported counts and that a `pending_payment` order moves nothing
- [x] 4.3 Extend `apps/api/tests/catalog.rs` with `brand_filter_and_real_sales`, covering the brand filter, the unpaid exclusion, the paid count and the sales order; verified `cargo test -p vmall-api` is green at 29 tests and repeatable
## 5. Mall surfaces
- [ ] 5.1 `pages/search.vue`: restore the brand facet from `listBrands()` and add the sales sort; verify the facet renders only when brands exist and that both filters compose
- [ ] 5.2 `components/ui/ProductCard.vue`: show the product's real `sold_count` and drop the review figure; verify no fabricated number remains
- [ ] 5.3 `pages/goods/[id].vue`: show the real sold count, and remove the reviews tab, its summary and reply blocks along with their mock imports; verify the page renders detail and after-sale tabs only
- [x] 5.1 `pages/search.vue`: restored the brand facet from `listBrands()` and the sales sort; verified the facet renders only when brands exist and that brand plus category compose
- [x] 5.2 `components/ui/ProductCard.vue`: shows the product's real `sold_count` and no review figure; verified the card reads "N sold" only
- [x] 5.3 `pages/goods/[id].vue`: shows the real sold count, and the reviews tab, its summary and reply blocks are gone; verified the page offers only the detail and after-sale tabs, keeps the store card, and still shows the shop's after-sale copy
- [x] 5.4 Removed the now-unreferenced fabrication cluster from `apps/mall/mock/data.ts``salesOf`, `commentCountOf`, `commentsFor`, `commentStats`, `salesRankFor`, `productDetail`, `storeDetail` and their types. Nothing imported them once the review UI went, and the fixed-data sales sort now orders by `sold_count`, which is zero there rather than an invented number
## 6. Verification
- [ ] 6.1 Run all three frontend builds and `cargo test -p vmall-api`; verify green. Treat the browser check as the real gate, since `nuxt build` does not typecheck (recorded in `docs/TBD-migrate-wave.md`)
- [ ] 6.2 With the backend seeded, verify in a browser: the search page filters by brand and sorts by sales, a product card shows a real sold count, and the product page has no reviews while keeping its after-sale copy
- [ ] 6.3 Verify the rollback: with every domain on fixed data and the backend stopped, the search facet and sorts still work from the fixed-data brand list
- [x] 6.1 All three frontends build and `cargo test -p vmall-api` is green at 29 tests. The browser check remains the real gate, since `nuxt build` does not typecheck
- [x] 6.2 Verified live in a browser: the search page shows the brand facet and a Sales sort, filtering by a brand narrows 24 products to 6, descending sales puts the sold products first with counts matching the API, product cards show only a real sold count, and the product page has no reviews while keeping its after-sale copy and store card
- [x] 6.3 Verified the rollback: with every domain on fixed data and the backend stopped, the facet renders the fixed-data brands and filtering by one narrows 24 products to 2
+11 -2
View File
@@ -1,6 +1,8 @@
import type {
Address,
AuthTokens,
Brand,
BrandInput,
Cart,
Category,
ContentInputByKind,
@@ -44,15 +46,17 @@ export interface ProductListQuery {
page?: number;
per_page?: number;
category_id?: string;
brand_id?: string;
q?: string;
shop_id?: string;
/** `price` orders by each product's lowest active SKU price. */
sort?: "price";
/** `price` orders by lowest active SKU price; `sales` by units sold. */
sort?: "price" | "sales";
order?: "asc" | "desc";
}
export interface ProductUpsertBody {
category_id?: string | null;
brand_id?: string | null;
slug: string;
name: LocalizedText;
description?: LocalizedText;
@@ -145,6 +149,7 @@ export interface ApiClient {
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
getProduct(idOrSlug: string): Promise<Product>;
listCategories(): Promise<Category[]>;
listBrands(): Promise<Brand[]>;
listCurrencies(): Promise<Currency[]>;
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
getCart(): Promise<Cart>;
@@ -223,6 +228,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
listCategories: () => r("GET", "/categories"),
listBrands: () => r("GET", "/brands"),
listCurrencies: () => r("GET", "/currencies"),
convert: (amountMinor, from, to) =>
r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }),
@@ -282,6 +288,9 @@ export function createApi(opts: ApiClientOptions): ApiClient {
getContent: () => r("GET", "/admin/content"),
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
setShopProfile: (id, body) => r("PUT", `/admin/shops/${id}/profile`, body),
/** Replaces the whole ordered brand list. */
getBrands: () => r("GET", "/brands"),
replaceBrands: (items) => r("PUT", "/admin/brands", items),
},
};
}
+17
View File
@@ -50,15 +50,32 @@ export interface Product {
id: string;
shop_id: string;
category_id: string | null;
brand_id: string | null;
slug: string;
name: LocalizedText;
description: LocalizedText;
images: string[];
status: ProductStatus;
created_at: string;
/** Units sold across paid orders; every product payload carries it. */
sold_count: number;
skus?: Sku[];
}
export interface Brand {
id: string;
name: LocalizedText;
slug: string;
position: number;
active: boolean;
}
export interface BrandInput {
slug: string;
name: LocalizedText;
active?: boolean;
}
export interface Sku {
id: string;
product_id: string;
+36 -10
View File
@@ -222,24 +222,50 @@ const products = [
{ 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" },
];
// 5b. brands (admin-managed reference data), then assign one per product
const BRANDS = [
{ slug: "aurora", name: { en: "Aurora", zh: "极光" } },
{ slug: "nordwind", name: { en: "Nordwind", zh: "北风" } },
{ slug: "hexon", name: { en: "Hexon", zh: "赫克森" } },
{ slug: "mikado", name: { en: "Mikado", zh: "御门" } },
{ slug: "solace", name: { en: "Solace", zh: "索莱斯" } },
{ slug: "terra", name: { en: "Terra", zh: "大地" } },
];
r = await call("PUT", "/admin/brands", { token: admin, body: BRANDS });
if (r.status !== 200) fail("replace brands", r);
// Captured, not read back through `r`: the product loop reassigns `r`.
const brandList = r.data;
const brandBy = (slug) => brandList.find((b) => b.slug === slug)?.id ?? null;
const SHOP_BRAND = {
"demo-store": "solace",
"aurora-digital": "aurora",
"nordwind-home": "nordwind",
"terra-grocery": "terra",
};
// A couple of deliberate exceptions so more than four brands are in use.
const PRODUCT_BRAND = { "mechanical-keyboard": "hexon", "terra-leather-tote": "mikado" };
console.log(`brands ready: ${r.data.length}`);
for (const p of products) {
const token = ownerTokens.get(p.shop);
r = await call("POST", "/shop/products", {
token,
body: {
slug: p.slug,
name: p.name,
description: p.description,
category_id: catBy(p.category),
images: [`https://picsum.photos/seed/${p.img}/600/600`],
},
});
const body = {
slug: p.slug,
name: p.name,
description: p.description,
category_id: catBy(p.category),
brand_id: brandBy(PRODUCT_BRAND[p.slug] ?? SHOP_BRAND[p.shop]),
images: [`https://picsum.photos/seed/${p.img}/600/600`],
};
r = await call("POST", "/shop/products", { token, body });
let id;
if (r.status === 201) id = r.data.id;
else if (r.status === 409) {
const list = await call("GET", "/shop/products?per_page=100", { token });
id = list.data.items.find((x) => x.slug === p.slug)?.id;
if (!id) fail(`lookup product ${p.slug}`, r);
// Re-apply the body so a re-run converges the brand assignment too.
r = await call("PUT", `/shop/products/${id}`, { token, body });
if (r.status !== 200) fail(`update product ${p.slug}`, r);
} else fail(`create product ${p.slug}`, r);
r = await call("POST", `/shop/products/${id}/skus`, {