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,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">
|
||||
|
||||
Reference in New Issue
Block a user