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:
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
Address,
|
||||
AuthTokens,
|
||||
Brand,
|
||||
BrandInput,
|
||||
Cart,
|
||||
Category,
|
||||
ContentInputByKind,
|
||||
@@ -44,15 +46,17 @@ export interface ProductListQuery {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
category_id?: string;
|
||||
brand_id?: string;
|
||||
q?: string;
|
||||
shop_id?: string;
|
||||
/** `price` orders by each product's lowest active SKU price. */
|
||||
sort?: "price";
|
||||
/** `price` orders by lowest active SKU price; `sales` by units sold. */
|
||||
sort?: "price" | "sales";
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface ProductUpsertBody {
|
||||
category_id?: string | null;
|
||||
brand_id?: string | null;
|
||||
slug: string;
|
||||
name: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
@@ -145,6 +149,7 @@ export interface ApiClient {
|
||||
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
|
||||
getProduct(idOrSlug: string): Promise<Product>;
|
||||
listCategories(): Promise<Category[]>;
|
||||
listBrands(): Promise<Brand[]>;
|
||||
listCurrencies(): Promise<Currency[]>;
|
||||
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
|
||||
getCart(): Promise<Cart>;
|
||||
@@ -223,6 +228,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
|
||||
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
|
||||
listCategories: () => r("GET", "/categories"),
|
||||
listBrands: () => r("GET", "/brands"),
|
||||
listCurrencies: () => r("GET", "/currencies"),
|
||||
convert: (amountMinor, from, to) =>
|
||||
r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }),
|
||||
@@ -282,6 +288,9 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
getContent: () => r("GET", "/admin/content"),
|
||||
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
|
||||
setShopProfile: (id, body) => r("PUT", `/admin/shops/${id}/profile`, body),
|
||||
/** Replaces the whole ordered brand list. */
|
||||
getBrands: () => r("GET", "/brands"),
|
||||
replaceBrands: (items) => r("PUT", "/admin/brands", items),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,15 +50,32 @@ export interface Product {
|
||||
id: string;
|
||||
shop_id: string;
|
||||
category_id: string | null;
|
||||
brand_id: string | null;
|
||||
slug: string;
|
||||
name: LocalizedText;
|
||||
description: LocalizedText;
|
||||
images: string[];
|
||||
status: ProductStatus;
|
||||
created_at: string;
|
||||
/** Units sold across paid orders; every product payload carries it. */
|
||||
sold_count: number;
|
||||
skus?: Sku[];
|
||||
}
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
name: LocalizedText;
|
||||
slug: string;
|
||||
position: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface BrandInput {
|
||||
slug: string;
|
||||
name: LocalizedText;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface Sku {
|
||||
id: string;
|
||||
product_id: string;
|
||||
|
||||
Reference in New Issue
Block a user