Files
dusuniot/src/routes/collection.index.tsx
T
bigcode ddeeca3293 v0.10.21: 产品归档页 C1-C4(原站 /shop/ 模板 1398 + WPC 筛选 + WooCommerce 网格)
- 导入 archive 模板 post 1398(slug=shop-archive),/collection/ 用 ElementorRenderer
  直出 hero/两列外壳,按 widget id 注入 ShopFilters/ShopProducts;新增 server function
  解决客户端导航时 loader 打不开 db 的问题
- C1 hero:面包屑 Home / Shop(BreadcrumbWidget + vars.breadcrumb 上下文)
- C2 筛选:12 组属性(term taxonomy 顺序,scripts/import-attribute-term-order.ts);
  长期列表 5 项 + See more,Battery/Operating/Install 默认折叠;URL query 驱动,
  SoM 筛选实测 22 条;顶部 Filters 标题/已选 chips/Clear all
- C3 网格:WooCommerce 归档 DOM(结果计数、orderby、24 卡 4 列、卡片 1:1 图/标题/
  Read more);默认排序导入 products.meta.menu_order(scripts/import-product-menu-order.ts),
  前三个商品与原站一致(DSOM-040R/110R/130N)
- C4 分页:页码/箭头、当前页样式、page2 25–48 条验证
- 修复 Tailwind .columns-4 与 WooCommerce 网格类冲突;app.css 新增 shop 归档 scoped 样式
- 1436 实测:hero 52.8、shop 节 2906.7、筛选区 2798.7、网格 2523.3、卡 257.7×409、
  分页 y2855,与原站逐项一致(±6px 内);bun test 11 pass;版本 0.10.21
2026-11-02 12:32:18 +08:00

140 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 产品列表 /collection/(原站 /shop/ 的等价页)
* 原站 /shop/ 使用 WooCommerce 归档模板(elementor_library post 1398):
* - a2ab15c:面包屑 hero(bg #F4F5F8)
* - 65176b9:左侧 WPC 筛选(20% 列)+ 右侧 “IoT Products” 标题、排序、24 商品 4 列网格、分页
* 本路由以 ElementorRenderer 直出该模板外壳,按 widget id 注入:
* - 98d2008 → ShopFilters(属性复选筛选,URL query 驱动)
* - d761846 → ShopProducts(WooCommerce 归档网格 + 排序 + 分页)
* 模板缺失时降级为内置分类卡片 + 简单网格。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
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, type CollectionSearch } from "~/components/website/collection-shared";
import { ShopFilters } from "~/components/website/ShopFilters";
import { ShopProducts, orderByToSort } from "~/components/website/ShopProducts";
import { getFilterConfig, getProductCategoryTree, getShopTemplate, listProducts, type ProductListResult } from "~/server/data";
import { useT } from "~/i18n";
export const Route = createFileRoute("/collection/")({
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
loaderDeps: ({ search }) => ({ search }),
loader: async ({ deps }) => loadCollection({ data: deps.search }),
head: () => ({
meta: [
{ title: "All Products | IoT Gateways & Devices | Dusun IoT" },
{ name: "description", content: "Browse all Dusun IoT products: industrial IoT gateways, single board computers, wireless modules and smart IoT devices." },
],
}),
component: CollectionIndexPage,
});
/** # 函数 产品归档页数据加载(Server Function:客户端导航时由服务端执行,
* 避免把 db/查询层打进浏览器包;返回纯 JSON) */
const loadCollection = createServerFn({ method: "GET" })
.validator((d: CollectionSearch) => d)
.handler(async ({ data: search }) => {
const filters = searchToFilters(search);
const [layout, cats, list, shop, filterConfig] = await Promise.all([
getLayoutData(),
getProductCategoryTree().catch(() => []),
listProducts({ page: search.page, filters, pageSize: 24, sort: orderByToSort(search.orderby) }).catch(
() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult,
),
getShopTemplate().catch(() => null),
getFilterConfig().catch(() => []),
]);
// Elementor 文档是递归 JSON,TanStack 序列化校验无法展开;运行时为纯 JSON
return { layout, cats, list, shop, filters, filterConfig, search } as any;
});
/** # 类型 归档页 loader 数据 */
type CollectionData = {
layout: any;
cats: { id: number; name: string; slug: string; parentId: number | null; count: number }[];
list: ProductListResult;
shop: { meta?: unknown } | null;
filters: Record<string, string[]>;
filterConfig: { name: string; slug: string; values: { value: string; slug: string; count: number }[] }[];
search: CollectionSearch;
};
/** # 组件 全部产品列表页 */
function CollectionIndexPage() {
const data = Route.useLoaderData();
const shopDoc = extractElementor(data.shop?.meta);
if (shopDoc) {
return (
<Layout layout={data.layout}>
<ElementorRenderer
doc={shopDoc}
vars={{ breadcrumb: [{ label: "Home", url: "/" }, { label: "Shop" }] }}
shortcodes={{
"98d2008": (
<ShopFilters config={data.filterConfig} selected={data.filters} total={data.list.total} search={data.search} />
),
"d761846": (
<ShopProducts
items={data.list.items}
total={data.list.total}
page={data.list.page}
pageSize={data.list.pageSize}
search={data.search}
/>
),
}}
/>
</Layout>
);
}
return <FallbackCollectionPage data={data} />;
}
/** # 组件 降级列表页(归档模板缺失时) */
function FallbackCollectionPage(props: { data: CollectionData }) {
const t = useT();
const data = props.data;
const topCats = data.cats.filter((c) => !c.parentId && c.slug !== "uncategorized");
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_products")}</BreadcrumbPage></BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<h1 className="text-[28px]">{t("nav_all_products")}</h1>
<p className="text-muted-foreground">{t("collection_products_count", { total: data.list.total })}</p>
<section className="mt-5 mb-8">
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{topCats.map((c) => (
<Link key={c.id} to="/collection/$slug/" params={{ slug: c.slug }} className="block overflow-hidden rounded-lg border border-line bg-white text-brand transition-[box-shadow,transform] duration-200 hover:-translate-y-0.5">
<div className="px-4 pt-3.5 pb-[18px]">
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{c.name}</div>
<div className="text-[13px] text-muted-foreground">{t("collection_products_count", { total: Number(c.count) })}</div>
</div>
</Link>
))}
</div>
</section>
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{data.list.items.map((p) => (
<ProductCard key={p.id} p={p} />
))}
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products found.</p> : null}
</div>
<Pagination page={data.list.page} total={data.list.total} pageSize={data.list.pageSize} makeHref={(p) => `/collection/?page=${p}`} />
</div>
</Layout>
);
}