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
This commit is contained in:
@@ -57,11 +57,15 @@
|
|||||||
|
|
||||||
## 四、其他核心页型版块(自上而下,概览)
|
## 四、其他核心页型版块(自上而下,概览)
|
||||||
|
|
||||||
### 1. 产品分类列表/落地页(4 版块)
|
### 1. 产品归档/列表页(原站 /shop/,4 版块)
|
||||||
- C1 落地主题区(同 slug 文章 Elementor 直出,每类内容不同)
|
- C1 归档 hero:Elementor 模板 post 1398 的 a2ab15c(面包屑 Home / Shop,bg #F4F5F8)✅ v0.10.21
|
||||||
- C2 侧栏:分类树 + 属性复选筛选(URL search 驱动)
|
- C2 侧栏筛选:WPC Filter Everything(12 组属性复选、计数、See more、末三组折叠)✅ v0.10.21
|
||||||
- C3 产品网格(ProductCard ×24)
|
- C3 商品网格:WooCommerce 归档 24 卡 4 列(图/标题/Read more,Elementor skin)✅ v0.10.21
|
||||||
- C4 分页
|
- C4 排序 + 分页:orderby 下拉(默认 menu_order)、结果计数、页码/箭头分页 ✅ v0.10.21
|
||||||
|
- 说明:分类 URL(/product-category/*)在原站多数 301 到 landing-pages/*(Elementor 落地页,
|
||||||
|
归方案/落地页页型);/shop/ 才是 WooCommerce 归档版块,已按模板 1398 直出 +
|
||||||
|
ShopFilters/ShopProducts 注入。筛选 URL 用查询参数(原站 WPC 为 /shop/type-som/ 美化链接),
|
||||||
|
术语顺序/计数来自导入数据(term taxonomy 顺序 + product_attribute_map 计数)。
|
||||||
|
|
||||||
### 2. 方案页
|
### 2. 方案页
|
||||||
- S1 `/solution/` 列表入口页
|
- S1 `/solution/` 列表入口页
|
||||||
@@ -141,6 +145,17 @@ cookie-policy:19 段纯文本。
|
|||||||
> (form_key=contact-form-1),成功显示 CF7 原文提示;弹窗内社交图标 5 个(20px/间距 14/品牌色)。
|
> (form_key=contact-form-1),成功显示 CF7 原文提示;弹窗内社交图标 5 个(20px/间距 14/品牌色)。
|
||||||
> 实测 1436:白卡 700×737 居中、两列 289.9/三列 189、消息框 604×153;390:宽 390、p5/p6 两列、
|
> 实测 1436:白卡 700×737 居中、两列 289.9/三列 189、消息框 604×153;390:宽 390、p5/p6 两列、
|
||||||
> p7 全宽,与 popup 自定义 CSS 一致;提交入库验证后已清理测试数据。
|
> p7 全宽,与 popup 自定义 CSS 一致;提交入库验证后已清理测试数据。
|
||||||
|
>
|
||||||
|
> **C1-C4 产品归档取证(v0.10.21)**:原站 /shop/ 使用 archive 模板 post 1398
|
||||||
|
> (scripts/import-shop-template.ts 导入 contents slug=shop-archive),/collection/ 路由直出
|
||||||
|
> 模板外壳并注入 ShopFilters(widget 98d2008)/ShopProducts(d761846)。1436 实测:
|
||||||
|
> hero 52.8、shop 节 2906.7(左列 275.2/右列 1100.8、列高一致)、筛选区 2798.7(12 组,
|
||||||
|
> 标题 44/灰底、行高 20.8、组距 32;CPU/OS/Interface/RAM/Flash 默认 5 项 + See more,
|
||||||
|
> Battery/Operating/Install 默认折叠)、H1 24/600、结果计数“Showing 1–24 of 138 results”、
|
||||||
|
> 商品网格 2523.3(24 卡 4 列 gap 10,卡 257.7×409)、分页 y2855 margin-top 40;
|
||||||
|
> 筛选 SoM → 22 条、排序 date、See more 5→17、分页 page2 均通过。默认排序改为
|
||||||
|
> products.meta.menu_order(scripts/import-product-menu-order.ts),术语顺序改为
|
||||||
|
> taxonomy 顺序(scripts/import-attribute-term-order.ts)。
|
||||||
|
|
||||||
## 七、已知遗留(非当前版块范围,后续版块收口)
|
## 七、已知遗留(非当前版块范围,后续版块收口)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dusuniot-rebuild",
|
"name": "dusuniot-rebuild",
|
||||||
"version": "0.10.20",
|
"version": "0.10.21",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* # 脚本 导入产品属性术语顺序(对齐原站 /shop/ 筛选列表顺序)
|
||||||
|
* 原站 WPC 筛选按 wp_term_taxonomy 中的术语顺序展示(取证:pa_product-type 顺序
|
||||||
|
* SoM → IoT Gateway → Wi-Fi Repeater → Router → Beacon,对应 term_id 756/764/845/846/862;
|
||||||
|
* pa_cpu 顺序 PX30 → RK3328 → RK3588 → PX30K …)。本项目导入时按字母序写入,需要修复。
|
||||||
|
* 做法:解析 dump 的 wp_terms(term_id→name)与 wp_term_taxonomy(插入顺序),
|
||||||
|
* 对每个 pa_<属性 slug>,按顺序把 product_attribute_values.sort 设为序号。
|
||||||
|
* 运行:bun scripts/import-attribute-term-order.ts
|
||||||
|
*/
|
||||||
|
import { SQL } from "bun";
|
||||||
|
|
||||||
|
const DUMP = "/home/bigcode/data/dev/sites/dusuniot/legacy/dusuniot-db-20261029.sql";
|
||||||
|
const db = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
|
||||||
|
|
||||||
|
/** # 函数 提取某张表的 INSERT 段文本(支持多段 INSERT) */
|
||||||
|
function tableInserts(lines: string[], table: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
let buf = "";
|
||||||
|
let on = false;
|
||||||
|
for (const l of lines) {
|
||||||
|
if (l.startsWith(`INSERT INTO \`${table}\` VALUES`)) {
|
||||||
|
on = true;
|
||||||
|
buf = l;
|
||||||
|
} else if (on) {
|
||||||
|
buf += l;
|
||||||
|
}
|
||||||
|
if (on && l.trimEnd().endsWith(";")) {
|
||||||
|
out.push(buf);
|
||||||
|
buf = "";
|
||||||
|
on = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 解析 SQL 值元组 */
|
||||||
|
function parseTuples(s: string): string[][] {
|
||||||
|
const out: string[][] = [];
|
||||||
|
let p = s.indexOf("(");
|
||||||
|
while (p >= 0 && p < s.length) {
|
||||||
|
const values: string[] = [];
|
||||||
|
let cur = "";
|
||||||
|
let inQ = false;
|
||||||
|
let esc = false;
|
||||||
|
let depth = 0;
|
||||||
|
p++;
|
||||||
|
for (; p < s.length; p++) {
|
||||||
|
const ch = s[p]!;
|
||||||
|
if (esc) { cur += ch; esc = false; continue; }
|
||||||
|
if (inQ) {
|
||||||
|
if (ch === "\\") { esc = true; continue; }
|
||||||
|
if (ch === "'") { inQ = false; continue; }
|
||||||
|
cur += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "'") { inQ = true; continue; }
|
||||||
|
if (ch === "(") { depth++; cur += ch; continue; }
|
||||||
|
if (ch === ")") {
|
||||||
|
if (depth === 0) { values.push(cur); cur = ""; p++; break; }
|
||||||
|
depth--;
|
||||||
|
cur += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "," && depth === 0) { values.push(cur); cur = ""; continue; }
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
out.push(values);
|
||||||
|
p = s.indexOf("(", p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = (await Bun.file(DUMP).text()).split("\n");
|
||||||
|
|
||||||
|
// term_id → name
|
||||||
|
const nameById = new Map<number, string>();
|
||||||
|
for (const seg of tableInserts(lines, "wp_terms")) {
|
||||||
|
for (const v of parseTuples(seg)) {
|
||||||
|
if (v[0]) nameById.set(Number(v[0]), v[1] ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("terms:", nameById.size);
|
||||||
|
|
||||||
|
// taxonomy → [term names](按 dump 插入顺序)
|
||||||
|
const byTax = new Map<string, string[]>();
|
||||||
|
for (const seg of tableInserts(lines, "wp_term_taxonomy")) {
|
||||||
|
for (const v of parseTuples(seg)) {
|
||||||
|
const taxonomy = v[2] ?? "";
|
||||||
|
const termId = Number(v[1]);
|
||||||
|
if (!taxonomy.startsWith("pa_")) continue;
|
||||||
|
const name = nameById.get(termId);
|
||||||
|
if (!name) continue;
|
||||||
|
const list = byTax.get(taxonomy) ?? [];
|
||||||
|
list.push(name);
|
||||||
|
byTax.set(taxonomy, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
for (const [taxonomy, names] of byTax) {
|
||||||
|
const attrSlug = taxonomy.slice(3);
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
const res = await db`
|
||||||
|
UPDATE product_attribute_values v SET sort = ${i}
|
||||||
|
FROM product_attributes a
|
||||||
|
WHERE v.attribute_id = a.id AND a.slug = ${attrSlug} AND lower(trim(v.value)) = lower(trim(${names[i]!}))`;
|
||||||
|
updated += res.count;
|
||||||
|
}
|
||||||
|
console.log(taxonomy, "→", names.length, "terms");
|
||||||
|
}
|
||||||
|
console.log("updated rows:", updated);
|
||||||
|
await db.end();
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* # 脚本 导入产品 menu_order(WooCommerce 默认排序依据)
|
||||||
|
* 原站 /shop/ 默认排序 = wp_posts.menu_order ASC(取证:DSOM-040R=1、DSOM-110R=3、
|
||||||
|
* DSOM-130N=4,与线上商品顺序一致)。本项目 products 表无 menu_order 列,
|
||||||
|
* 故写入 products.meta.menu_order,listProducts 默认排序读取该字段。
|
||||||
|
* 数据源:legacy dump 的 wp_posts 全部 INSERT 段(每段 23 字段,menu_order 为第 20 列)。
|
||||||
|
* 运行:bun scripts/import-product-menu-order.ts
|
||||||
|
*/
|
||||||
|
import { SQL } from "bun";
|
||||||
|
|
||||||
|
const DUMP = "/home/bigcode/data/dev/sites/dusuniot/legacy/dusuniot-db-20261029.sql";
|
||||||
|
const db = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
|
||||||
|
|
||||||
|
/** # 函数 解析 SQL 值元组(仅支持字符串/数字,处理反斜杠转义与括号深度) */
|
||||||
|
function parseTuples(s: string, from: number): string[][] {
|
||||||
|
const out: string[][] = [];
|
||||||
|
let p = from;
|
||||||
|
while (p < s.length) {
|
||||||
|
if (s[p] === ";") break;
|
||||||
|
if (s[p] !== "(") { p++; continue; }
|
||||||
|
const values: string[] = [];
|
||||||
|
let cur = "";
|
||||||
|
let inQ = false;
|
||||||
|
let esc = false;
|
||||||
|
let depth = 0;
|
||||||
|
p++;
|
||||||
|
for (; p < s.length; p++) {
|
||||||
|
const ch = s[p]!;
|
||||||
|
if (esc) { cur += ch; esc = false; continue; }
|
||||||
|
if (inQ) {
|
||||||
|
if (ch === "\\") { esc = true; continue; }
|
||||||
|
if (ch === "'") { inQ = false; continue; }
|
||||||
|
cur += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "'") { inQ = true; continue; }
|
||||||
|
if (ch === "(") { depth++; cur += ch; continue; }
|
||||||
|
if (ch === ")") {
|
||||||
|
if (depth === 0) { values.push(cur); cur = ""; p++; break; }
|
||||||
|
depth--;
|
||||||
|
cur += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "," && depth === 0) { values.push(cur); cur = ""; continue; }
|
||||||
|
cur += ch;
|
||||||
|
}
|
||||||
|
out.push(values);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await Bun.file(DUMP).text();
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let idx = 0;
|
||||||
|
while (true) {
|
||||||
|
const i = text.indexOf("INSERT INTO `wp_posts`", idx);
|
||||||
|
if (i < 0) break;
|
||||||
|
const start = text.indexOf("(", i);
|
||||||
|
rows.push(...parseTuples(text, start));
|
||||||
|
idx = text.indexOf(";", start);
|
||||||
|
if (idx < 0) break;
|
||||||
|
}
|
||||||
|
const products = rows.filter((v) => v[20] === "product");
|
||||||
|
console.log("dump product rows:", products.length);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
for (const v of products) {
|
||||||
|
const legacyId = Number(v[0]);
|
||||||
|
const order = Number(v[19] || 0);
|
||||||
|
if (!legacyId) continue;
|
||||||
|
const res = await db`
|
||||||
|
UPDATE products SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object('menu_order', ${order})
|
||||||
|
WHERE legacy_id = ${legacyId}`;
|
||||||
|
if (res.count) updated += res.count;
|
||||||
|
}
|
||||||
|
console.log("updated products:", updated);
|
||||||
|
await db.end();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* # 脚本 导入产品归档页 Elementor 模板(legacy elementor_library post 1398)
|
||||||
|
* 原站 /shop/ 与产品分类归档共用 archive 模板 post 1398(elementor_location=archive):
|
||||||
|
* - a2ab15c:面包屑 hero(bg #F4F5F8 / padding 0 30 / content 1400)
|
||||||
|
* - 65176b9:两列(20% WPC 筛选 + 80% 标题 + WooCommerce 商品归档)
|
||||||
|
* - 61b1990:移动端归档描述(hide_desktop)
|
||||||
|
* 写入 contents(type=template, slug=shop-archive, meta.elementor),供
|
||||||
|
* collection 路由以 ElementorRenderer 直出外壳,筛选/商品网格按 widget id 注入。
|
||||||
|
* 运行:bun scripts/import-shop-template.ts
|
||||||
|
*/
|
||||||
|
import { SQL } from "bun";
|
||||||
|
|
||||||
|
const DUMP = "/home/bigcode/data/dev/sites/dusuniot/legacy/dusuniot-db-20261029.sql";
|
||||||
|
const db = new SQL("postgres://dusuniot:dusuniot@127.0.0.1:25432/dusuniot");
|
||||||
|
|
||||||
|
/** # 函数 从 dump 提取指定 post 的 _elementor_data */
|
||||||
|
async function extractTemplate(postId: number): Promise<Record<string, unknown>[]> {
|
||||||
|
const text = await Bun.file(DUMP).text();
|
||||||
|
const re = new RegExp("\\(\\d+," + postId + ",'_elementor_data','((?:[^'\\\\]|\\\\.)*)'\\)");
|
||||||
|
const m = text.match(re);
|
||||||
|
if (!m) throw new Error(`dump 中未找到 post ${postId} 的 _elementor_data`);
|
||||||
|
const json = m[1]!.replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
if (!Array.isArray(data)) throw new Error("_elementor_data 不是数组");
|
||||||
|
return data as Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = await extractTemplate(1398);
|
||||||
|
const meta = JSON.stringify({ elementor: elements });
|
||||||
|
const existing = await db`SELECT id FROM contents WHERE slug='shop-archive' AND type='template'`;
|
||||||
|
if (existing.length) {
|
||||||
|
await db`UPDATE contents SET meta=${meta}::jsonb, status='published', updated_at=now() WHERE id=${existing[0]!.id}`;
|
||||||
|
console.log("已更新 shop-archive 模板,contents.id =", existing[0]!.id);
|
||||||
|
} else {
|
||||||
|
const inserted = await db`
|
||||||
|
INSERT INTO contents (type, slug, title, status, meta, legacy_id, published_at)
|
||||||
|
VALUES ('template', 'shop-archive', 'Shop Archive', 'published', ${meta}::jsonb, 1398, now())
|
||||||
|
RETURNING id`;
|
||||||
|
console.log("已插入 shop-archive 模板,contents.id =", inserted[0]!.id);
|
||||||
|
}
|
||||||
|
console.log("顶层节:", elements.map((el) => el.id).join(", "));
|
||||||
|
await db.end();
|
||||||
@@ -273,24 +273,46 @@ const PostsDataContext = createContext<Record<string, PostCardItem[]>>({});
|
|||||||
/** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/
|
/** # 成员变量 WordPress 短代码渲染上下文(widget id → 预渲染节点;页脚 menu-2/gtranslate) **/
|
||||||
const ShortcodesContext = createContext<Record<string, ReactNode>>({});
|
const ShortcodesContext = createContext<Record<string, ReactNode>>({});
|
||||||
|
|
||||||
|
/** # 类型 渲染器动态数据(面包屑等 WP 动态 widget 的 SSR 上下文) */
|
||||||
|
export type RendererVars = {
|
||||||
|
/** # 成员变量 面包屑路径(末项无 url 表示当前页) **/
|
||||||
|
breadcrumb?: { label: string; url?: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 成员变量 动态数据上下文(woocommerce-breadcrumb 等 widget 读取) **/
|
||||||
|
const VarsContext = createContext<RendererVars>({});
|
||||||
|
|
||||||
/** # 组件 Elementor 渲染器入口
|
/** # 组件 Elementor 渲染器入口
|
||||||
* 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空)
|
* 参数 shortcodes 短代码 widget 的预渲染内容(键为 widget id;缺省渲染为空)
|
||||||
|
* 参数 vars 动态数据(面包屑路径等)
|
||||||
*/
|
*/
|
||||||
export function ElementorRenderer({ doc, postsData, shortcodes }: { doc: ElDocument; postsData?: Record<string, PostCardItem[]>; shortcodes?: Record<string, ReactNode> }) {
|
export function ElementorRenderer({
|
||||||
|
doc,
|
||||||
|
postsData,
|
||||||
|
shortcodes,
|
||||||
|
vars,
|
||||||
|
}: {
|
||||||
|
doc: ElDocument;
|
||||||
|
postsData?: Record<string, PostCardItem[]>;
|
||||||
|
shortcodes?: Record<string, ReactNode>;
|
||||||
|
vars?: RendererVars;
|
||||||
|
}) {
|
||||||
const elements = normalizeDoc(doc);
|
const elements = normalizeDoc(doc);
|
||||||
if (!elements.length) return null;
|
if (!elements.length) return null;
|
||||||
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
|
const css = collectCustomCss(elements) + collectResponsiveCss(elements);
|
||||||
return (
|
return (
|
||||||
<PostsDataContext.Provider value={postsData || {}}>
|
<VarsContext.Provider value={vars || {}}>
|
||||||
<ShortcodesContext.Provider value={shortcodes || {}}>
|
<PostsDataContext.Provider value={postsData || {}}>
|
||||||
<div className="content-html">
|
<ShortcodesContext.Provider value={shortcodes || {}}>
|
||||||
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
|
<div className="content-html">
|
||||||
|
{css ? <style dangerouslySetInnerHTML={{ __html: css }} /> : null}
|
||||||
{elements.map((el, i) => (
|
{elements.map((el, i) => (
|
||||||
<ElementNode key={el.id || i} el={el} />
|
<ElementNode key={el.id || i} el={el} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ShortcodesContext.Provider>
|
</ShortcodesContext.Provider>
|
||||||
</PostsDataContext.Provider>
|
</PostsDataContext.Provider>
|
||||||
|
</VarsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +666,9 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
|
|||||||
const w = s(el.widgetType);
|
const w = s(el.widgetType);
|
||||||
const settings = resolveGlobals(el.settings || {});
|
const settings = resolveGlobals(el.settings || {});
|
||||||
const shortcodes = useContext(ShortcodesContext);
|
const shortcodes = useContext(ShortcodesContext);
|
||||||
|
// 任意 widget 可由调用方按 id 注入实现(WooCommerce 归档/WPC 筛选等动态插件 widget)
|
||||||
|
const injected = el.id ? shortcodes[el.id] : undefined;
|
||||||
|
if (injected !== undefined) return <>{injected}</>;
|
||||||
const unknownFallback = () => {
|
const unknownFallback = () => {
|
||||||
const title = s(settings.heading_title) || s(settings.title);
|
const title = s(settings.heading_title) || s(settings.title);
|
||||||
const text = s(settings.editor) || s(settings.html) || s(settings.text);
|
const text = s(settings.editor) || s(settings.html) || s(settings.text);
|
||||||
@@ -658,6 +683,8 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
|
|||||||
switch (w) {
|
switch (w) {
|
||||||
case "heading":
|
case "heading":
|
||||||
return <HeadingWidget settings={settings} />;
|
return <HeadingWidget settings={settings} />;
|
||||||
|
case "woocommerce-breadcrumb":
|
||||||
|
return <BreadcrumbWidget />;
|
||||||
case "text-editor":
|
case "text-editor":
|
||||||
return <TextEditorWidget settings={settings} />;
|
return <TextEditorWidget settings={settings} />;
|
||||||
case "image":
|
case "image":
|
||||||
@@ -746,6 +773,23 @@ function WidgetNodeInner({ el }: { el: ElElement }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** # 组件 woocommerce-breadcrumb widget:面包屑(路径由 vars.breadcrumb 注入;
|
||||||
|
* 原站归档页模板 1398 的 hero 即此 widget,末项为当前页、前面项带链接) */
|
||||||
|
function BreadcrumbWidget() {
|
||||||
|
const vars = useContext(VarsContext);
|
||||||
|
const trail = vars.breadcrumb?.length ? vars.breadcrumb : [{ label: "Home", url: "/" }, { label: "Shop" }];
|
||||||
|
return (
|
||||||
|
<nav className="woocommerce-breadcrumb" aria-label="Breadcrumb">
|
||||||
|
{trail.map((n, i) => (
|
||||||
|
<Fragment key={`${n.label}-${i}`}>
|
||||||
|
{i > 0 ? <span className="ds-crumb-sep" dangerouslySetInnerHTML={{ __html: " / " }} /> : null}
|
||||||
|
{n.url && i < trail.length - 1 ? <a href={n.url}>{n.label}</a> : <span>{n.label}</span>}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** # 组件 heading widget:标题(可带 link,is_external → target=_blank;title 支持 HTML) */
|
/** # 组件 heading widget:标题(可带 link,is_external → target=_blank;title 支持 HTML) */
|
||||||
function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
|
function HeadingWidget({ settings }: { settings: Record<string, unknown> }) {
|
||||||
// 标签合法域同原站 Utils::validate_html_tag(h1..h6 / div / span / p);默认 h2
|
// 标签合法域同原站 Utils::validate_html_tag(h1..h6 / div / span / p);默认 h2
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 商店筛选侧栏 ShopFilters(对齐原站 /shop/ 的 WPC Filter Everything 组件)
|
||||||
|
* 原站结构(filter-everything 插件,filter set 25674):
|
||||||
|
* .wpc-filters-main-wrap > .wpc-filters-widget-content > .wpc-filters-widget-wrapper
|
||||||
|
* > .wpc-filters-section ×12(.wpc-filter-title 灰底标题 + .wpc-filters-ul-list 复选框列表)
|
||||||
|
* - 顺序固定:Product Type / Chip Platform / CPU Name / System support / Downlink / Uplink /
|
||||||
|
* Interface / RAM / Flash / Back-up Battery / Operating Temperature / Install
|
||||||
|
* - 后三组默认折叠(标题为按钮,带展开箭头);
|
||||||
|
* - 术语行 = 复选框 + 名称 + (计数);长列表(>8)显示 “Show more/less”;
|
||||||
|
* - 勾选切换写入 URL query(属性 slug=值1,值2),页面按筛选重查(本站等价实现)。
|
||||||
|
*/
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import type { CollectionSearch } from "~/components/website/collection-shared";
|
||||||
|
|
||||||
|
/** # 类型 单个筛选器定义(来自 getFilterConfig) */
|
||||||
|
export type ShopFilterConfig = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
values: { value: string; slug: string; count: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 常量 筛选器展示顺序(原站 WPC filter set 25674 的 section 顺序) **/
|
||||||
|
const FILTER_ORDER = [
|
||||||
|
"product-type",
|
||||||
|
"chip-platform",
|
||||||
|
"cpu",
|
||||||
|
"os",
|
||||||
|
"downlink",
|
||||||
|
"uplink",
|
||||||
|
"interface",
|
||||||
|
"ram",
|
||||||
|
"flash",
|
||||||
|
"battery",
|
||||||
|
"operating-temperature",
|
||||||
|
"install",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 常量 默认折叠的筛选组(原站实测末三组标题为按钮且内容 display:none) **/
|
||||||
|
const COLLAPSED_BY_DEFAULT = new Set(["battery", "operating-temperature", "install"]);
|
||||||
|
|
||||||
|
/** # 函数 从 search 组装 URL query(保留 orderby 等键) */
|
||||||
|
function searchToQuery(search: CollectionSearch): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(search)) {
|
||||||
|
if (k !== "page" && v) p.set(k, String(v));
|
||||||
|
}
|
||||||
|
// 任何筛选变化都回到第 1 页(原站 WPC 行为)
|
||||||
|
const qs = p.toString();
|
||||||
|
return qs ? `?${qs}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 常量 长列表筛选组(WPC wpc-show-more:默认只显示 5 项 + See more) **/
|
||||||
|
const SHOW_MORE_SLUGS = new Set(["cpu", "os", "interface", "ram", "flash"]);
|
||||||
|
|
||||||
|
/** # 组件 商店筛选侧栏 */
|
||||||
|
export function ShopFilters(props: {
|
||||||
|
config: ShopFilterConfig[];
|
||||||
|
selected: Record<string, string[]>;
|
||||||
|
total: number;
|
||||||
|
search: CollectionSearch;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [showAll, setShowAll] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 按固定顺序排列,并剔除原站未展示的 network-device
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const map = new Map(props.config.map((c) => [c.slug, c]));
|
||||||
|
return FILTER_ORDER.map((slug) => map.get(slug)).filter((x): x is ShopFilterConfig => !!x);
|
||||||
|
}, [props.config]);
|
||||||
|
|
||||||
|
/** # 函数 勾选/取消某个术语(写 URL query 并回到第 1 页) */
|
||||||
|
function toggle(attrSlug: string, valueSlug: string) {
|
||||||
|
const current = new Set(props.selected[attrSlug] || []);
|
||||||
|
if (current.has(valueSlug)) current.delete(valueSlug);
|
||||||
|
else current.add(valueSlug);
|
||||||
|
const next: CollectionSearch = { ...props.search };
|
||||||
|
delete next.page;
|
||||||
|
if (current.size) next[attrSlug] = [...current].join(",");
|
||||||
|
else delete next[attrSlug];
|
||||||
|
navigate({ to: "/collection/", search: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 清空全部筛选 */
|
||||||
|
function clearAll() {
|
||||||
|
const next: CollectionSearch = {};
|
||||||
|
if (props.search.orderby) next.orderby = props.search.orderby;
|
||||||
|
navigate({ to: "/collection/", search: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedChips = sections.flatMap((s) =>
|
||||||
|
(props.selected[s.slug] || []).map((v) => ({ attr: s.slug, value: v, label: s.values.find((x) => x.slug === v)?.value || v })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wpc-filters-main-wrap wpc-filter-set-25674" data-wpc-hide-empty="yes" data-set="25674">
|
||||||
|
<div className="wpc-filters-widget-content wpc-show-counts-yes">
|
||||||
|
<div className="wpc-filters-widget-containers-wrapper">
|
||||||
|
{/* 已选条件 chips(原站顶部区域内联展示) */}
|
||||||
|
<div className="wpc-filters-widget-top-container">
|
||||||
|
<div className="wpc-widget-top-inside">
|
||||||
|
<div className="wpc-inner-widget-chips-wrapper">
|
||||||
|
<ul className={"wpc-filter-chips-list" + (selectedChips.length ? "" : " wpc-empty-chips-container")}>
|
||||||
|
{selectedChips.map((c) => (
|
||||||
|
<li key={`${c.attr}-${c.value}`} className="wpc-filter-chip">
|
||||||
|
<span>{c.label}</span>
|
||||||
|
<button type="button" aria-label={`Remove ${c.label}`} onClick={() => toggle(c.attr, c.value)}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{selectedChips.length ? (
|
||||||
|
<button type="button" className="wpc-clear-all" onClick={clearAll}>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wpc-filter-set-widget-title">Filters</div>
|
||||||
|
<div className="wpc-filters-scroll-container">
|
||||||
|
<div className="wpc-filters-widget-wrapper">
|
||||||
|
{/* 原站 .wpc-posts-found 因 hide-empty 配置无文字,仅保留 24px 占位 */}
|
||||||
|
<div className="wpc-posts-found" data-found={props.total} aria-hidden="true" />
|
||||||
|
{sections.map((sec, idx) => {
|
||||||
|
const collapsed = COLLAPSED_BY_DEFAULT.has(sec.slug) && !expanded[sec.slug];
|
||||||
|
const all = showAll[sec.slug];
|
||||||
|
const limited = SHOW_MORE_SLUGS.has(sec.slug);
|
||||||
|
const values = all || !limited ? sec.values : sec.values.slice(0, 5);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sec.slug}
|
||||||
|
className={"wpc-filters-section wpc-filter-" + sec.slug + " wpc-filter-taxonomy wpc-filter-layout-checkboxes" + (collapsed ? " wpc-filter-collapsed" : "")}
|
||||||
|
data-fid={idx}
|
||||||
|
>
|
||||||
|
<div className="wpc-filter-header">
|
||||||
|
{COLLAPSED_BY_DEFAULT.has(sec.slug) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="widget-title wpc-filter-title"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
onClick={() => setExpanded((e) => ({ ...e, [sec.slug]: !e[sec.slug] }))}
|
||||||
|
>
|
||||||
|
<span className="wpc-wrap-icons">{sec.name}</span>
|
||||||
|
<span className="wpc-open-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="widget-title wpc-filter-title">{sec.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsed ? null : (
|
||||||
|
<div className="wpc-filter-content">
|
||||||
|
<ul className="wpc-filters-ul-list wpc-filters-checkboxes">
|
||||||
|
{values.map((v) => {
|
||||||
|
const checked = (props.selected[sec.slug] || []).includes(v.slug);
|
||||||
|
return (
|
||||||
|
<li key={v.slug} className={"wpc-checkbox-item wpc-term-item" + (checked ? " wpc-term-selected" : "")}>
|
||||||
|
<div className="wpc-term-item-content-wrapper">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
readOnly
|
||||||
|
aria-label={v.value}
|
||||||
|
id={`wpc-checkbox-${sec.slug}-${v.slug}`}
|
||||||
|
onClick={() => toggle(sec.slug, v.slug)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`wpc-checkbox-${sec.slug}-${v.slug}`}>
|
||||||
|
<span className="wpc-filter-link">{v.value}</span>
|
||||||
|
<span className="wpc-term-count">
|
||||||
|
<span>(</span>
|
||||||
|
<span className="wpc-term-count-value">{v.count}</span>
|
||||||
|
<span>)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{limited && sec.values.length > 5 ? (
|
||||||
|
<div className="wpc-filter-more-less">
|
||||||
|
<button type="button" className="wpc-toggle-a" onClick={() => setShowAll((s) => ({ ...s, [sec.slug]: !s[sec.slug] }))}>
|
||||||
|
{all ? "See less" : "See more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 商店商品区 ShopProducts(对齐原站 /shop/ WooCommerce 归档 + Elementor skin)
|
||||||
|
* 原站结构(elementor 模板 1398 的 wc-archive-products widget d761846):
|
||||||
|
* .woocommerce > .woocommerce-result-count + form.woocommerce-ordering + ul.products(4 列)
|
||||||
|
* > li.product ×24(图/标题/Read more)+ nav.woocommerce-pagination。
|
||||||
|
* 卡片样式由 Elementor skin 生成(白底 1px #C9CEE8 边框、图 1:1、标题 16/600/#444、
|
||||||
|
* 按钮白底橙字描边圆角 8),见 app.css「Shop archive」块。
|
||||||
|
*/
|
||||||
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
|
import type { CollectionSearch } from "~/components/website/collection-shared";
|
||||||
|
|
||||||
|
/** # 类型 商品卡数据(listProducts.items 子集) */
|
||||||
|
export type ShopProduct = {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
sku: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
imageAlt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** # 常量 WooCommerce 排序项(原站 orderby 下拉) **/
|
||||||
|
const ORDER_OPTIONS = [
|
||||||
|
{ value: "menu_order", label: "Default sorting" },
|
||||||
|
{ value: "popularity", label: "Sort by popularity" },
|
||||||
|
{ value: "date", label: "Sort by latest" },
|
||||||
|
{ value: "price", label: "Sort by price: low to high" },
|
||||||
|
{ value: "price-desc", label: "Sort by price: high to low" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** # 函数 原站 orderby → listProducts sort */
|
||||||
|
export function orderByToSort(orderby: string | undefined): "default" | "views" | "latest" | "price" | "price-desc" {
|
||||||
|
switch (orderby) {
|
||||||
|
case "popularity": return "views";
|
||||||
|
case "date": return "latest";
|
||||||
|
case "price": return "price";
|
||||||
|
case "price-desc": return "price-desc";
|
||||||
|
default: return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 函数 组装带筛选与排序的 URL(保留全部 search 键) */
|
||||||
|
function hrefFor(search: CollectionSearch, page: number): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(search)) {
|
||||||
|
if (k !== "page" && v) p.set(k, String(v));
|
||||||
|
}
|
||||||
|
if (page > 1) p.set("page", String(page));
|
||||||
|
const qs = p.toString();
|
||||||
|
return `/collection/${qs ? `?${qs}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** # 组件 商店商品区 */
|
||||||
|
export function ShopProducts(props: {
|
||||||
|
items: ShopProduct[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search: CollectionSearch;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize));
|
||||||
|
const start = (props.page - 1) * props.pageSize + 1;
|
||||||
|
const end = Math.min(props.total, props.page * props.pageSize);
|
||||||
|
const orderby = props.search.orderby || "menu_order";
|
||||||
|
|
||||||
|
// 页码窗口:原站 6 页全展示;页数多时用省略号(WooCommerce 默认行为)
|
||||||
|
const pages: (number | "…")[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(1);
|
||||||
|
const from = Math.max(2, props.page - 1);
|
||||||
|
const to = Math.min(totalPages - 1, props.page + 1);
|
||||||
|
if (from > 2) pages.push("…");
|
||||||
|
for (let i = from; i <= to; i++) pages.push(i);
|
||||||
|
if (to < totalPages - 1) pages.push("…");
|
||||||
|
pages.push(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="woocommerce ds-cols-4">
|
||||||
|
<div className="woocommerce-notices-wrapper" />
|
||||||
|
{props.total > 0 ? (
|
||||||
|
<p className="woocommerce-result-count" role="status">
|
||||||
|
Showing {start}–{end} of {props.total} results
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="woocommerce-result-count">It seems we can't find what you're looking for.</p>
|
||||||
|
)}
|
||||||
|
<form className="woocommerce-ordering" method="get" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
<select
|
||||||
|
name="orderby"
|
||||||
|
className="orderby"
|
||||||
|
aria-label="Shop order"
|
||||||
|
value={orderby}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next: CollectionSearch = { ...props.search };
|
||||||
|
delete next.page;
|
||||||
|
if (e.target.value === "menu_order") delete next.orderby;
|
||||||
|
else next.orderby = e.target.value;
|
||||||
|
navigate({ to: "/collection/", search: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ORDER_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input type="hidden" name="paged" value={String(props.page)} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul className="products elementor-grid ds-cols-4">
|
||||||
|
{props.items.map((p, i) => (
|
||||||
|
<li key={p.id} className={"product type-product status-publish has-post-thumbnail product-type-simple" + (i === 0 ? " first" : "")}>
|
||||||
|
<Link to="/product/$slug/" params={{ slug: p.slug }} className="woocommerce-LoopProduct-link woocommerce-loop-product__link">
|
||||||
|
{p.imageUrl ? (
|
||||||
|
<img
|
||||||
|
loading="lazy"
|
||||||
|
width={261}
|
||||||
|
height={261}
|
||||||
|
src={p.imageUrl}
|
||||||
|
className="attachment-woocommerce_thumbnail size-woocommerce_thumbnail"
|
||||||
|
alt={p.imageAlt || p.title}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<h2 className="woocommerce-loop-product__title">{p.title}</h2>
|
||||||
|
</Link>
|
||||||
|
<div className="woocommerce-loop-product__buttons">
|
||||||
|
<Link to="/product/$slug/" params={{ slug: p.slug }} className="button product_type_simple">
|
||||||
|
Read more
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<nav className="woocommerce-pagination" aria-label="Product Pagination">
|
||||||
|
<ul className="page-numbers">
|
||||||
|
{props.page > 1 ? (
|
||||||
|
<li>
|
||||||
|
<a className="prev page-numbers" aria-label={`Page ${props.page - 1}`} href={hrefFor(props.search, props.page - 1)}>
|
||||||
|
←
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{pages.map((pg, i) =>
|
||||||
|
pg === "…" ? (
|
||||||
|
<li key={`e${i}`}><span className="page-numbers dots">…</span></li>
|
||||||
|
) : (
|
||||||
|
<li key={pg}>
|
||||||
|
{pg === props.page ? (
|
||||||
|
<span aria-label={`Page ${pg}`} aria-current="page" className="page-numbers current">{pg}</span>
|
||||||
|
) : (
|
||||||
|
<a aria-label={`Page ${pg}`} className="page-numbers" href={hrefFor(props.search, pg)}>{pg}</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{props.page < totalPages ? (
|
||||||
|
<li>
|
||||||
|
<a className="next page-numbers" aria-label={`Page ${props.page + 1}`} href={hrefFor(props.search, props.page + 1)}>
|
||||||
|
→
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
import type { FilterState } from "~/server/data";
|
import type { FilterState } from "~/server/data";
|
||||||
|
|
||||||
/** # 类型 /collection 的扁平 search 参数:page 数字 + 其余属性筛选键(逗号分隔值) */
|
/** # 类型 /collection 的扁平 search 参数:page 数字 + 其余属性筛选键(逗号分隔值) */
|
||||||
export type CollectionSearch = { page?: number } & Record<string, string | undefined>;
|
export type CollectionSearch = { page?: number; orderby?: string } & Record<string, string | number | undefined>;
|
||||||
|
|
||||||
/** # 函数 解析 collection search 参数(保持扁平原样,仅规范化 page) */
|
/** # 函数 解析 collection search 参数(保持扁平原样,仅规范化 page) */
|
||||||
export function parseCollectionSearch(search: Record<string, unknown>): CollectionSearch {
|
export function parseCollectionSearch(search: Record<string, unknown>): CollectionSearch {
|
||||||
const out: Record<string, string | undefined> = {};
|
const out: Record<string, string | number | undefined> = {};
|
||||||
const page = Number(search.page);
|
const page = Number(search.page);
|
||||||
if (Number.isFinite(page) && page > 1) out.page = String(Math.floor(page));
|
if (Number.isFinite(page) && page > 1) out.page = Math.floor(page);
|
||||||
for (const [k, v] of Object.entries(search)) {
|
for (const [k, v] of Object.entries(search)) {
|
||||||
if (k === "page" || v == null) continue;
|
if (k === "page" || v == null) continue;
|
||||||
if (Array.isArray(v)) {
|
if (Array.isArray(v)) {
|
||||||
@@ -29,11 +29,13 @@ export function parseCollectionSearch(search: Record<string, unknown>): Collecti
|
|||||||
return out as CollectionSearch;
|
return out as CollectionSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** # 函数 扁平 search → 结构化筛选状态(每个属性键按逗号拆分为值数组) */
|
/** # 函数 扁平 search → 结构化筛选状态(每个属性键按逗号拆分为值数组)
|
||||||
|
* orderby(WooCommerce 排序)不是筛选字段,跳过
|
||||||
|
*/
|
||||||
export function searchToFilters(search: CollectionSearch): FilterState {
|
export function searchToFilters(search: CollectionSearch): FilterState {
|
||||||
const filters: FilterState = {};
|
const filters: FilterState = {};
|
||||||
for (const [k, v] of Object.entries(search)) {
|
for (const [k, v] of Object.entries(search)) {
|
||||||
if (k === "page" || v == null) continue;
|
if (k === "page" || k === "orderby" || v == null) continue;
|
||||||
const values = String(v)
|
const values = String(v)
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
@@ -45,13 +47,13 @@ export function searchToFilters(search: CollectionSearch): FilterState {
|
|||||||
|
|
||||||
/** # 函数 结构化筛选状态 → 扁平 search(navigate 用,page > 1 时附带) */
|
/** # 函数 结构化筛选状态 → 扁平 search(navigate 用,page > 1 时附带) */
|
||||||
export function filtersToSearch(filters: FilterState | undefined, page: number): CollectionSearch {
|
export function filtersToSearch(filters: FilterState | undefined, page: number): CollectionSearch {
|
||||||
const out: Record<string, string | undefined> = {};
|
const out: Record<string, string | number | undefined> = {};
|
||||||
if (filters) {
|
if (filters) {
|
||||||
for (const [k, vals] of Object.entries(filters)) {
|
for (const [k, vals] of Object.entries(filters)) {
|
||||||
if (vals.length) out[k] = vals.join(",");
|
if (vals.length) out[k] = vals.join(",");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (page > 1) out.page = String(page);
|
if (page > 1) out.page = page;
|
||||||
return out as CollectionSearch;
|
return out as CollectionSearch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* # 路由 产品列表 /collection/
|
* # 路由 产品列表 /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 { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getLayoutData, Layout } from "~/components/website/Layout";
|
import { getLayoutData, Layout } from "~/components/website/Layout";
|
||||||
|
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
|
||||||
import { ProductCard } from "~/components/website/ProductCard";
|
import { ProductCard } from "~/components/website/ProductCard";
|
||||||
import { Pagination } from "~/components/website/Pagination";
|
import { Pagination } from "~/components/website/Pagination";
|
||||||
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "~/components/ui/breadcrumb";
|
||||||
import { parseCollectionSearch, searchToFilters, type CollectionSearch } from "~/components/website/collection-shared";
|
import { parseCollectionSearch, searchToFilters, type CollectionSearch } from "~/components/website/collection-shared";
|
||||||
import { getProductCategoryTree, listProducts, type ProductListResult } from "~/server/data";
|
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";
|
import { useT } from "~/i18n";
|
||||||
|
|
||||||
export const Route = createFileRoute("/collection/")({
|
export const Route = createFileRoute("/collection/")({
|
||||||
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
validateSearch: parseCollectionSearch as (search: Record<string, unknown>) => CollectionSearch,
|
||||||
loaderDeps: ({ search }) => ({ search }),
|
loaderDeps: ({ search }) => ({ search }),
|
||||||
loader: async ({ deps }) => {
|
loader: async ({ deps }) => loadCollection({ data: deps.search }),
|
||||||
const [layout, cats, list] = await Promise.all([
|
|
||||||
getLayoutData(),
|
|
||||||
getProductCategoryTree().catch(() => []),
|
|
||||||
listProducts({ page: deps.search.page, filters: searchToFilters(deps.search), pageSize: 24 }).catch(
|
|
||||||
() => ({ items: [], total: 0, page: 1, pageSize: 24 }) as ProductListResult,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
return { layout, cats, list };
|
|
||||||
},
|
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "All Products | IoT Gateways & Devices | Dusun IoT" },
|
{ title: "All Products | IoT Gateways & Devices | Dusun IoT" },
|
||||||
@@ -33,12 +34,72 @@ export const Route = createFileRoute("/collection/")({
|
|||||||
component: CollectionIndexPage,
|
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() {
|
function CollectionIndexPage() {
|
||||||
const t = useT();
|
|
||||||
const data = Route.useLoaderData();
|
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");
|
const topCats = data.cats.filter((c) => !c.parentId && c.slug !== "uncategorized");
|
||||||
const page = data.list.page;
|
|
||||||
return (
|
return (
|
||||||
<Layout layout={data.layout}>
|
<Layout layout={data.layout}>
|
||||||
<div className="site-container">
|
<div className="site-container">
|
||||||
@@ -55,14 +116,13 @@ function CollectionIndexPage() {
|
|||||||
<section className="mt-5 mb-8">
|
<section className="mt-5 mb-8">
|
||||||
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
<div className="grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
|
||||||
{topCats.map((c) => (
|
{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 hover:shadow-[0_8px_24px_rgba(16,42,76,0.1)]">
|
<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="px-4 pt-3.5 pb-[18px]">
|
||||||
<div className="mb-1.5 text-[15px] leading-[1.45] font-semibold">{c.name}</div>
|
<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 className="text-[13px] text-muted-foreground">{t("collection_products_count", { total: Number(c.count) })}</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{topCats.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">Product categories are being updated.</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -70,14 +130,9 @@ function CollectionIndexPage() {
|
|||||||
{data.list.items.map((p) => (
|
{data.list.items.map((p) => (
|
||||||
<ProductCard key={p.id} p={p} />
|
<ProductCard key={p.id} p={p} />
|
||||||
))}
|
))}
|
||||||
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products found. They may still be syncing — please check back soon.</p> : null}
|
{data.list.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">No products found.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination page={data.list.page} total={data.list.total} pageSize={data.list.pageSize} makeHref={(p) => `/collection/?page=${p}`} />
|
||||||
page={page}
|
|
||||||
total={data.list.total}
|
|
||||||
pageSize={data.list.pageSize}
|
|
||||||
makeHref={(p) => `/collection/?page=${p}`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
+16
-2
@@ -114,6 +114,11 @@ export async function getFooterTemplate() {
|
|||||||
return getPublishedContent("footer", "template");
|
return getPublishedContent("footer", "template");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** # 函数 产品归档页 Elementor 模板(legacy elementor_library post 1398) */
|
||||||
|
export async function getShopTemplate() {
|
||||||
|
return getPublishedContent("shop-archive", "template");
|
||||||
|
}
|
||||||
|
|
||||||
/** # 类型 产品筛选状态:每个筛选字段已选值列表 */
|
/** # 类型 产品筛选状态:每个筛选字段已选值列表 */
|
||||||
export type FilterState = Record<string, string[]>;
|
export type FilterState = Record<string, string[]>;
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ export async function listProducts(opts: {
|
|||||||
q?: string;
|
q?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sort?: "latest" | "title" | "views";
|
sort?: "default" | "latest" | "title" | "views" | "price" | "price-desc";
|
||||||
}): Promise<ProductListResult> {
|
}): Promise<ProductListResult> {
|
||||||
const page = Math.max(1, opts.page || 1);
|
const page = Math.max(1, opts.page || 1);
|
||||||
const pageSize = Math.min(48, Math.max(8, opts.pageSize || 24));
|
const pageSize = Math.min(48, Math.max(8, opts.pageSize || 24));
|
||||||
@@ -181,7 +186,16 @@ export async function listProducts(opts: {
|
|||||||
async function runProductQuery(conds: unknown[], page: number, pageSize: number, sort?: string): Promise<ProductListResult> {
|
async function runProductQuery(conds: unknown[], page: number, pageSize: number, sort?: string): Promise<ProductListResult> {
|
||||||
const where = and(...(conds as never[]));
|
const where = and(...(conds as never[]));
|
||||||
const [{ total }] = await db.select({ total: count() }).from(products).where(where);
|
const [{ total }] = await db.select({ total: count() }).from(products).where(where);
|
||||||
const order = sort === "title" ? asc(products.title) : sort === "views" ? desc(products.views) : desc(products.createdAt);
|
// 排序(对齐原站 /shop/ 的 orderby:默认=WooCommerce menu_order、
|
||||||
|
// popularity=浏览量、date/最新=发布时间、price/price-desc=价格升降;
|
||||||
|
// menu_order 由 scripts/import-product-menu-order.ts 写入 products.meta.menu_order)
|
||||||
|
const order =
|
||||||
|
sort === "title" ? asc(products.title)
|
||||||
|
: sort === "views" ? desc(products.views)
|
||||||
|
: sort === "price" ? asc(products.price)
|
||||||
|
: sort === "price-desc" ? desc(products.price)
|
||||||
|
: sort === "latest" ? desc(products.createdAt)
|
||||||
|
: sql`NULLIF(${products.meta}->>'menu_order', '')::int ASC NULLS LAST, ${products.id} ASC`;
|
||||||
const items = await db
|
const items = await db
|
||||||
.select({
|
.select({
|
||||||
id: products.id,
|
id: products.id,
|
||||||
|
|||||||
@@ -893,3 +893,89 @@ body.dialog-prevent-scroll { overflow: hidden; }
|
|||||||
.ds-inquiry-popup .ele-new-form4-dif .wpcf7-form p:nth-of-type(6) { width: 48%; margin: 0 0 2% 2%; }
|
.ds-inquiry-popup .ele-new-form4-dif .wpcf7-form p:nth-of-type(6) { width: 48%; margin: 0 0 2% 2%; }
|
||||||
.ds-inquiry-popup .ele-new-form4-dif .wpcf7-form p:nth-of-type(7) { width: 100%; margin: 0 0 2%; }
|
.ds-inquiry-popup .ele-new-form4-dif .wpcf7-form p:nth-of-type(7) { width: 100%; margin: 0 0 2%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== # 页面类型 产品归档 /shop/(原站 elementor_library post 1398 + WooCommerce + WPC 筛选)
|
||||||
|
* hero a2ab15c:bg #F4F5F8 / padding 0 30 / content 1400,面包屑 14.72px #767676(下距 1em);
|
||||||
|
* 主体 65176b9:bg #F4F5F8 / padding 0 30 30,两列 20%/80%(各白底 + 20px 内边距);
|
||||||
|
* 左列 WPC 筛选:section 1px #C9CEE8、标题 #F4F5F8 padding 10、行高 20.8、组间距 32;
|
||||||
|
* 右列:h1 1.5rem/600、结果计数 + 排序(42px 高 #C9CEE8 圆角 4)、24 卡 4 列 gap 10、
|
||||||
|
* 卡片 1px 边框/圆角 4/底部 1.5em、图 1:1、标题 16/600/#444、按钮白底橙字描边圆角 8、
|
||||||
|
* 分页 margin-top 40(数字 25×32 padding 8、当前页 #E9E6ED/#816F98)。 */
|
||||||
|
[data-el="a2ab15c"] > div { align-items: center; }
|
||||||
|
[data-el="a2ab15c"] .woocommerce-breadcrumb { margin: 0 0 1em; font-size: 0.92em; color: #767676; line-height: 1.5; }
|
||||||
|
[data-el="a2ab15c"] .woocommerce-breadcrumb a { color: #767676; text-decoration: none; }
|
||||||
|
[data-el="a2ab15c"] .woocommerce-breadcrumb a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* WPC 筛选侧栏 */
|
||||||
|
[data-el="98d2008"] .wpc-filters-main-wrap { width: auto; }
|
||||||
|
[data-el="98d2008"] .wpc-posts-found { height: 24px; margin: 0; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-set-widget-title { margin: 0 0 12px; font-size: 16px; line-height: 24px; color: #333; }
|
||||||
|
[data-el="98d2008"] .wpc-filters-section { border: 1px solid #C9CEE8; margin: 0 0 32px; background: #fff; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-content { padding: 10px; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-title {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
width: 100%; margin: 0; padding: 10px; border: 0; background: #F4F5F8;
|
||||||
|
font-size: 16px; font-weight: 400; line-height: 24px; color: #333; text-align: left;
|
||||||
|
}
|
||||||
|
[data-el="98d2008"] button.wpc-filter-title { cursor: pointer; }
|
||||||
|
[data-el="98d2008"] .wpc-open-icon::before {
|
||||||
|
content: ""; display: inline-block; width: 8px; height: 8px;
|
||||||
|
border-right: 1px solid #333; border-bottom: 1px solid #333; transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
[data-el="98d2008"] .wpc-filter-title[aria-expanded="true"] .wpc-open-icon::before { transform: rotate(-135deg); }
|
||||||
|
[data-el="98d2008"] .wpc-filters-ul-list { list-style: none; margin: 0; padding: 2px 0 0; }
|
||||||
|
[data-el="98d2008"] .wpc-checkbox-item { display: list-item; margin: 0 0 12px; padding: 0; font-size: 16px; line-height: 1.3; }
|
||||||
|
[data-el="98d2008"] .wpc-term-item-content-wrapper { display: flex; align-items: center; gap: 4px; }
|
||||||
|
[data-el="98d2008"] .wpc-term-item-content-wrapper input[type="checkbox"] { width: 13px; height: 13px; margin: 0; accent-color: #ED6C00; }
|
||||||
|
[data-el="98d2008"] .wpc-term-item-content-wrapper label { display: flex; align-items: baseline; gap: 2px; margin: 0; font-size: 16px; font-weight: 400; line-height: 1.3; color: #333; cursor: pointer; }
|
||||||
|
[data-el="98d2008"] .wpc-term-selected .wpc-filter-link { color: #ED6C00; }
|
||||||
|
[data-el="98d2008"] .wpc-term-count { color: #333; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-more-less { padding: 0; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-more-less button { border: 0; background: none; padding: 0; color: #A4A8B7; font-size: 16px; line-height: 24px; cursor: pointer; }
|
||||||
|
[data-el="98d2008"] .wpc-inner-widget-chips-wrapper { margin: 0 0 8px; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-chips-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-chip { display: inline-flex; align-items: center; gap: 4px; border: 1px solid #C9CEE8; border-radius: 3px; padding: 2px 6px; font-size: 13px; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-chip button { border: 0; background: none; cursor: pointer; padding: 0; color: #767676; }
|
||||||
|
[data-el="98d2008"] .wpc-clear-all { border: 0; background: none; padding: 0; margin-top: 6px; color: #ED6C00; font-size: 13px; cursor: pointer; }
|
||||||
|
[data-el="98d2008"] .wpc-filter-collapsed .wpc-filter-content { display: none; }
|
||||||
|
|
||||||
|
/* 右列商品区 */
|
||||||
|
[data-el="d761846"] .woocommerce::after { content: ""; display: block; clear: both; }
|
||||||
|
[data-el="d761846"] .woocommerce-result-count { float: left; margin: 6px 0 16px; font-size: 16px; line-height: 24px; color: #333; }
|
||||||
|
[data-el="d761846"] .woocommerce-ordering { float: right; margin: 0 0 16px; }
|
||||||
|
[data-el="d761846"] .woocommerce-ordering select {
|
||||||
|
height: 42px; padding: 8px 16px; border: 1px solid #C9CEE8; border-radius: 4px;
|
||||||
|
background: #efefef; color: #000; font-size: 16px; font-family: inherit;
|
||||||
|
}
|
||||||
|
[data-el="d761846"] ul.products {
|
||||||
|
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px; list-style: none; margin: 0; padding: 0; clear: both;
|
||||||
|
}
|
||||||
|
[data-el="d761846"] ul.products li.product {
|
||||||
|
display: flex; flex-direction: column; justify-content: space-between;
|
||||||
|
text-align: center; background: #fff; border: 1px solid #C9CEE8; border-radius: 4px;
|
||||||
|
padding: 0 0 1.5em; margin: 0; transition: box-shadow 0.3s, border-color 0.3s;
|
||||||
|
}
|
||||||
|
[data-el="d761846"] ul.products li.product:hover { box-shadow: 0 10px 20px 0 rgba(35, 37, 121, 0.15); border-color: rgba(35, 37, 121, 0.15); }
|
||||||
|
[data-el="d761846"] .woocommerce-loop-product__link { display: block; text-decoration: none; color: inherit; }
|
||||||
|
[data-el="d761846"] ul.products li.product img { display: block; width: 100%; height: auto; margin: 0; }
|
||||||
|
[data-el="d761846"] ul.products li.product .woocommerce-loop-product__title {
|
||||||
|
margin: 0; padding: 8px 0; font-size: 16px; font-weight: 600; line-height: 1.2; color: #444;
|
||||||
|
}
|
||||||
|
[data-el="d761846"] .woocommerce-loop-product__buttons { margin-top: auto; display: flex; flex-direction: column; align-items: center; }
|
||||||
|
[data-el="d761846"] ul.products li.product .button {
|
||||||
|
display: inline-block; margin: 1rem auto 0; padding: 0.618em 1em; line-height: 1;
|
||||||
|
font-size: 1em; font-weight: 400; color: #FFA11B; background: #fff;
|
||||||
|
border: 1px solid #FFA11B; border-radius: 8px; text-decoration: none; transition: all 0.3s;
|
||||||
|
}
|
||||||
|
[data-el="d761846"] ul.products li.product .button:hover { color: #fff; background: #ED6C00; border-color: #ED6C00; }
|
||||||
|
[data-el="d761846"] .woocommerce-pagination { margin: 40px 0 0; clear: both; text-align: center; }
|
||||||
|
[data-el="d761846"] .woocommerce-pagination ul.page-numbers { display: inline-flex; justify-content: center; align-items: center; list-style: none; margin: 1px; padding: 0; border: 1px solid #CFC8D8; }
|
||||||
|
[data-el="d761846"] .woocommerce-pagination ul.page-numbers li { display: block; }
|
||||||
|
[data-el="d761846"] .woocommerce-pagination ul.page-numbers .page-numbers {
|
||||||
|
display: block; min-width: 25px; padding: 8px; font-size: 16px; line-height: 1;
|
||||||
|
color: #ED6C00; text-decoration: none;
|
||||||
|
}
|
||||||
|
[data-el="d761846"] .woocommerce-pagination ul.page-numbers .current { background: #E9E6ED; color: #816F98; }
|
||||||
|
[data-el="d761846"] .woocommerce-pagination ul.page-numbers .dots { color: #333; }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user