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,
|
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)]
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
pub struct Product {
|
pub struct Product {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub shop_id: Uuid,
|
pub shop_id: Uuid,
|
||||||
pub category_id: Option<Uuid>,
|
pub category_id: Option<Uuid>,
|
||||||
|
pub brand_id: Option<Uuid>,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
pub name: serde_json::Value,
|
pub name: serde_json::Value,
|
||||||
pub description: 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)]
|
#[serde(flatten)]
|
||||||
pub product: Product,
|
pub product: Product,
|
||||||
pub skus: Vec<Sku>,
|
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(
|
pub async fn attach_skus(
|
||||||
@@ -41,10 +62,31 @@ pub async fn attach_skus(
|
|||||||
.fetch_all(db)
|
.fetch_all(db)
|
||||||
.await?
|
.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
|
Ok(products
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|p| ProductWithSkus {
|
.map(|p| ProductWithSkus {
|
||||||
skus: skus.iter().filter(|s| s.product_id == p.id).cloned().collect(),
|
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,
|
product: p,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -62,6 +104,7 @@ struct ListQuery {
|
|||||||
page: Option<i64>,
|
page: Option<i64>,
|
||||||
per_page: Option<i64>,
|
per_page: Option<i64>,
|
||||||
category_id: Option<Uuid>,
|
category_id: Option<Uuid>,
|
||||||
|
brand_id: Option<Uuid>,
|
||||||
shop_id: Option<Uuid>,
|
shop_id: Option<Uuid>,
|
||||||
q: Option<String>,
|
q: Option<String>,
|
||||||
sort: Option<String>,
|
sort: Option<String>,
|
||||||
@@ -72,15 +115,18 @@ struct ListQuery {
|
|||||||
enum SortBy {
|
enum SortBy {
|
||||||
Newest,
|
Newest,
|
||||||
Price,
|
Price,
|
||||||
|
Sales,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListQuery {
|
impl ListQuery {
|
||||||
/// Only `price` is a supported sort; anything else is a client error rather
|
/// Only `price` and `sales` are supported sorts; anything else is a client
|
||||||
/// than being silently ignored. An absent `sort` keeps newest-first.
|
/// error rather than being silently ignored. An absent `sort` keeps
|
||||||
|
/// newest-first.
|
||||||
fn sort_by(&self) -> ApiResult<SortBy> {
|
fn sort_by(&self) -> ApiResult<SortBy> {
|
||||||
match self.sort.as_deref() {
|
match self.sort.as_deref() {
|
||||||
None => Ok(SortBy::Newest),
|
None => Ok(SortBy::Newest),
|
||||||
Some("price") => Ok(SortBy::Price),
|
Some("price") => Ok(SortBy::Price),
|
||||||
|
Some("sales") => Ok(SortBy::Sales),
|
||||||
Some(other) => Err(ApiError::BadRequest(format!("unsupported sort: {other}"))),
|
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'
|
WHERE p.status = 'published' AND s.status = 'active'
|
||||||
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
|
AND ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
|
||||||
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
||||||
AND ($3::text IS NULL OR p.name::text ILIKE $3)"
|
AND ($3::text IS NULL OR p.name::text ILIKE $3)
|
||||||
|
AND ($4::uuid IS NULL OR p.brand_id = $4)"
|
||||||
))
|
))
|
||||||
.bind(q.category_id)
|
.bind(q.category_id)
|
||||||
.bind(q.shop_id)
|
.bind(q.shop_id)
|
||||||
.bind(&pattern)
|
.bind(&pattern)
|
||||||
|
.bind(q.brand_id)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -138,6 +186,8 @@ async fn list_products(
|
|||||||
(SortBy::Newest, _) => "p.created_at DESC".to_string(),
|
(SortBy::Newest, _) => "p.created_at DESC".to_string(),
|
||||||
(SortBy::Price, true) => format!("{MIN_PRICE} ASC NULLS LAST, p.created_at DESC"),
|
(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::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!(
|
let products = sqlx::query_as::<_, Product>(&format!(
|
||||||
"{SUBTREE_CTE}
|
"{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 ($1::uuid IS NULL OR p.category_id IN (SELECT id FROM subtree))
|
||||||
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
AND ($2::uuid IS NULL OR p.shop_id = $2)
|
||||||
AND ($3::text IS NULL OR p.name::text ILIKE $3)
|
AND ($3::text IS NULL OR p.name::text ILIKE $3)
|
||||||
|
AND ($4::uuid IS NULL OR p.brand_id = $4)
|
||||||
ORDER BY {order_clause}
|
ORDER BY {order_clause}
|
||||||
LIMIT $4 OFFSET $5"
|
LIMIT $5 OFFSET $6"
|
||||||
))
|
))
|
||||||
.bind(q.category_id)
|
.bind(q.category_id)
|
||||||
.bind(q.shop_id)
|
.bind(q.shop_id)
|
||||||
.bind(&pattern)
|
.bind(&pattern)
|
||||||
|
.bind(q.brand_id)
|
||||||
.bind(per_page)
|
.bind(per_page)
|
||||||
.bind((page - 1) * per_page)
|
.bind((page - 1) * per_page)
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod brands;
|
||||||
pub mod cart;
|
pub mod cart;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
@@ -23,6 +24,8 @@ pub fn api_router(state: AppState) -> Router<AppState> {
|
|||||||
.merge(auth::router(state.clone()))
|
.merge(auth::router(state.clone()))
|
||||||
.merge(currency::router(state.clone()))
|
.merge(currency::router(state.clone()))
|
||||||
.merge(catalog::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::router(state.clone()))
|
||||||
.merge(content::admin_router(state.clone()))
|
.merge(content::admin_router(state.clone()))
|
||||||
.merge(cart::router(state.clone()))
|
.merge(cart::router(state.clone()))
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ async fn get_product(
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ProductBody {
|
pub struct ProductBody {
|
||||||
category_id: Option<Uuid>,
|
category_id: Option<Uuid>,
|
||||||
|
brand_id: Option<Uuid>,
|
||||||
slug: String,
|
slug: String,
|
||||||
name: serde_json::Value,
|
name: serde_json::Value,
|
||||||
description: Option<serde_json::Value>,
|
description: Option<serde_json::Value>,
|
||||||
@@ -132,11 +133,12 @@ async fn create_product(
|
|||||||
let shop_id = require_shop(&auth)?;
|
let shop_id = require_shop(&auth)?;
|
||||||
validate_product_body(&body)?;
|
validate_product_body(&body)?;
|
||||||
let product = sqlx::query_as::<_, Product>(
|
let product = sqlx::query_as::<_, Product>(
|
||||||
"INSERT INTO products (shop_id, category_id, slug, name, description, images)
|
"INSERT INTO products (shop_id, category_id, brand_id, slug, name, description, images)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *",
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(shop_id)
|
.bind(shop_id)
|
||||||
.bind(body.category_id)
|
.bind(body.category_id)
|
||||||
|
.bind(body.brand_id)
|
||||||
.bind(body.slug.trim())
|
.bind(body.slug.trim())
|
||||||
.bind(&body.name)
|
.bind(&body.name)
|
||||||
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
||||||
@@ -164,11 +166,13 @@ async fn update_product(
|
|||||||
validate_product_body(&body)?;
|
validate_product_body(&body)?;
|
||||||
let product = sqlx::query_as::<_, Product>(
|
let product = sqlx::query_as::<_, Product>(
|
||||||
"UPDATE products
|
"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 *",
|
WHERE id = $1 RETURNING *",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(body.category_id)
|
.bind(body.category_id)
|
||||||
|
.bind(body.brand_id)
|
||||||
.bind(body.slug.trim())
|
.bind(body.slug.trim())
|
||||||
.bind(&body.name)
|
.bind(&body.name)
|
||||||
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
.bind(body.description.unwrap_or_else(|| serde_json::json!({})))
|
||||||
|
|||||||
+117
-2
@@ -1,8 +1,9 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
category_id_by_slug, client, create_product_with_sku, create_product_with_sku_in_category,
|
add_to_cart, category_id_by_slug, checkout, client, create_product_full, create_product_with_sku,
|
||||||
create_shop, login_admin, make_shop_owner, publish_product, register_customer, spawn_app,
|
create_product_with_sku_in_category, create_shop, login_admin, make_shop_owner, pay,
|
||||||
|
publish_product, register_customer, spawn_app,
|
||||||
};
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
@@ -274,6 +275,120 @@ async fn category_subtree_listing_and_price_sort() {
|
|||||||
assert_eq!(body["error"]["code"], "BAD_REQUEST");
|
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]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn suspended_shop_hidden_from_public_catalog() {
|
async fn suspended_shop_hidden_from_public_catalog() {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ pub async fn create_product_with_sku(
|
|||||||
price_minor: i64,
|
price_minor: i64,
|
||||||
stock: i32,
|
stock: i32,
|
||||||
) -> (String, String) {
|
) -> (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.
|
/// 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,
|
price_minor: i64,
|
||||||
stock: i32,
|
stock: i32,
|
||||||
category_id: Option<&str>,
|
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) {
|
) -> (String, String) {
|
||||||
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
let slug = format!("{slug}-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||||
let res = client()
|
let res = client()
|
||||||
@@ -171,6 +184,7 @@ pub async fn create_product_with_sku_in_category(
|
|||||||
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
|
"name": {"en": format!("Product {slug}"), "zh": format!("商品 {slug}")},
|
||||||
"description": {"en": "desc en", "zh": "描述"},
|
"description": {"en": "desc en", "zh": "描述"},
|
||||||
"category_id": category_id,
|
"category_id": category_id,
|
||||||
|
"brand_id": brand_id,
|
||||||
}))
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Product } from "@vmall/shared";
|
import type { Product } from "@vmall/shared";
|
||||||
import { t as pick } 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 props = defineProps<{ product: Product }>();
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
|
|
||||||
const sku = computed(() => lowestSku(props.product));
|
const sku = computed(() => lowestSku(props.product));
|
||||||
const sales = computed(() => salesOf(props.product));
|
// Reported by the catalog API from paid orders; no reviews figure exists.
|
||||||
const comments = computed(() => commentCountOf(props.product));
|
const sold = computed(() => props.product.sold_count);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<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>
|
<s v-if="sku" class="market"><PriceText :amount-minor="sku.price_minor + 10000" :currency="sku.currency" /></s>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
BASE_CURRENCY,
|
BASE_CURRENCY,
|
||||||
MOCK_BANNERS,
|
MOCK_BANNERS,
|
||||||
|
MOCK_BRANDS,
|
||||||
MOCK_CATEGORIES,
|
MOCK_CATEGORIES,
|
||||||
MOCK_CURRENCIES,
|
MOCK_CURRENCIES,
|
||||||
MOCK_PROMOS,
|
MOCK_PROMOS,
|
||||||
@@ -162,7 +163,10 @@ export function createMockApi(): ApiClient {
|
|||||||
searchMockProducts({
|
searchMockProducts({
|
||||||
q: q.q,
|
q: q.q,
|
||||||
categoryId: q.category_id,
|
categoryId: q.category_id,
|
||||||
|
brandId: q.brand_id,
|
||||||
shopId: q.shop_id,
|
shopId: q.shop_id,
|
||||||
|
sort: q.sort,
|
||||||
|
order: q.order,
|
||||||
page: q.page,
|
page: q.page,
|
||||||
perPage: q.per_page,
|
perPage: q.per_page,
|
||||||
}),
|
}),
|
||||||
@@ -175,6 +179,7 @@ export function createMockApi(): ApiClient {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
|
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
|
||||||
|
listBrands: () => Promise.resolve(MOCK_BRANDS),
|
||||||
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
|
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
|
||||||
convert: (amountMinor, from, to) =>
|
convert: (amountMinor, from, to) =>
|
||||||
Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: 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)),
|
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => {
|
||||||
getShop: (slug) => {
|
|
||||||
const store = storeById(slug);
|
const store = storeById(slug);
|
||||||
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
|
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
|
||||||
return Promise.resolve(toShopProfile(store));
|
return Promise.resolve(toShopProfile(store));
|
||||||
@@ -412,6 +416,8 @@ export function createMockApi(): ApiClient {
|
|||||||
getContent: () => unsupported(),
|
getContent: () => unsupported(),
|
||||||
replaceContent: () => unsupported(),
|
replaceContent: () => unsupported(),
|
||||||
setShopProfile: () => unsupported(),
|
setShopProfile: () => unsupported(),
|
||||||
|
getBrands: () => unsupported(),
|
||||||
|
replaceBrands: () => unsupported(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-115
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
Address,
|
Address,
|
||||||
|
Brand,
|
||||||
Category,
|
Category,
|
||||||
Invoice,
|
Invoice,
|
||||||
LocalizedText,
|
LocalizedText,
|
||||||
@@ -50,18 +51,6 @@ export interface MockPromo {
|
|||||||
url: string;
|
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 {
|
export interface MockCoupon {
|
||||||
id: string;
|
id: string;
|
||||||
title: LocalizedText;
|
title: LocalizedText;
|
||||||
@@ -105,21 +94,6 @@ export interface IntegralProduct {
|
|||||||
stock: number;
|
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 ----------
|
// ---------- currencies ----------
|
||||||
|
|
||||||
export const MOCK_CURRENCIES = [
|
export const MOCK_CURRENCIES = [
|
||||||
@@ -332,7 +306,15 @@ export function categorySubtreeIds(rootId: string): Set<string> {
|
|||||||
|
|
||||||
// ---------- brands ----------
|
// ---------- 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 ----------
|
// ---------- stores ----------
|
||||||
|
|
||||||
@@ -442,7 +424,6 @@ function buildProducts(): Product[] {
|
|||||||
const now = "2026-09-01T00:00:00.000Z";
|
const now = "2026-09-01T00:00:00.000Z";
|
||||||
return PRODUCT_SPECS.map((spec, i) => {
|
return PRODUCT_SPECS.map((spec, i) => {
|
||||||
const id = `p${i + 1}`;
|
const id = `p${i + 1}`;
|
||||||
PRODUCT_BRAND[id] = spec.brand;
|
|
||||||
const img = `/mock/product-${spec.n}.svg`;
|
const img = `/mock/product-${spec.n}.svg`;
|
||||||
const skus: Sku[] = [];
|
const skus: Sku[] = [];
|
||||||
const combos = spec.attrs.reduce<string[][]>(
|
const combos = spec.attrs.reduce<string[][]>(
|
||||||
@@ -470,12 +451,16 @@ function buildProducts(): Product[] {
|
|||||||
id,
|
id,
|
||||||
shop_id: spec.store,
|
shop_id: spec.store,
|
||||||
category_id: spec.cat,
|
category_id: spec.cat,
|
||||||
|
brand_id: spec.brand,
|
||||||
slug: spec.slug,
|
slug: spec.slug,
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
description: spec.sub,
|
description: spec.sub,
|
||||||
images: [img, img, img],
|
images: [img, img, img],
|
||||||
status: "published",
|
status: "published",
|
||||||
created_at: now,
|
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,
|
skus,
|
||||||
} satisfies Product;
|
} satisfies Product;
|
||||||
});
|
});
|
||||||
@@ -501,28 +486,19 @@ export interface MockSearchQuery {
|
|||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
brandId?: string;
|
brandId?: string;
|
||||||
shopId?: string;
|
shopId?: string;
|
||||||
sort?: "default" | "price" | "sales" | "comments";
|
sort?: "default" | "price" | "sales";
|
||||||
order?: "asc" | "desc";
|
order?: "asc" | "desc";
|
||||||
page?: number;
|
page?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic pseudo stats so sort orders are stable. Seeded by hashing the
|
/** Stable ordering for the fixed-data catalogue; not a claim about sales. */
|
||||||
// id, not by parsing digits out of it: mock ids are "p1" but live ids are UUIDs,
|
|
||||||
// and `Number("f8a1...")` is NaN, which rendered as "NaN sold".
|
|
||||||
function seedOf(id: string): number {
|
function seedOf(id: string): number {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
|
for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) % 100000;
|
||||||
return hash;
|
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 } {
|
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 page = query.page && query.page > 0 ? query.page : 1;
|
||||||
const perPage = query.perPage && query.perPage > 0 ? query.perPage : 20;
|
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);
|
const ids = categorySubtreeIds(query.categoryId);
|
||||||
list = list.filter((p) => p.category_id !== null && ids.has(p.category_id));
|
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();
|
const kw = query.q?.trim().toLowerCase();
|
||||||
if (kw) {
|
if (kw) {
|
||||||
list = list.filter((p) =>
|
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)));
|
list = [...list].sort((a, b) => dir * ((lowestSku(a)?.price_minor ?? 0) - (lowestSku(b)?.price_minor ?? 0)));
|
||||||
break;
|
break;
|
||||||
case "sales":
|
case "sales":
|
||||||
list = [...list].sort((a, b) => dir * (salesOf(a) - salesOf(b)));
|
// The fixed-data catalogue has no orders, so every product reports zero
|
||||||
break;
|
// sold and this keeps the incoming order rather than inventing one.
|
||||||
case "comments":
|
list = [...list].sort((a, b) => dir * (a.sold_count - b.sold_count));
|
||||||
list = [...list].sort((a, b) => dir * (commentCountOf(a) - commentCountOf(b)));
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
list = [...list].sort((a, b) => seedOf(a.id) - seedOf(b.id));
|
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" },
|
{ 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 ----------
|
// ---------- marketing ----------
|
||||||
|
|
||||||
export const SECKILL_SESSIONS: SeckillSession[] = [
|
export const SECKILL_SESSIONS: SeckillSession[] = [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineNuxtConfig({
|
|||||||
// Domains served by the live backend; every other domain stays on the
|
// Domains served by the live backend; every other domain stays on the
|
||||||
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
|
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
|
||||||
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
|
// 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",
|
appName: "mall",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import { ApiError } from "@vmall/shared";
|
import { ApiError } from "@vmall/shared";
|
||||||
import type { Category, Product, Sku } 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 { lowestSku } from "~/utils/product";
|
||||||
import { useCartStore } from "~/stores/cart";
|
import { useCartStore } from "~/stores/cart";
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ const { data: pageData } = await useAsyncData(
|
|||||||
const current = await $api.getProduct(routeId.value);
|
const current = await $api.getProduct(routeId.value);
|
||||||
// The rail uses live catalogue products so its links resolve; the store
|
// The rail uses live catalogue products so its links resolve; the store
|
||||||
// card, comments and coupons stay local display-only content (non-goals).
|
// card, comments and coupons stay local display-only content (non-goals).
|
||||||
// Store best sellers, mirroring the mock's salesRankFor(shopId). Ranking by
|
// Store best sellers, ranking the shop's own products. Ranking by category
|
||||||
// category would empty the rail for any product alone in its leaf category.
|
// would empty the rail for any product alone in its leaf category.
|
||||||
const siblings = await $api.listProducts({
|
const siblings = await $api.listProducts({
|
||||||
shop_id: current.shop_id,
|
shop_id: current.shop_id,
|
||||||
per_page: 6,
|
per_page: 6,
|
||||||
@@ -54,15 +54,11 @@ const detail = computed(() => {
|
|||||||
product: current,
|
product: current,
|
||||||
// Null when the shop has no public profile; the card is then not rendered.
|
// 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,
|
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
|
||||||
comments: commentsFor(current.id),
|
|
||||||
commentStats: commentStats(current.id),
|
|
||||||
coupons: MOCK_COUPONS,
|
coupons: MOCK_COUPONS,
|
||||||
salesRank: related.value,
|
salesRank: related.value,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Display-only sales figure; `salesOf` hashes the id so it is safe for UUIDs.
|
|
||||||
|
|
||||||
const store = computed(() => detail.value?.store ?? null);
|
const store = computed(() => detail.value?.store ?? null);
|
||||||
const selectedAttributes = reactive<Record<string, string>>({});
|
const selectedAttributes = reactive<Record<string, string>>({});
|
||||||
const quantity = ref(1);
|
const quantity = ref(1);
|
||||||
@@ -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(() => [
|
const tabs = computed(() => [
|
||||||
{ key: "detail", label: t("product.tabsDetail") },
|
{ key: "detail", label: t("product.tabsDetail") },
|
||||||
{ key: "reviews", label: t("product.tabsReviews") },
|
|
||||||
{ key: "service", label: t("product.tabsAfterSale") },
|
{ 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>
|
<span v-if="matchedSku" class="market-label">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-meta">
|
<div class="summary-meta">
|
||||||
<span>{{ t("product.sold", { n: salesOf(detail.product) }) }}</span>
|
<span>{{ t("product.sold", { n: detail.product.sold_count }) }}</span>
|
||||||
<span>{{ t("product.commentCount", { n: detail.commentStats.all }) }}</span>
|
|
||||||
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
|
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -324,20 +320,6 @@ const detailImages = computed(() => product.value?.images ?? []);
|
|||||||
<p>{{ pick(detail.product.description, locale) }}</p>
|
<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" />
|
<img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" :src="image" :alt="t('product.detail')" loading="lazy" />
|
||||||
</div>
|
</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">
|
<div v-else class="service-content">
|
||||||
<h2>{{ t("product.tabsAfterSale") }}</h2>
|
<h2>{{ t("product.tabsAfterSale") }}</h2>
|
||||||
<p v-if="detail.store?.after_sale">{{ pick(detail.store.after_sale, locale) }}</p>
|
<p v-if="detail.store?.after_sale">{{ pick(detail.store.after_sale, locale) }}</p>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t as pick } from "@vmall/shared";
|
import { t as pick } from "@vmall/shared";
|
||||||
import type { Category, 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
|
// Sorts the catalog model can answer: price and real sales. Comments still have
|
||||||
// removed in Wave 1: no backend model backs them.
|
// no model, so that facet stays out.
|
||||||
type SortType = "default" | "price";
|
type SortType = "default" | "price" | "sales";
|
||||||
type OrderType = "asc" | "desc";
|
type OrderType = "asc" | "desc";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -17,13 +17,17 @@ const queryValue = (value: unknown): string => {
|
|||||||
return typeof value === "string" ? value : "";
|
return typeof value === "string" ? value : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseSort = (raw: string): SortType =>
|
||||||
|
raw === "price" || raw === "sales" ? raw : "default";
|
||||||
|
|
||||||
const state = computed(() => {
|
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 order: OrderType = queryValue(route.query.order) === "asc" ? "asc" : "desc";
|
||||||
const pageValue = Number.parseInt(queryValue(route.query.page), 10);
|
const pageValue = Number.parseInt(queryValue(route.query.page), 10);
|
||||||
return {
|
return {
|
||||||
q: queryValue(route.query.q),
|
q: queryValue(route.query.q),
|
||||||
category: queryValue(route.query.category),
|
category: queryValue(route.query.category),
|
||||||
|
brand: queryValue(route.query.brand),
|
||||||
sort,
|
sort,
|
||||||
order,
|
order,
|
||||||
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
|
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
|
||||||
@@ -38,8 +42,9 @@ const { data: result } = await useAsyncData(
|
|||||||
$api.listProducts({
|
$api.listProducts({
|
||||||
q: state.value.q || undefined,
|
q: state.value.q || undefined,
|
||||||
category_id: state.value.category || undefined,
|
category_id: state.value.category || undefined,
|
||||||
sort: state.value.sort === "price" ? "price" : undefined,
|
brand_id: state.value.brand || undefined,
|
||||||
order: state.value.sort === "price" ? state.value.order : undefined,
|
sort: state.value.sort === "default" ? undefined : state.value.sort,
|
||||||
|
order: state.value.sort === "default" ? undefined : state.value.order,
|
||||||
page: state.value.page,
|
page: state.value.page,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
}),
|
}),
|
||||||
@@ -55,11 +60,17 @@ const topLevelCategories = computed(() =>
|
|||||||
allCategories.value.filter((category) => category.parent_id === null).sort(byPosition),
|
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 replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => {
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries({
|
for (const [key, value] of Object.entries({
|
||||||
q: state.value.q,
|
q: state.value.q,
|
||||||
category: state.value.category,
|
category: state.value.category,
|
||||||
|
brand: state.value.brand,
|
||||||
sort: state.value.sort,
|
sort: state.value.sort,
|
||||||
order: state.value.order,
|
order: state.value.order,
|
||||||
page: String(state.value.page),
|
page: String(state.value.page),
|
||||||
@@ -79,6 +90,10 @@ const chooseCategory = (id?: string): void => {
|
|||||||
void replaceQuery({ category: id, page: "1" });
|
void replaceQuery({ category: id, page: "1" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const chooseBrand = (id?: string): void => {
|
||||||
|
void replaceQuery({ brand: id, page: "1" });
|
||||||
|
};
|
||||||
|
|
||||||
const chooseSort = (sort: SortType): void => {
|
const chooseSort = (sort: SortType): void => {
|
||||||
const order: OrderType = state.value.sort === sort && sort !== "default"
|
const order: OrderType = state.value.sort === sort && sort !== "default"
|
||||||
? state.value.order === "asc" ? "desc" : "asc"
|
? state.value.order === "asc" ? "desc" : "asc"
|
||||||
@@ -118,6 +133,7 @@ const breadcrumbItems = computed(() => [
|
|||||||
const sortOptions = computed(() => [
|
const sortOptions = computed(() => [
|
||||||
{ key: "default" as SortType, label: t("search.defaultSort") },
|
{ key: "default" as SortType, label: t("search.defaultSort") },
|
||||||
{ key: "price" as SortType, label: t("search.price") },
|
{ key: "price" as SortType, label: t("search.price") },
|
||||||
|
{ key: "sales" as SortType, label: t("search.sales") },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -155,6 +171,19 @@ const sortOptions = computed(() => [
|
|||||||
>{{ pick(category.name, locale) }}</button>
|
>{{ pick(category.name, locale) }}</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="filter-row sort-row">
|
||||||
<strong>{{ t("search.sort") }}</strong>
|
<strong>{{ t("search.sort") }}</strong>
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type LiveDomain =
|
|||||||
| "currency"
|
| "currency"
|
||||||
| "content"
|
| "content"
|
||||||
| "shops"
|
| "shops"
|
||||||
|
| "brands"
|
||||||
| "cart"
|
| "cart"
|
||||||
| "orders"
|
| "orders"
|
||||||
| "shipments"
|
| "shipments"
|
||||||
@@ -33,6 +34,7 @@ const LIVE_PICKS = {
|
|||||||
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
|
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
|
||||||
content: (a: ApiClient) => ({ getHomeContent: a.getHomeContent }),
|
content: (a: ApiClient) => ({ getHomeContent: a.getHomeContent }),
|
||||||
shops: (a: ApiClient) => ({ listShops: a.listShops, getShop: a.getShop }),
|
shops: (a: ApiClient) => ({ listShops: a.listShops, getShop: a.getShop }),
|
||||||
|
brands: (a: ApiClient) => ({ listBrands: a.listBrands }),
|
||||||
cart: (a: ApiClient) => ({
|
cart: (a: ApiClient) => ({
|
||||||
getCart: a.getCart,
|
getCart: a.getCart,
|
||||||
addCartItem: a.addCartItem,
|
addCartItem: a.addCartItem,
|
||||||
@@ -64,6 +66,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
|
|||||||
"currency",
|
"currency",
|
||||||
"content",
|
"content",
|
||||||
"shops",
|
"shops",
|
||||||
|
"brands",
|
||||||
"auth",
|
"auth",
|
||||||
"cart",
|
"cart",
|
||||||
"orders",
|
"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] **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).
|
- [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.
|
- [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.
|
||||||
- [ ] Extend the `ORDER BY` whitelist if more sorts are wanted beyond the `sort=price` added in Wave 1.
|
- [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`.
|
- [ ] *(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.
|
- **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.
|
- **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.
|
- **seckill / collective / integral marketing pages** — display-only mock content.
|
||||||
|
|
||||||
## Decisions already made (do not relitigate)
|
## Decisions already made (do not relitigate)
|
||||||
|
|||||||
@@ -2,34 +2,35 @@
|
|||||||
|
|
||||||
## 1. Schema and seed
|
## 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`
|
- [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`
|
||||||
- [ ] 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.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. 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
|
- [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
|
||||||
- [ ] 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.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. 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
|
- [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
|
||||||
- [ ] 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
|
- [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
|
||||||
- [ ] 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.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. 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
|
- [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
|
||||||
- [ ] 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
|
- [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
|
||||||
- [ ] 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.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. 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
|
- [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
|
||||||
- [ ] 5.2 `components/ui/ProductCard.vue`: show the product's real `sold_count` and drop the review figure; verify no fabricated number remains
|
- [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
|
||||||
- [ ] 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.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. 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`)
|
- [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
|
||||||
- [ ] 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
|
- [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
|
||||||
- [ ] 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.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 {
|
import type {
|
||||||
Address,
|
Address,
|
||||||
AuthTokens,
|
AuthTokens,
|
||||||
|
Brand,
|
||||||
|
BrandInput,
|
||||||
Cart,
|
Cart,
|
||||||
Category,
|
Category,
|
||||||
ContentInputByKind,
|
ContentInputByKind,
|
||||||
@@ -44,15 +46,17 @@ export interface ProductListQuery {
|
|||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
category_id?: string;
|
category_id?: string;
|
||||||
|
brand_id?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
shop_id?: string;
|
shop_id?: string;
|
||||||
/** `price` orders by each product's lowest active SKU price. */
|
/** `price` orders by lowest active SKU price; `sales` by units sold. */
|
||||||
sort?: "price";
|
sort?: "price" | "sales";
|
||||||
order?: "asc" | "desc";
|
order?: "asc" | "desc";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductUpsertBody {
|
export interface ProductUpsertBody {
|
||||||
category_id?: string | null;
|
category_id?: string | null;
|
||||||
|
brand_id?: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: LocalizedText;
|
name: LocalizedText;
|
||||||
description?: LocalizedText;
|
description?: LocalizedText;
|
||||||
@@ -145,6 +149,7 @@ export interface ApiClient {
|
|||||||
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
|
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
|
||||||
getProduct(idOrSlug: string): Promise<Product>;
|
getProduct(idOrSlug: string): Promise<Product>;
|
||||||
listCategories(): Promise<Category[]>;
|
listCategories(): Promise<Category[]>;
|
||||||
|
listBrands(): Promise<Brand[]>;
|
||||||
listCurrencies(): Promise<Currency[]>;
|
listCurrencies(): Promise<Currency[]>;
|
||||||
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
|
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
|
||||||
getCart(): Promise<Cart>;
|
getCart(): Promise<Cart>;
|
||||||
@@ -223,6 +228,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
|||||||
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
|
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
|
||||||
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
|
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
|
||||||
listCategories: () => r("GET", "/categories"),
|
listCategories: () => r("GET", "/categories"),
|
||||||
|
listBrands: () => r("GET", "/brands"),
|
||||||
listCurrencies: () => r("GET", "/currencies"),
|
listCurrencies: () => r("GET", "/currencies"),
|
||||||
convert: (amountMinor, from, to) =>
|
convert: (amountMinor, from, to) =>
|
||||||
r("GET", "/currencies/convert", undefined, { amount_minor: 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"),
|
getContent: () => r("GET", "/admin/content"),
|
||||||
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
|
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
|
||||||
setShopProfile: (id, body) => r("PUT", `/admin/shops/${id}/profile`, body),
|
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;
|
id: string;
|
||||||
shop_id: string;
|
shop_id: string;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
|
brand_id: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: LocalizedText;
|
name: LocalizedText;
|
||||||
description: LocalizedText;
|
description: LocalizedText;
|
||||||
images: string[];
|
images: string[];
|
||||||
status: ProductStatus;
|
status: ProductStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** Units sold across paid orders; every product payload carries it. */
|
||||||
|
sold_count: number;
|
||||||
skus?: Sku[];
|
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 {
|
export interface Sku {
|
||||||
id: string;
|
id: string;
|
||||||
product_id: string;
|
product_id: string;
|
||||||
|
|||||||
+31
-5
@@ -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" },
|
{ 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) {
|
for (const p of products) {
|
||||||
const token = ownerTokens.get(p.shop);
|
const token = ownerTokens.get(p.shop);
|
||||||
r = await call("POST", "/shop/products", {
|
const body = {
|
||||||
token,
|
|
||||||
body: {
|
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: p.description,
|
description: p.description,
|
||||||
category_id: catBy(p.category),
|
category_id: catBy(p.category),
|
||||||
|
brand_id: brandBy(PRODUCT_BRAND[p.slug] ?? SHOP_BRAND[p.shop]),
|
||||||
images: [`https://picsum.photos/seed/${p.img}/600/600`],
|
images: [`https://picsum.photos/seed/${p.img}/600/600`],
|
||||||
},
|
};
|
||||||
});
|
r = await call("POST", "/shop/products", { token, body });
|
||||||
let id;
|
let id;
|
||||||
if (r.status === 201) id = r.data.id;
|
if (r.status === 201) id = r.data.id;
|
||||||
else if (r.status === 409) {
|
else if (r.status === 409) {
|
||||||
const list = await call("GET", "/shop/products?per_page=100", { token });
|
const list = await call("GET", "/shop/products?per_page=100", { token });
|
||||||
id = list.data.items.find((x) => x.slug === p.slug)?.id;
|
id = list.data.items.find((x) => x.slug === p.slug)?.id;
|
||||||
if (!id) fail(`lookup product ${p.slug}`, r);
|
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);
|
} else fail(`create product ${p.slug}`, r);
|
||||||
|
|
||||||
r = await call("POST", `/shop/products/${id}/skus`, {
|
r = await call("POST", `/shop/products/${id}/skus`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user