v0.10.27: /product/$slug 产品单模板一比一(模板2741/29972 补录、Woo 图廊/meta/tabs/相关产品组件、page_css 130 篇、动态按钮落地)+ css-translate 共享模块

This commit is contained in:
bigcode
2026-11-03 05:18:10 +08:00
parent d016be03ae
commit 35d154870a
9 changed files with 472 additions and 277 deletions
+120 -195
View File
@@ -1,38 +1,115 @@
/**
* # 路由 产品详情 /product/$slug/
* 对齐原站产品页模板:无面包屑;左图廊 + 右标题/Category/短描述/分隔线/
* Specification 与 Get A Quote 按钮;描述(Elementor 或 HTML)+
* Additional information(属性表)+ 相关产品。询盘走 Get A Quote 弹窗。
* 一比一复刻原站 Elementor 产品单模板(elementor_library 2741,dump 补录):
* a7db516 hero(图廊 + 标题/meta/短描述/按钮)→ 43df588(数据标签页:Description 面板嵌
* 产品自身 elementor 文档 + Additional information 属性表)→ 6819437/b4f3f0c/e12a7a7/
* 5955180(相关产品)/a0a5183(CF7 表单)/bed3f8a/9e7188e。
* 模板内动态链接:ACF 规格书按钮 → products.spec_url(非 URL 时回退 /contact-us/)、
* popup 按钮 → elementor-action 触发全局询盘弹窗。
*/
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { getLayoutData, Layout } from "~/components/website/Layout";
import { ElementorRenderer, extractElementor } from "~/components/elementor/Renderer";
import { InquiryForm } from "~/components/website/InquiryForm";
import { ProductCard } from "~/components/website/ProductCard";
import { ElementorRenderer, extractElementor, type ElDocument, type ElElement, type WooProductContext } from "~/components/elementor/Renderer";
import { Button } from "~/components/ui/button";
import { getProductDetail, getFilterConfig, listProducts, type ProductListResult } from "~/server/data";
import { Link } from "@tanstack/react-router";
import { getProductDetail, getProductTemplate, getEmbeddedTemplate, listProducts, loadPostsWidgetsData, type ProductListResult } from "~/server/data";
import { resolveMediaVariantUrl } from "~/utils/image-variant";
import { useT } from "~/i18n";
export const Route = createFileRoute("/product/$slug")({
loader: async ({ params }) => {
const [layout, detail, filterConfig] = await Promise.all([
const [layout, detail, template] = await Promise.all([
getLayoutData(),
getProductDetail(params.slug).catch(() => null),
getFilterConfig().catch(() => []),
getProductTemplate().catch(() => null),
]);
// 未找到产品:返回 notFound 标记(组件里渲染 404)
if (!detail) return { layout, detail: null, filterConfig, related: { items: [], total: 0, page: 1, pageSize: 4 } as ProductListResult };
const related = await listProducts({ category: detail.categories[0]?.slug, page: 1, pageSize: 8 }).catch(() => ({ items: [], total: 0, page: 1, pageSize: 8 }) as ProductListResult);
// meta 转为具体 JSON 类型(jsonb 列推断为 unknown,需显式收窄)
if (!detail)
return { layout, detail: null, tpl: null as null | { doc: ElDocument; pageCss?: string; woo: WooProductContext; templates: Record<string, ElElement[]>; postsData: Record<string, never[]> } };
const related = await listProducts({ category: detail.categories[0]?.slug, page: 1, pageSize: 8 }).catch(
() => ({ items: [], total: 0, page: 1, pageSize: 8 }) as ProductListResult,
);
const meta = (detail.meta ?? null) as Record<string, unknown> | null;
const gallery = detail.gallery.map((g) => ({ url: g.url, alt: g.alt }));
return {
layout,
detail: { ...detail, meta, gallery },
filterConfig,
related: { ...related, items: related.items.filter((r) => r.slug !== detail.slug).slice(0, 4) },
// 图廊变体:主图 600x600、缩略 300 档(原站 data-thumb 100x100,取 300 保证清晰度)
const mainImage = {
url: detail.featuredUrl ? resolveMediaVariantUrl(detail.featuredUrl, 600) : "",
alt: detail.featuredAlt ?? detail.title,
};
const thumbs = [
...(detail.featuredUrl ? [{ url: resolveMediaVariantUrl(detail.featuredUrl, 300), alt: detail.featuredAlt ?? detail.title }] : []),
...gallery.map((g) => ({ url: resolveMediaVariantUrl(g.url, 300), alt: g.alt ?? detail.title })),
];
const productDoc = extractElementor(meta);
const woo: WooProductContext = {
title: detail.title,
sku: detail.sku ?? "",
shortDescription: detail.shortDescription ?? "",
categories: detail.categories,
mainImage,
thumbs,
attributes: detail.attributes.map((a) => ({ attr: a.attr, value: a.value })),
productDoc: productDoc ?? undefined,
productPageCss: typeof meta?.page_css === "string" ? meta.page_css : undefined,
related: related.items.filter((r) => r.slug !== detail.slug).slice(0, 4).map((r) => ({
id: r.id,
slug: r.slug,
title: r.title,
sku: r.sku,
imageUrl: r.imageUrl,
imageAlt: r.imageAlt,
})),
};
// 模板动态链接落地:ACF 规格书 → spec_url(非 URL 回退 /contact-us/);popup → 询盘弹窗触发
let doc: ElDocument | null = null;
let pageCss: string | undefined;
if (template?.doc) {
doc = JSON.parse(JSON.stringify(template.doc)) as ElDocument;
pageCss = template.pageCss;
const specHref = /^(\/|https?:)/.test(detail.specUrl ?? "") ? (detail.specUrl as string) : "/contact-us/";
const walk = (el: unknown) => {
if (Array.isArray(el)) {
el.forEach(walk);
return;
}
if (!el || typeof el !== "object") return;
const e = el as { widgetType?: unknown; settings?: Record<string, unknown>; elements?: unknown[] };
if (e.widgetType === "button" && e.settings) {
const dyn = JSON.stringify(e.settings.__dynamic__ ?? "");
if (dyn.includes("acf-url")) e.settings.url = specHref;
else if (dyn.includes("popup")) e.settings.url = "/?elementor-action=popup";
}
(Array.isArray(e.elements) ? e.elements : []).forEach(walk);
};
walk(doc);
}
// 内嵌模板短代码(如 bed3f8a → template-29972「IoT Gateways for Recommendation」)
const templates: Record<string, ElElement[]> = {};
if (doc) {
const scIds = new Map<number, string[]>();
const walkSc = (els: ElElement[] | undefined) => {
for (const el of els || []) {
const st = (el.settings || {}) as Record<string, unknown>;
const m = /\[elementor-template\s+id=["']?(\d+)/.exec(typeof st.shortcode === "string" ? st.shortcode : "");
if (m && el.id) {
const list = scIds.get(Number(m[1])) ?? [];
list.push(el.id);
scIds.set(Number(m[1]), list);
}
walkSc(el.elements as ElElement[] | undefined);
}
};
walkSc(doc as unknown as ElElement[]);
for (const [tid, widgetIds] of scIds) {
const row = await getEmbeddedTemplate(tid);
const elDoc = extractElementor((row?.meta ?? null) as Record<string, unknown> | null);
if (Array.isArray(elDoc) && elDoc.length) for (const wid of widgetIds) templates[wid] = elDoc;
}
}
// posts widget 预取(模板 + 产品文档里全部 posts widget)
const postsData = doc
? await loadPostsWidgetsData([...(doc as unknown as ElElement[]), ...((Array.isArray(productDoc) ? productDoc : []) as ElElement[])])
: {};
return { layout, detail: { ...detail, meta, gallery }, tpl: doc ? { doc, pageCss, woo, templates, postsData } : null };
},
head: ({ loaderData }) => ({
meta: [
@@ -43,186 +120,34 @@ export const Route = createFileRoute("/product/$slug")({
component: ProductPage,
});
/** # 组件 产品详情页 */
/** # 组件 产品详情页(模板 2741 直出) */
function ProductPage() {
const t = useT();
const data = Route.useLoaderData();
const d = data.detail;
const [quoteOpen, setQuoteOpen] = useState(false);
if (!d) return <Layout layout={data.layout}><NotFound /></Layout>;
if (!data.detail || !data.tpl) {
return (
<Layout layout={data.layout}>
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>{t("product_not_found")}</p>
<Button asChild>
<Link to="/shop/">Back to Shop</Link>
</Button>
</div>
</Layout>
);
}
return (
<Layout layout={data.layout}>
<div className="site-container">
<div className="my-3 mb-8 grid grid-cols-1 gap-9 min-[900px]:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
<Gallery images={[...(d.featuredUrl ? [{ url: d.featuredUrl, alt: d.featuredAlt }] : []), ...d.gallery]} title={d.title} />
<div>
<h1 className="mt-0 text-[26px] leading-[1.35]">{d.title}</h1>
{d.categories.length > 0 ? (
<p className="my-1.5 text-muted-foreground">
{d.categories.length > 1 ? t("product_categories") : t("product_category")}{" "}
{d.categories.map((cat, i) => (
<span key={cat.slug}>
{i > 0 ? ", " : ""}
<Link to="/collection/$slug/" params={{ slug: cat.slug }} className="text-brand">{cat.name}</Link>
</span>
))}
</p>
) : null}
{d.shortDescription ? (
<div className="content-html" dangerouslySetInnerHTML={{ __html: d.shortDescription }} />
) : null}
{/* 分隔线 + 操作按钮(对齐原站 Elementor 模板:Specification / Get A Quote) */}
<hr className="my-5 border-t border-line" />
<div className="flex flex-wrap gap-3">
<Button variant="outline" className="border-brand px-4 py-2 text-brand hover:bg-brand hover:text-white" onClick={() => scrollToSpec(d)}>
<SpecGlyph /> {t("product_specification")}
</Button>
<Button variant="outline" className="border-brand px-4 py-2 text-brand hover:bg-brand hover:text-white" onClick={() => setQuoteOpen(true)}>
{t("product_get_quote")}
</Button>
</div>
</div>
</div>
<Description detail={d} />
{d.attributes.length > 0 ? (
<section id="additional-information" className="my-10 scroll-mt-24">
<h2>{t("product_additional_info")}</h2>
<table className="my-3 w-full border-collapse text-sm">
<tbody>
{d.attributes.map((a, i) => (
<tr key={i}>
<th className="w-[32%] border border-line bg-soft px-3 py-2 text-left font-semibold">{a.attr}</th>
<td className="border border-line px-3 py-2 text-left">{a.value}</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
<section className="my-10">
<h2>{t("product_related")}</h2>
<div className="mt-[18px] grid grid-cols-2 gap-[18px] min-[960px]:grid-cols-4">
{data.related.items.map((p) => (
<ProductCard key={p.id} p={p} />
))}
{data.related.items.length === 0 ? <p className="py-[60px] text-center text-muted-foreground">{t("product_no_related")}</p> : null}
</div>
</section>
</div>
{/* Get A Quote 弹窗(对齐原站 Elementor popup 询盘表单) */}
{quoteOpen ? (
<div className="fixed inset-0 z-[300] flex items-start justify-center overflow-auto bg-black/55 p-4 py-[60px]" onClick={() => setQuoteOpen(false)}>
<div className="relative w-full max-w-[560px] rounded-lg bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-label="Close"
onClick={() => setQuoteOpen(false)}
className="absolute top-2.5 right-3 cursor-pointer border-0 bg-transparent text-2xl leading-none text-muted-foreground hover:text-ink"
>
×
</button>
<InquiryForm formKey="product-inquiry-form" productInterest={d.title} title={t("product_quote_title", { name: d.title })} />
</div>
</div>
) : null}
<ElementorRenderer
doc={data.tpl.doc}
pageCss={data.tpl.pageCss}
woo={data.tpl.woo}
postsData={data.tpl.postsData}
shortcodes={Object.fromEntries(
Object.entries(data.tpl.templates).map(([id, doc]) => [id, <ElementorRenderer key={id} doc={doc} postsData={data.tpl!.postsData} />]),
)}
/>
</Layout>
);
}
/** # 函数 Specification 按钮:有属性表滚动到 Additional information,有规格书则下载 */
function scrollToSpec(d: { attributes: unknown[]; specUrl?: string | null }) {
if (d.attributes.length > 0) {
document.getElementById("additional-information")?.scrollIntoView({ behavior: "smooth" });
return;
}
if (d.specUrl) window.open(d.specUrl, "_blank", "noreferrer");
}
/** # 组件 Specification 按钮文件图标(对齐原站 far-file-alt) */
function SpecGlyph() {
return (
<svg width="15" height="15" viewBox="0 0 384 512" fill="currentColor" aria-hidden="true">
<path d="M288 248v28c0 6.6-5.4 12-12 12H108c-6.6 0-12-5.4-12-12v-28c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12zm-12 72H108c-6.6 0-12 5.4-12 12v28c0 6.6 5.4 12 12 12h168c6.6 0 12-5.4 12-12v-28c0-6.6-5.4-12-12-12zm108-188.1V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V48C0 21.5 21.5 0 48 0h204.1C264.8 0 277 5.1 286 14.1L369.9 98c9 8.9 14.1 21.2 14.1 33.9zm-128-80V128h76.1L256 51.9zM336 464V176H232c-13.3 0-24-10.7-24-24V48H48v416h288z" />
</svg>
);
}
/** # 组件 图廊:主图 + 缩略图切换(纯 React state) */function Gallery(props: { images: { url: string; alt: string | null }[]; title: string }) {
const [active, setActive] = useState(0);
const [zoomOpen, setZoomOpen] = useState(false);
if (!props.images.length) {
return <div className="flex aspect-[4/3] items-center justify-center overflow-hidden rounded-lg border border-line bg-white" aria-label={props.title} />;
}
const img = props.images[Math.min(active, props.images.length - 1)];
return (
<div>
<div className="relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-lg border border-line bg-white">
<img src={img.url} alt={img.alt || props.title} className="max-h-full max-w-full object-contain" />
{/* 全屏查看触发图标(对齐原站 gallery trigger) */}
<button
type="button"
aria-label="View full-screen image gallery"
onClick={() => setZoomOpen(true)}
className="absolute top-2 right-2 cursor-pointer border-0 bg-transparent text-[15px] text-muted-foreground hover:text-brand"
>
🔍
</button>
</div>
{props.images.length > 1 ? (
<div className="mt-2.5 flex flex-wrap gap-2">
{props.images.map((t, i) => (
<img
key={i}
src={t.url}
alt={t.alt || `${props.title} thumbnail ${i + 1}`}
className={"size-[72px] cursor-pointer rounded-md border-2 bg-white object-contain p-1 " + (i === active ? "border-brand" : "border-line")}
onClick={() => setActive(i)}
loading="lazy"
/>
))}
</div>
) : null}
{zoomOpen ? (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/80 p-6" onClick={() => setZoomOpen(false)}>
<img src={img.url} alt={img.alt || props.title} className="max-h-full max-w-full rounded bg-white object-contain" onClick={(e) => e.stopPropagation()} />
<button
type="button"
aria-label="Close"
onClick={() => setZoomOpen(false)}
className="absolute top-4 right-5 cursor-pointer border-0 bg-transparent text-3xl leading-none text-white"
>
×
</button>
</div>
) : null}
</div>
);
}
/** # 组件 产品描述:有 Elementor 用渲染器,否则输出 description HTML */
function Description(props: { detail: { meta: Record<string, unknown> | null; description: string | null } }) {
const elementor = extractElementor(props.detail.meta);
if (elementor) return <ElementorRenderer doc={elementor} />;
if (props.detail.description) {
return <div className="content-html" dangerouslySetInnerHTML={{ __html: props.detail.description }} />;
}
return null;
}
/** # 组件 产品未找到 */
function NotFound() {
const t = useT();
return (
<div className="pt-[70px] pb-10 text-center">
<h1 className="m-0 text-[64px] text-brand">404</h1>
<p>{t("product_not_found")}</p>
<Button asChild>
<Link to="/collection/">{t("product_browse_all")}</Link>
</Button>
</div>
);
}