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:
+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[] = [
|
||||
|
||||
Reference in New Issue
Block a user