v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* # 路由 /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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user