feat(mall): serve catalog and currency from the live API
Wave 1 of replacing the fixed-data mock adapter. The mall now selects its API adapter per domain, with catalog and currency served live while auth, cart, orders, shipments and invoices stay on fixed data. Backend: - seed the 6 x 2 x 2 category tree as reference data (migration 0005). The API exposes no category write route, so this cannot come from the seed script - filter public product listing by the category subtree with a recursive CTE, matching the mock's existing behaviour instead of exact-match - add sort=price with order=asc|desc, validated by hand so an unsupported value returns the project's ApiError 400 shape rather than axum's own rejection Mall: - replace the all-or-nothing mockApi boolean with a liveDomains list composed through a typed per-domain pick map - source home floors, the category menu, search and product detail from the catalog API; banners, promos, quick links, store card and comment/coupon content stay local display-only content - drop the brand facet and the sales/comments sorts: no backend model backs them - fix salesOf/commentCountOf, which parsed digits out of the product id and so rendered "NaN sold" for live UUID ids; they now hash the id Seed: 24 products across 4 shops, idempotent on re-run. Note: the mall defaults to a live catalog, so pnpm dev:mall now expects the API to be running; set NUXT_PUBLIC_LIVE_DOMAINS to an empty array for all-mock work. OpenSpec change: openspec/changes/replace-mock-api-wave-1
This commit is contained in:
+38
-51
@@ -1,20 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { Category } from "@vmall/shared";
|
||||
import {
|
||||
MOCK_BRANDS,
|
||||
MOCK_CATEGORIES,
|
||||
childCategories,
|
||||
searchMockProducts,
|
||||
topCategories,
|
||||
} from "~/mock/data";
|
||||
import type { Category, Paged, Product } from "@vmall/shared";
|
||||
|
||||
type SortType = "default" | "price" | "sales" | "comments";
|
||||
// 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";
|
||||
type OrderType = "asc" | "desc";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { locale, t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
const queryValue = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return typeof value[0] === "string" ? value[0] : "";
|
||||
@@ -22,33 +18,41 @@ const queryValue = (value: unknown): string => {
|
||||
};
|
||||
|
||||
const state = computed(() => {
|
||||
const sortValue = queryValue(route.query.sort);
|
||||
const orderValue = queryValue(route.query.order);
|
||||
const sort: SortType = ["default", "price", "sales", "comments"].includes(sortValue)
|
||||
? (sortValue as SortType)
|
||||
: "default";
|
||||
const order: OrderType = orderValue === "asc" ? "asc" : "desc";
|
||||
const sort: SortType = queryValue(route.query.sort) === "price" ? "price" : "default";
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const result = computed(() =>
|
||||
searchMockProducts({
|
||||
q: state.value.q,
|
||||
categoryId: state.value.category || undefined,
|
||||
brandId: state.value.brand || undefined,
|
||||
sort: state.value.sort,
|
||||
order: state.value.order,
|
||||
page: state.value.page,
|
||||
perPage: 20,
|
||||
}),
|
||||
const emptyPage: Paged<Product> = { items: [], total: 0, page: 1, per_page: 20 };
|
||||
|
||||
const { data: result } = await useAsyncData(
|
||||
"search-results",
|
||||
() =>
|
||||
$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,
|
||||
page: state.value.page,
|
||||
per_page: 20,
|
||||
}),
|
||||
{ watch: [state], default: () => emptyPage },
|
||||
);
|
||||
|
||||
// Shared with the shell's category menu, so the tree is not refetched.
|
||||
const { data: categories } = await useAsyncData("shell-categories", () => $api.listCategories());
|
||||
const allCategories = computed(() => categories.value ?? []);
|
||||
const byPosition = (a: Category, b: Category): number => a.position - b.position;
|
||||
|
||||
const topLevelCategories = computed(() =>
|
||||
allCategories.value.filter((category) => category.parent_id === null).sort(byPosition),
|
||||
);
|
||||
|
||||
const replaceQuery = async (patch: Record<string, string | undefined>): Promise<void> => {
|
||||
@@ -56,7 +60,6 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
|
||||
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),
|
||||
@@ -64,9 +67,9 @@ const replaceQuery = async (patch: Record<string, string | undefined>): Promise<
|
||||
})) {
|
||||
if (value) next[key] = value;
|
||||
}
|
||||
if (next.sort === "default") {
|
||||
if (next.sort === "default" || next.sort === undefined) {
|
||||
delete next.sort;
|
||||
if (next.order === "desc") delete next.order;
|
||||
delete next.order;
|
||||
}
|
||||
if (next.page === "1") delete next.page;
|
||||
await router.replace({ query: next });
|
||||
@@ -76,10 +79,6 @@ 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"
|
||||
@@ -87,7 +86,8 @@ const chooseSort = (sort: SortType): void => {
|
||||
void replaceQuery({ sort, order, page: "1" });
|
||||
};
|
||||
|
||||
const categoryById = (id: string): Category | undefined => MOCK_CATEGORIES.find((category) => category.id === id);
|
||||
const categoryById = (id: string): Category | undefined =>
|
||||
allCategories.value.find((category) => category.id === id);
|
||||
|
||||
const selectedPath = computed(() => {
|
||||
const path: Category[] = [];
|
||||
@@ -101,7 +101,9 @@ const selectedPath = computed(() => {
|
||||
|
||||
const categoryChildren = computed(() => {
|
||||
const selected = selectedPath.value[selectedPath.value.length - 1];
|
||||
return selected ? childCategories(selected.id) : [];
|
||||
return selected
|
||||
? allCategories.value.filter((category) => category.parent_id === selected.id).sort(byPosition)
|
||||
: [];
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
@@ -116,8 +118,6 @@ 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") },
|
||||
{ key: "comments" as SortType, label: t("search.comments") },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -131,7 +131,7 @@ const sortOptions = computed(() => [
|
||||
<div class="filter-options">
|
||||
<button type="button" :class="{ active: !state.category }" @click="chooseCategory()">{{ t("search.all") }}</button>
|
||||
<button
|
||||
v-for="category in topCategories()"
|
||||
v-for="category in topLevelCategories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
:class="{ active: category.id === state.category }"
|
||||
@@ -155,19 +155,6 @@ const sortOptions = computed(() => [
|
||||
>{{ pick(category.name, locale) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div 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 MOCK_BRANDS"
|
||||
:key="brand.id"
|
||||
type="button"
|
||||
:class="{ active: brand.id === state.brand }"
|
||||
@click="chooseBrand(brand.id)"
|
||||
>{{ brand.name }}</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