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:
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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[] = [
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`, {
|
||||
|
||||
Reference in New Issue
Block a user