diff --git a/apps/mall/plugins/api.ts b/apps/mall/plugins/api.ts
index a8730a4..fa34f85 100644
--- a/apps/mall/plugins/api.ts
+++ b/apps/mall/plugins/api.ts
@@ -13,6 +13,7 @@ type LiveDomain =
| "currency"
| "content"
| "shops"
+ | "brands"
| "cart"
| "orders"
| "shipments"
@@ -33,6 +34,7 @@ const LIVE_PICKS = {
currency: (a: ApiClient) => ({ listCurrencies: a.listCurrencies, convert: a.convert }),
content: (a: ApiClient) => ({ getHomeContent: a.getHomeContent }),
shops: (a: ApiClient) => ({ listShops: a.listShops, getShop: a.getShop }),
+ brands: (a: ApiClient) => ({ listBrands: a.listBrands }),
cart: (a: ApiClient) => ({
getCart: a.getCart,
addCartItem: a.addCartItem,
@@ -64,6 +66,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
"currency",
"content",
"shops",
+ "brands",
"auth",
"cart",
"orders",
diff --git a/docs/TBD-migrate-wave.md b/docs/TBD-migrate-wave.md
index 0cfc37d..a2ff3ce 100644
--- a/docs/TBD-migrate-wave.md
+++ b/docs/TBD-migrate-wave.md
@@ -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] **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.
-- [ ] Extend the `ORDER BY` whitelist if more sorts are wanted beyond the `sort=price` added in Wave 1.
+- [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.
+- [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`.
---
@@ -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.
- **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.
## Decisions already made (do not relitigate)
diff --git a/openspec/changes/replace-mock-api-wave-6/tasks.md b/openspec/changes/replace-mock-api-wave-6/tasks.md
index 45f51ea..58e353b 100644
--- a/openspec/changes/replace-mock-api-wave-6/tasks.md
+++ b/openspec/changes/replace-mock-api-wave-6/tasks.md
@@ -2,34 +2,35 @@
## 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`
-- [ ] 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.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`
+- [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.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
-- [ ] 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.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
+- [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.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
-- [ ] 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
-- [ ] 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.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
+- [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
+- [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.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
-- [ ] 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
-- [ ] 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.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
+- [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
+- [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.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
-- [ ] 5.2 `components/ui/ProductCard.vue`: show the product's real `sold_count` and drop the review figure; verify no fabricated number remains
-- [ ] 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.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
+- [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
+- [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.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`)
-- [ ] 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
-- [ ] 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.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
+- [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
+- [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
diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts
index 4cfd95b..2f65fa9 100644
--- a/packages/shared/src/api.ts
+++ b/packages/shared/src/api.ts
@@ -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>;
getProduct(idOrSlug: string): Promise;
listCategories(): Promise;
+ listBrands(): Promise;
listCurrencies(): Promise;
convert(amountMinor: number, from: string, to: string): Promise;
getCart(): Promise;
@@ -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),
},
};
}
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 0d0a9b5..46c39db 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -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;
diff --git a/scripts/seed-demo.mjs b/scripts/seed-demo.mjs
index 88a4b6a..7d9246e 100755
--- a/scripts/seed-demo.mjs
+++ b/scripts/seed-demo.mjs
@@ -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" },
];
+// 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) {
const token = ownerTokens.get(p.shop);
- r = await call("POST", "/shop/products", {
- token,
- body: {
- slug: p.slug,
- name: p.name,
- description: p.description,
- category_id: catBy(p.category),
- images: [`https://picsum.photos/seed/${p.img}/600/600`],
- },
- });
+ const body = {
+ slug: p.slug,
+ name: p.name,
+ description: p.description,
+ 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`],
+ };
+ r = await call("POST", "/shop/products", { token, body });
let id;
if (r.status === 201) id = r.data.id;
else if (r.status === 409) {
const list = await call("GET", "/shop/products?per_page=100", { token });
id = list.data.items.find((x) => x.slug === p.slug)?.id;
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);
r = await call("POST", `/shop/products/${id}/skus`, {