119 lines
5.3 KiB
TypeScript
119 lines
5.3 KiB
TypeScript
/**
|
||
* # 路由 /shop/ 全部产品列表页
|
||
* 原站 /shop/ 形态:左栏属性筛选(复选框,URL search params 驱动)+ 主区
|
||
* 「IoT Products / Showing x–y of z results」+ 产品网格 + 分页。
|
||
*/
|
||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||
import { ProductCard } from "~/components/website/ProductCard";
|
||
import { Pagination } from "~/components/website/Pagination";
|
||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||
import { parseCollectionSearch, searchToFilters, filtersToSearch, type CollectionSearch } from "~/components/website/collection-shared";
|
||
import { getFilterConfig, listProducts, type FilterState, type ProductListResult } from "~/server/data";
|
||
import { useT } from "~/i18n";
|
||
|
||
export const Route = createFileRoute("/shop")({
|
||
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||
loaderDeps: ({ search }) => ({ search }),
|
||
loader: async ({ deps }) => {
|
||
const [layout, filterConfig] = await Promise.all([
|
||
getLayoutData(),
|
||
getFilterConfig().catch(() => []),
|
||
]);
|
||
const list = await listProducts({
|
||
filters: searchToFilters(deps.search),
|
||
page: deps.search.page,
|
||
pageSize: 24,
|
||
}).catch(() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult);
|
||
return { layout, filterConfig, list };
|
||
},
|
||
head: () => ({
|
||
meta: [
|
||
{ title: "All Products - DusunIoT" },
|
||
{ name: "description", content: "Browse all Dusun IoT gateways and modules." },
|
||
],
|
||
}),
|
||
component: ShopPage,
|
||
});
|
||
|
||
/** # 函数 组件 /shop/ 页面 */
|
||
function ShopPage() {
|
||
const t = useT();
|
||
const data = Route.useLoaderData();
|
||
const search = Route.useSearch();
|
||
const router = useRouter();
|
||
const page = data.list.page;
|
||
const from = (page - 1) * data.list.pageSize + (data.list.items.length ? 1 : 0);
|
||
const to = (page - 1) * data.list.pageSize + data.list.items.length;
|
||
|
||
/** # 函数 更新筛选:切换某属性值后 navigate(重置到第 1 页) */
|
||
function toggleFilter(attrSlug: string, valueSlug: string) {
|
||
const filters: FilterState = searchToFilters(search);
|
||
const cur = filters[attrSlug] || [];
|
||
const next = cur.includes(valueSlug) ? cur.filter((v) => v !== valueSlug) : [...cur, valueSlug];
|
||
if (next.length) filters[attrSlug] = next;
|
||
else delete filters[attrSlug];
|
||
router.navigate({ to: "/shop/", search: filtersToSearch(filters, 1) });
|
||
}
|
||
|
||
return (
|
||
<Layout layout={data.layout}>
|
||
<div className="site-container">
|
||
<Breadcrumb aria-label="Breadcrumb">
|
||
<BreadcrumbList>
|
||
<BreadcrumbItem><BreadcrumbLink to="/">{t("nav_home")}</BreadcrumbLink></BreadcrumbItem>
|
||
<BreadcrumbSeparator />
|
||
<BreadcrumbItem><BreadcrumbPage>{t("nav_shop")}</BreadcrumbPage></BreadcrumbItem>
|
||
</BreadcrumbList>
|
||
</Breadcrumb>
|
||
<div className="grid grid-cols-1 items-start gap-7 min-[900px]:grid-cols-[250px_1fr]">
|
||
{/* 左栏属性筛选(与原站 /shop/ 一致:仅属性过滤器,无分类树) */}
|
||
<aside>
|
||
{data.filterConfig.map((f) => (
|
||
<div className="mb-[18px] rounded-lg border border-line bg-soft p-4" key={f.slug}>
|
||
<h3 className="m-0 mb-2.5 text-[15px]">{f.name}</h3>
|
||
<ul className="m-0 list-none p-0">
|
||
{f.values.map((v) => {
|
||
const checked = (searchToFilters(search)[f.slug] || []).includes(v.slug);
|
||
return (
|
||
<li key={v.slug} className="mb-1.5">
|
||
<label className="flex cursor-pointer items-center gap-2">
|
||
<input type="checkbox" checked={checked} onChange={() => toggleFilter(f.slug, v.slug)} />
|
||
<span className="flex-1">{v.value}</span>
|
||
<span className="text-xs text-muted-foreground">({Number(v.count)})</span>
|
||
</label>
|
||
</li>
|
||
);
|
||
})}
|
||
{f.values.length === 0 ? <li className="text-muted-foreground">{t("collection_no_options")}</li> : null}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</aside>
|
||
|
||
<div>
|
||
<h1 className="mt-0 text-[26px]">{t("shop_iot_products")}</h1>
|
||
<p className="text-muted-foreground">
|
||
{data.list.total > 0 ? t("shop_showing", { from, to, total: data.list.total }) : t("shop_no_results")}
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||
{data.list.items.map((p) => (
|
||
<ProductCard key={p.id} p={p} />
|
||
))}
|
||
</div>
|
||
{data.list.items.length === 0 ? (
|
||
<p className="py-[60px] text-center text-muted-foreground">No products found with the selected filters.</p>
|
||
) : null}
|
||
<Pagination
|
||
page={page}
|
||
total={data.list.total}
|
||
pageSize={data.list.pageSize}
|
||
makeHref={(n) => (n > 1 ? `/shop/?page=${n}` : "/shop/")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|