Files
vmall/apps/mall/pages/search.vue
T
james e0e833d0e5 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
2026-09-17 15:15:25 +00:00

254 lines
7.9 KiB
Vue

<script setup lang="ts">
import { t as pick } from "@vmall/shared";
import type { 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";
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] : "";
return typeof value === "string" ? value : "";
};
const state = computed(() => {
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),
sort,
order,
page: Number.isFinite(pageValue) && pageValue > 0 ? pageValue : 1,
};
});
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> => {
const next: Record<string, string> = {};
for (const [key, value] of Object.entries({
q: state.value.q,
category: state.value.category,
sort: state.value.sort,
order: state.value.order,
page: String(state.value.page),
...patch,
})) {
if (value) next[key] = value;
}
if (next.sort === "default" || next.sort === undefined) {
delete next.sort;
delete next.order;
}
if (next.page === "1") delete next.page;
await router.replace({ query: next });
};
const chooseCategory = (id?: string): void => {
void replaceQuery({ category: id, page: "1" });
};
const chooseSort = (sort: SortType): void => {
const order: OrderType = state.value.sort === sort && sort !== "default"
? state.value.order === "asc" ? "desc" : "asc"
: "asc";
void replaceQuery({ sort, order, page: "1" });
};
const categoryById = (id: string): Category | undefined =>
allCategories.value.find((category) => category.id === id);
const selectedPath = computed(() => {
const path: Category[] = [];
let current = state.value.category ? categoryById(state.value.category) : undefined;
while (current) {
path.unshift(current);
current = current.parent_id ? categoryById(current.parent_id) : undefined;
}
return path;
});
const categoryChildren = computed(() => {
const selected = selectedPath.value[selectedPath.value.length - 1];
return selected
? allCategories.value.filter((category) => category.parent_id === selected.id).sort(byPosition)
: [];
});
const breadcrumbItems = computed(() => [
{ label: t("search.home"), to: "/" },
{
label: state.value.q
? t("search.searchResults", { keyword: state.value.q })
: t("search.productList"),
},
]);
const sortOptions = computed(() => [
{ key: "default" as SortType, label: t("search.defaultSort") },
{ key: "price" as SortType, label: t("search.price") },
]);
</script>
<template>
<div class="search-page section-bg">
<UiBreadcrumb :items="breadcrumbItems" />
<main class="w1200">
<section class="filter mpanel" :aria-label="t('search.sort')">
<div class="filter-row">
<strong>{{ t("search.category") }}</strong>
<div class="filter-options">
<button type="button" :class="{ active: !state.category }" @click="chooseCategory()">{{ t("search.all") }}</button>
<button
v-for="category in topLevelCategories"
:key="category.id"
type="button"
:class="{ active: category.id === state.category }"
@click="chooseCategory(category.id)"
>{{ pick(category.name, locale) }}</button>
<template v-if="selectedPath.length">
<button
v-for="category in selectedPath.slice(1)"
:key="`path-${category.id}`"
type="button"
:class="{ active: category.id === state.category }"
@click="chooseCategory(category.id)"
>{{ pick(category.name, locale) }}</button>
</template>
<button
v-for="category in categoryChildren"
:key="`child-${category.id}`"
type="button"
:class="{ active: category.id === state.category }"
@click="chooseCategory(category.id)"
>{{ pick(category.name, locale) }}</button>
</div>
</div>
<div class="filter-row sort-row">
<strong>{{ t("search.sort") }}</strong>
<div class="filter-options">
<button
v-for="option in sortOptions"
:key="option.key"
type="button"
:class="{ active: state.sort === option.key }"
:aria-label="option.key === state.sort ? (state.order === 'asc' ? t('search.ascending') : t('search.descending')) : undefined"
@click="chooseSort(option.key)"
>
{{ option.label }}
<span v-if="state.sort === option.key && option.key !== 'default'" aria-hidden="true">{{ state.order === "asc" ? "↑" : "↓" }}</span>
</button>
</div>
</div>
</section>
<div class="result-head">
<span>{{ t("search.resultCount", { n: result.total }) }}</span>
</div>
<div v-if="result.items.length" class="product-grid">
<UiProductCard v-for="product in result.items" :key="product.id" :product="product" />
</div>
<UiEmptyState v-else :text="t('search.empty')" />
<UiPagination :page="result.page" :total="result.total" :per-page="result.per_page" @change="(page) => replaceQuery({ page: String(page) })" />
</main>
</div>
</template>
<style scoped>
.search-page {
min-height: 650px;
padding-bottom: 50px;
}
.filter {
background: #fff;
border: 1px solid var(--mall-line);
padding: 2px 20px;
}
.filter-row {
display: flex;
min-height: 48px;
align-items: flex-start;
border-bottom: 1px solid var(--mall-line);
padding: 12px 0 8px;
}
.filter-row:last-child {
border-bottom: 0;
}
.filter-row strong {
width: 90px;
flex: 0 0 90px;
color: var(--mall-ink);
font-size: 13px;
line-height: 28px;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
}
.filter-options button {
border: 0;
background: transparent;
color: var(--mall-muted);
cursor: pointer;
font-size: 13px;
line-height: 28px;
padding: 0 10px;
}
.filter-options button:hover,
.filter-options button.active {
background: var(--mall-red);
color: #fff;
border-radius: var(--mall-radius);
}
.sort-row .filter-options button {
min-width: 60px;
}
.result-head {
color: var(--mall-muted);
font-size: 13px;
padding: 22px 2px 12px;
}
.product-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 900px) {
.product-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>