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
+8 -2
View File
@@ -21,6 +21,7 @@ import type {
import {
BASE_CURRENCY,
MOCK_BANNERS,
MOCK_BRANDS,
MOCK_CATEGORIES,
MOCK_CURRENCIES,
MOCK_PROMOS,
@@ -162,7 +163,10 @@ export function createMockApi(): ApiClient {
searchMockProducts({
q: q.q,
categoryId: q.category_id,
brandId: q.brand_id,
shopId: q.shop_id,
sort: q.sort,
order: q.order,
page: q.page,
perPage: q.per_page,
}),
@@ -175,6 +179,7 @@ export function createMockApi(): ApiClient {
},
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
listBrands: () => Promise.resolve(MOCK_BRANDS),
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
convert: (amountMinor, from, to) =>
Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }),
@@ -375,8 +380,7 @@ export function createMockApi(): ApiClient {
})),
}),
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)),
getShop: (slug) => {
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => {
const store = storeById(slug);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
return Promise.resolve(toShopProfile(store));
@@ -412,6 +416,8 @@ export function createMockApi(): ApiClient {
getContent: () => unsupported(),
replaceContent: () => unsupported(),
setShopProfile: () => unsupported(),
getBrands: () => unsupported(),
replaceBrands: () => unsupported(),
},
};
}