v0.10.27: /product/$slug 产品单模板一比一(模板2741/29972 补录、Woo 图廊/meta/tabs/相关产品组件、page_css 130 篇、动态按钮落地)+ css-translate 共享模块
This commit is contained in:
+120
-195
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user