feat(mall): restore real brand and sales facets

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

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

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

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

OpenSpec change: openspec/changes/replace-mock-api-wave-6
This commit is contained in:
2026-09-17 17:35:28 +00:00
parent 65cea42da6
commit ce3e8db5b1
20 changed files with 487 additions and 193 deletions
+11 -2
View File
@@ -1,6 +1,8 @@
import type {
Address,
AuthTokens,
Brand,
BrandInput,
Cart,
Category,
ContentInputByKind,
@@ -44,15 +46,17 @@ export interface ProductListQuery {
page?: number;
per_page?: number;
category_id?: string;
brand_id?: string;
q?: string;
shop_id?: string;
/** `price` orders by each product's lowest active SKU price. */
sort?: "price";
/** `price` orders by lowest active SKU price; `sales` by units sold. */
sort?: "price" | "sales";
order?: "asc" | "desc";
}
export interface ProductUpsertBody {
category_id?: string | null;
brand_id?: string | null;
slug: string;
name: LocalizedText;
description?: LocalizedText;
@@ -145,6 +149,7 @@ export interface ApiClient {
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
getProduct(idOrSlug: string): Promise<Product>;
listCategories(): Promise<Category[]>;
listBrands(): Promise<Brand[]>;
listCurrencies(): Promise<Currency[]>;
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
getCart(): Promise<Cart>;
@@ -223,6 +228,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
listCategories: () => r("GET", "/categories"),
listBrands: () => r("GET", "/brands"),
listCurrencies: () => r("GET", "/currencies"),
convert: (amountMinor, from, to) =>
r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }),
@@ -282,6 +288,9 @@ export function createApi(opts: ApiClientOptions): ApiClient {
getContent: () => r("GET", "/admin/content"),
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
setShopProfile: (id, body) => r("PUT", `/admin/shops/${id}/profile`, body),
/** Replaces the whole ordered brand list. */
getBrands: () => r("GET", "/brands"),
replaceBrands: (items) => r("PUT", "/admin/brands", items),
},
};
}
+17
View File
@@ -50,15 +50,32 @@ export interface Product {
id: string;
shop_id: string;
category_id: string | null;
brand_id: string | null;
slug: string;
name: LocalizedText;
description: LocalizedText;
images: string[];
status: ProductStatus;
created_at: string;
/** Units sold across paid orders; every product payload carries it. */
sold_count: number;
skus?: Sku[];
}
export interface Brand {
id: string;
name: LocalizedText;
slug: string;
position: number;
active: boolean;
}
export interface BrandInput {
slug: string;
name: LocalizedText;
active?: boolean;
}
export interface Sku {
id: string;
product_id: string;