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
+6 -24
View File
@@ -2,7 +2,7 @@
import { t as pick } from "@vmall/shared";
import { ApiError } from "@vmall/shared";
import type { Category, Product, Sku } from "@vmall/shared";
import { MOCK_COUPONS, commentStats, commentsFor, salesOf } from "~/mock/data";
import { MOCK_COUPONS } from "~/mock/data";
import { lowestSku } from "~/utils/product";
import { useCartStore } from "~/stores/cart";
@@ -27,8 +27,8 @@ const { data: pageData } = await useAsyncData(
const current = await $api.getProduct(routeId.value);
// The rail uses live catalogue products so its links resolve; the store
// card, comments and coupons stay local display-only content (non-goals).
// Store best sellers, mirroring the mock's salesRankFor(shopId). Ranking by
// category would empty the rail for any product alone in its leaf category.
// Store best sellers, ranking the shop's own products. Ranking by category
// would empty the rail for any product alone in its leaf category.
const siblings = await $api.listProducts({
shop_id: current.shop_id,
per_page: 6,
@@ -54,15 +54,11 @@ const detail = computed(() => {
product: current,
// Null when the shop has no public profile; the card is then not rendered.
store: shops.value.find((shop) => shop.id === current.shop_id) ?? null,
comments: commentsFor(current.id),
commentStats: commentStats(current.id),
coupons: MOCK_COUPONS,
salesRank: related.value,
};
});
/// Display-only sales figure; `salesOf` hashes the id so it is safe for UUIDs.
const store = computed(() => detail.value?.store ?? null);
const selectedAttributes = reactive<Record<string, string>>({});
const quantity = ref(1);
@@ -174,9 +170,10 @@ const addCart = async (): Promise<void> => {
}
};
// No reviews tab: there is no reviews capability, and the mall will not present
// invented reviewers and ratings as fact. See the wave-6 design.
const tabs = computed(() => [
{ key: "detail", label: t("product.tabsDetail") },
{ key: "reviews", label: t("product.tabsReviews") },
{ key: "service", label: t("product.tabsAfterSale") },
]);
@@ -242,8 +239,7 @@ const detailImages = computed(() => product.value?.images ?? []);
<span v-if="matchedSku" class="market-label">{{ t("product.marketPrice") }} <s><PriceText :amount-minor="marketPrice" :currency="matchedSku.currency" /></s></span>
</div>
<div class="summary-meta">
<span>{{ t("product.sold", { n: salesOf(detail.product) }) }}</span>
<span>{{ t("product.commentCount", { n: detail.commentStats.all }) }}</span>
<span>{{ t("product.sold", { n: detail.product.sold_count }) }}</span>
<span :class="{ soldout: stock <= 0 }">{{ stock > 0 ? t("product.stock", { n: stock }) : t("product.noStock") }}</span>
</div>
@@ -324,20 +320,6 @@ const detailImages = computed(() => product.value?.images ?? []);
<p>{{ pick(detail.product.description, locale) }}</p>
<img v-for="(image, index) in detailImages" :key="`${image}-detail-${index}`" :src="image" :alt="t('product.detail')" loading="lazy" />
</div>
<div v-else-if="active === 'reviews'" class="reviews-content">
<div class="review-summary">
<strong>{{ t("product.reviewSummary", { rate: detail.commentStats.goodRate, n: detail.commentStats.all }) }}</strong>
<span>{{ t("product.reviewAll") }}</span>
</div>
<article v-for="comment in detail.comments" :key="comment.id" class="review">
<img :src="comment.avatar" :alt="comment.author" loading="lazy" />
<div class="review-body">
<header><strong>{{ comment.author }}</strong><UiRatingStars :value="comment.rating" :size="13" /><time>{{ comment.createdAt }}</time></header>
<p>{{ pick(comment.content, locale) }}</p>
<div v-if="comment.reply" class="reply"><strong>{{ t("product.reply") }}:</strong> {{ pick(comment.reply, locale) }}</div>
</div>
</article>
</div>
<div v-else class="service-content">
<h2>{{ t("product.tabsAfterSale") }}</h2>
<p v-if="detail.store?.after_sale">{{ pick(detail.store.after_sale, locale) }}</p>
+36 -7
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { Category, Paged, Product } from "@vmall/shared";
import type { Brand, Category, Paged, Product } from "@vmall/shared";
// Only sorts the catalog model can answer. Brand and sales/comment facets were
// removed in Wave 1: no backend model backs them.
type SortType = "default" | "price";
// Sorts the catalog model can answer: price and real sales. Comments still have
// no model, so that facet stays out.
type SortType = "default" | "price" | "sales";
type OrderType = "asc" | "desc";
const route = useRoute();
@@ -17,13 +17,17 @@ const queryValue = (value: unknown): string => {
return typeof value === "string" ? value : "";
};
const parseSort = (raw: string): SortType =>
raw === "price" || raw === "sales" ? raw : "default";
const state = computed(() => {
const sort: SortType = queryValue(route.query.sort) === "price" ? "price" : "default";
const sort = parseSort(queryValue(route.query.sort));
const order: OrderType = queryValue(route.query.order) === "asc" ? "asc" : "desc";
const pageValue = Number.parseInt(queryValue(route.query.page), 10);
return {
q: queryValue(route.query.q),
category: queryValue(route.query.category),
brand: queryValue(route.query.brand),
sort,
order,
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
@@ -38,8 +42,9 @@ const { data: result } = await useAsyncData(
$api.listProducts({
q: state.value.q || undefined,
category_id: state.value.category || undefined,
sort: state.value.sort === "price" ? "price" : undefined,
order: state.value.sort === "price" ? state.value.order : undefined,
brand_id: state.value.brand || undefined,
sort: state.value.sort === "default" ? undefined : state.value.sort,
order: state.value.sort === "default" ? undefined : state.value.order,
page: state.value.page,
per_page: 20,
}),
@@ -55,11 +60,17 @@ const topLevelCategories = computed(() =>
allCategories.value.filter((category) => category.parent_id === null).sort(byPosition),
);
// Shared with the product page; the fixed-data adapter serves the same list.
const { data: brands } = await useAsyncData("brands", () => $api.listBrands(), {
default: () => [] as Brand[],
});
const replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => {
const next: Record<string, string> = {};
for (const [key, value] of Object.entries({
q: state.value.q,
category: state.value.category,
brand: state.value.brand,
sort: state.value.sort,
order: state.value.order,
page: String(state.value.page),
@@ -79,6 +90,10 @@ const chooseCategory = (id?: string): void => {
void replaceQuery({ category: id, page: "1" });
};
const chooseBrand = (id?: string): void => {
void replaceQuery({ brand: id, page: "1" });
};
const chooseSort = (sort: SortType): void => {
const order: OrderType = state.value.sort === sort && sort !== "default"
? state.value.order === "asc" ? "desc" : "asc"
@@ -118,6 +133,7 @@ const breadcrumbItems = computed(() => [
const sortOptions = computed(() => [
{ key: "default" as SortType, label: t("search.defaultSort") },
{ key: "price" as SortType, label: t("search.price") },
{ key: "sales" as SortType, label: t("search.sales") },
]);
</script>
@@ -155,6 +171,19 @@ const sortOptions = computed(() => [
>{{ pick(category.name, locale) }}</button>
</div>
</div>
<div v-if="brands.length" class="filter-row">
<strong>{{ t("search.brand") }}</strong>
<div class="filter-options">
<button type="button" :class="{ active: !state.brand }" @click="chooseBrand()">{{ t("search.all") }}</button>
<button
v-for="brand in brands"
:key="brand.id"
type="button"
:class="{ active: brand.id === state.brand }"
@click="chooseBrand(brand.id)"
>{{ pick(brand.name, locale) }}</button>
</div>
</div>
<div class="filter-row sort-row">
<strong>{{ t("search.sort") }}</strong>
<div class="filter-options">