- 导入 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
140 lines
6.5 KiB
TypeScript
140 lines
6.5 KiB
TypeScript
/**
|
||
* # 路由 产品列表 /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>
|
||
);
|
||
}
|