271 lines
10 KiB
Vue
271 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { t as pick } from "@vmall/shared";
|
|
import type { Brand, Category, Paged, Product } from "@vmall/shared";
|
|
|
|
// 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();
|
|
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 parseSort = (raw: string): SortType => (raw === "price" || raw === "sales" ? raw : "default");
|
|
|
|
const state = computed(() => {
|
|
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,
|
|
};
|
|
});
|
|
|
|
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,
|
|
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,
|
|
}),
|
|
{ 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),
|
|
);
|
|
|
|
// 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),
|
|
...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 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"
|
|
: "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") },
|
|
{ key: "sales" as SortType, label: t("search.sales") },
|
|
]);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="bg-bg text-text min-h-screen pb-12 font-sans">
|
|
<UiBreadcrumb :items="breadcrumbItems" />
|
|
<main class="max-w-mall mx-auto w-full px-4">
|
|
<VCard :padded="false" class="border-border bg-surface border px-5">
|
|
<div class="border-border flex min-h-12 items-start gap-4 border-b py-3 last:border-b-0">
|
|
<strong class="text-text w-[90px] shrink-0 text-[13px] leading-7 font-semibold">{{
|
|
t("search.category")
|
|
}}</strong>
|
|
<div class="flex flex-wrap gap-x-2 gap-y-1">
|
|
<button
|
|
type="button"
|
|
class="text-muted hover:bg-primary rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 hover:text-white"
|
|
:class="!state.category ? 'bg-primary text-white' : ''"
|
|
@click="chooseCategory()"
|
|
>
|
|
{{ t("search.all") }}
|
|
</button>
|
|
<button
|
|
v-for="category in topLevelCategories"
|
|
:key="category.id"
|
|
type="button"
|
|
class="text-muted hover:bg-primary rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 hover:text-white"
|
|
:class="category.id === state.category ? 'bg-primary text-white' : ''"
|
|
@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="text-muted hover:bg-primary rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 hover:text-white"
|
|
:class="category.id === state.category ? 'bg-primary text-white' : ''"
|
|
@click="chooseCategory(category.id)"
|
|
>
|
|
{{ pick(category.name, locale) }}
|
|
</button></template
|
|
>
|
|
<button
|
|
v-for="category in categoryChildren"
|
|
:key="`child-${category.id}`"
|
|
type="button"
|
|
class="text-muted hover:bg-primary rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 hover:text-white"
|
|
:class="category.id === state.category ? 'bg-primary text-white' : ''"
|
|
@click="chooseCategory(category.id)"
|
|
>
|
|
{{ pick(category.name, locale) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="brands.length"
|
|
class="border-border flex min-h-12 items-start gap-4 border-b py-3"
|
|
>
|
|
<strong class="text-text w-[90px] shrink-0 text-[13px] leading-7 font-semibold">{{
|
|
t("search.brand")
|
|
}}</strong>
|
|
<div class="flex flex-wrap gap-x-2 gap-y-1">
|
|
<button
|
|
type="button"
|
|
class="text-muted hover:bg-primary rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 hover:text-white"
|
|
:class="!state.brand ? 'bg-primary text-white' : ''"
|
|
@click="chooseBrand()"
|
|
>
|
|
{{ t("search.all") }}</button
|
|
><button
|
|
v-for="brand in brands"
|
|
:key="brand.id"
|
|
type="button"
|
|
class="text-muted hover:bg-primary rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 hover:text-white"
|
|
:class="brand.id === state.brand ? 'bg-primary text-white' : ''"
|
|
@click="chooseBrand(brand.id)"
|
|
>
|
|
{{ pick(brand.name, locale) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex min-h-12 items-start gap-4 py-3">
|
|
<strong class="text-text w-[90px] shrink-0 text-[13px] leading-7 font-semibold">{{
|
|
t("search.sort")
|
|
}}</strong>
|
|
<div class="flex flex-wrap gap-x-2 gap-y-1">
|
|
<button
|
|
v-for="option in sortOptions"
|
|
:key="option.key"
|
|
type="button"
|
|
class="text-muted hover:bg-primary min-w-[60px] rounded-md border-0 bg-transparent px-2.5 py-0 text-[13px] leading-7 hover:text-white"
|
|
:class="state.sort === option.key ? 'bg-primary text-white' : ''"
|
|
: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>
|
|
</VCard>
|
|
|
|
<div class="text-muted px-0.5 pt-[22px] pb-3 text-[13px]">
|
|
{{ t("search.resultCount", { n: result.total }) }}
|
|
</div>
|
|
<div
|
|
v-if="result.items.length"
|
|
class="grid [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))] gap-4"
|
|
>
|
|
<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>
|