f5dc2f7624
User report: product detail page was wrong — section titles are
per-product custom data (CMS wppop_sections migration: Packing &
Delivery / Our Exhibition / Our Certifications / Our Company / FAQ),
not fixed headings; they must drive an anchor tab system like the
CMS [locale]/products/[slug].astro detailTabs.
Data side (both layers, task option 1 + 2):
- theme_pro.product_detail_sections: fields.sections
[{title, content}] JSON is the structured source; legacy sync
output that appended sections as trailing body H2 blocks is
recognized by known/data titles and split out (overview keeps the
rest; same-title blocks dedupe to the structured version; Features
body block drops when fields.features renders it)
- sync_cms_data.build_product: store sections/features as structured
fields JSON, stop appending H2 blocks to the body; stats now count
entries with a sections field
- Specifications table no longer leaks structural JSON field keys
(sections/features/specs/gallery/categories) — scalar spec keys
only
Template side (trade-pro product.html, replaces 12a panel
switching):
- tab sequence is fully data-driven and matches the CMS: Product
Details (always) -> custom sections (data titles) ->
Specifications / Features (shown only when data exists)
- tabs are #anchor links, not panel toggles: all sections render
flat with visible titles (readable and crawlable without JS; native
anchor jump), JS adds smooth scrolling + scrollspy highlight +
tab centering in the horizontal scroll row
- section ids are slug-derived (sec-packing-delivery), duplicate
titles get an index suffix; features grid ports the CMS
detail-feature-row
Tests: test_trade_pro_product_layout_structure rewritten for the
anchor-tab contract — sections via fields, legacy body-H2 fallback,
spec/features conditional tabs, no-sections product showing only
Product Details + Specifications, spec table free of JSON key leaks,
updated pro.css needles. 30 passed; the 1 failure
(test_trade_pro_blocks_visual_bridge) fails identically on the
previous commit (pre-existing, unrelated).
Co-Authored-By: Claude Code <noreply@example.com>
247 lines
12 KiB
HTML
247 lines
12 KiB
HTML
{# trade-pro product detail — cms 产品详情精确对齐重做(Task 12a,重做 10b);
|
||
Task 15 选项卡改锚点 Tab + 每产品自定义区块。
|
||
设计源(唯一权威依据)D:/project/cms/runtimes/astro-site-runtime/src/pages/
|
||
[locale]/products/[slug].astro,区块序列逐块对照:
|
||
detail-hero(面包屑 + 1.08/0.92 双栏:画廊/标题区,cms 产品名 h2 全页无 h1)
|
||
→ product-tabbed(sticky 锚点选项卡:Product Details 正文恒有 → 每产品自定义
|
||
区块 detail_sections(wppop_sections 迁移,标题来自数据)→ Specifications /
|
||
Features 按数据显隐)→ related-detail(sf-tp-card 卡片网格)→ quote-section
|
||
(左联系信息 + 右 CTA/外部表单)。
|
||
与 cms 的三处结构决策:
|
||
1. 选项卡为锚点跳转非面板切换(用户要求,Task 15):Tab 是 #anchor 链接,
|
||
区块全部平铺渲染 + panel-title 常显——无 JS 时原生锚点跳转、全部区块可读,
|
||
静态可抓取;JS 渐进增强平滑滚动 + scrollspy 高亮当前 Tab 并在横滚行内居中。
|
||
2. 正文不拆表:WordPress 规格表 <table> 直接在 body HTML 里,正文整体作为
|
||
Product Details 区块渲染(.sf-tp-product-body rich-body 表格样式:全宽/
|
||
发丝框/斑马纹/覆盖内联宽高/单元格 div 净空);specs 变量有数据时另设
|
||
Specifications 区块(干净 spec-table,每行两对键值,从 fields 读)。
|
||
自定义区块数据由 theme_pro.product_detail_sections 供给:fields.sections
|
||
结构化优先,旧同步产物的 body H2 段识别拆出(正文 overview 不重复渲染)。
|
||
3. 画廊交互复用平台 pro.js 既有钩子(.pro-product-media /
|
||
.pro-gallery-thumbs / [data-gallery-image]):缩略图点击换主图 + 主图灯箱,
|
||
零新增 JS;无 JS 时全部图片仍平铺可读。无图回退封面,再无则占位渐变
|
||
(空值不破版)。 #}
|
||
<section class="sf-tp-product-hero">
|
||
<div class="wrap">
|
||
<nav class="sf-tp-breadcrumb" aria-label="Breadcrumb" data-reveal>
|
||
<a href="/">Home</a><a href="/products/">Products</a><span class="sf-tp-breadcrumb-current">{{ page.title }}</span>
|
||
</nav>
|
||
<div class="sf-tp-product-grid">
|
||
{# pro.js 经 .pro-gallery-thumbs 的 parentElement 定位 .pro-product-media img——
|
||
缩略图容器必须保持为 .pro-product-media 的兄弟节点(不可外包滚动容器) #}
|
||
<div class="sf-tp-product-gallery" data-reveal>
|
||
{% if gallery %}
|
||
<div class="sf-tp-product-media pro-product-media">
|
||
<img class="sf-tp-product-main-img" src="{{ gallery[0] }}" alt="{{ page.title }}" width="800" height="600" loading="eager" decoding="async">
|
||
</div>
|
||
{% if gallery|length > 1 %}
|
||
<div class="sf-tp-product-thumbs pro-gallery-thumbs" aria-label="Product image gallery">
|
||
{% for photo in gallery %}
|
||
<button type="button" class="sf-tp-product-thumb" data-gallery-image="{{ photo }}" aria-label="View image {{ loop.index }}" aria-pressed="{{ 'true' if loop.first else 'false' }}"><img src="{{ photo }}" alt="{{ page.title }} image {{ loop.index }}" width="150" height="100" loading="lazy" decoding="async"></button>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
<div class="sf-tp-product-media pro-product-media">
|
||
<div class="sf-tp-photo-plain" aria-hidden="true"><span>PRODUCT</span></div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
<div class="sf-tp-product-summary" data-reveal style="--reveal-i: 1">
|
||
<span class="eyebrow">PRODUCT DETAILS / {{ fields.get('brand') or 'CATALOGUE' }}</span>
|
||
{# cms 产品名为 h2 量级(2xl 非巨型);平台全站单 h1 契约
|
||
(test_theme_pro_v3 全页 h1==1)保留 h1 标签、字号用 cms 2xl #}
|
||
<h1 class="sf-tp-product-title">{{ page.title }}</h1>
|
||
<p class="sf-tp-product-lead">{{ page.summary or 'Detailed product information, specifications and export packaging are published on this page.' }}</p>
|
||
{% if fields.get('brand') or fields.get('model') %}
|
||
<p class="sf-tp-product-meta">{% if fields.get('brand') %}<span>Brand · {{ fields.get('brand') }}</span>{% endif %}{% if fields.get('model') %}<span>Model · {{ fields.get('model') }}</span>{% endif %}</p>
|
||
{% endif %}
|
||
<div class="sf-tp-product-actions">
|
||
<a class="sf-tp-btn" href="/contact/">Get a quote {{ icon('arrow-right') }}</a>
|
||
<a class="sf-tp-btn sf-tp-btn--ghost" href="/products/">All products {{ icon('arrow-right') }}</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{# 选项卡详情(cms .product-tabbed,Task 15 改锚点 Tab):Tab 序列完全数据
|
||
驱动,对齐 cms detailTabs——Product Details(正文)恒有 → 每产品自定义区块
|
||
detail_sections(CMS wppop_sections 迁移:Packing & Delivery / Our Exhibition /
|
||
Our Certifications / Our Company / FAQ,标题来自数据不固定;theme_pro 从
|
||
fields.sections 读、旧同步产物从 body H2 段识别拆出)→ Specifications(specs
|
||
有数据才显)→ Features(fields.features 有数据才显)。
|
||
Tab 是锚点跳转非面板切换(用户要求 + 区块全部平铺渲染):无 JS 时原生锚点
|
||
跳转 + 全部区块可读、标题可见,静态可抓取;JS 平滑滚动 + scrollspy 高亮。 #}
|
||
{% set tp_spec_rows = specs | rejectattr('label', 'equalto', 'gallery') | list %}
|
||
<div class="sf-tp-product-tabbed" data-sf-tp-tabbed>
|
||
<nav class="sf-tp-product-tabs" aria-label="Product details">
|
||
<div class="wrap sf-tp-tabs-row">
|
||
<a class="sf-tp-tab active" href="#overview" role="tab" aria-selected="true">Product Details</a>
|
||
{% for s in detail_sections %}
|
||
<a class="sf-tp-tab" href="#{{ s.id }}" role="tab" aria-selected="false">{{ s.title }}</a>
|
||
{% endfor %}
|
||
{% if tp_spec_rows %}
|
||
<a class="sf-tp-tab" href="#specifications" role="tab" aria-selected="false">Specifications</a>
|
||
{% endif %}
|
||
{% if detail_features %}
|
||
<a class="sf-tp-tab" href="#features" role="tab" aria-selected="false">Features</a>
|
||
{% endif %}
|
||
</div>
|
||
</nav>
|
||
|
||
<section id="overview" class="sf-tp-detail-section">
|
||
<div class="wrap">
|
||
<h2 class="sf-tp-panel-title">Product details</h2>
|
||
<div class="sf-tp-product-body">{{ body_copy(detail_overview, page) }}</div>
|
||
</div>
|
||
</section>
|
||
{% for s in detail_sections %}
|
||
<section id="{{ s.id }}" class="sf-tp-detail-section">
|
||
<div class="wrap">
|
||
<h2 class="sf-tp-panel-title">{{ s.title }}</h2>
|
||
<div class="rich-body sf-tp-product-body">{{ s.content }}</div>
|
||
</div>
|
||
</section>
|
||
{% endfor %}
|
||
{% if tp_spec_rows %}
|
||
<section id="specifications" class="sf-tp-detail-section sf-tp-detail-section--compact">
|
||
<div class="wrap">
|
||
<h2 class="sf-tp-panel-title">Specifications</h2>
|
||
<div class="sf-tp-table-scroll">
|
||
<table class="sf-tp-spec-table">
|
||
<tbody>
|
||
{# cms spec-table:每行两对键值(batch(2) 纵向配对) #}
|
||
{% for row in tp_spec_rows | batch(2) %}
|
||
<tr>{% for cell in row %}<th scope="row">{{ cell.label }}</th><td>{{ cell.value }}</td>{% endfor %}</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{% endif %}
|
||
{% if detail_features %}
|
||
<section id="features" class="sf-tp-detail-section sf-tp-detail-section--compact">
|
||
<div class="wrap">
|
||
<h2 class="sf-tp-panel-title">Features</h2>
|
||
<div class="sf-tp-detail-features">
|
||
{% for f in detail_features %}
|
||
<div class="sf-tp-detail-feature">
|
||
<span aria-hidden="true">✦</span>
|
||
<div><h3>{{ f.title }}</h3><p>{{ f.desc }}</p></div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{% endif %}
|
||
</div>
|
||
<script>
|
||
/* 锚点选项卡(Task 15,cms [slug].astro 尾部脚本重做):点击平滑滚动到区块
|
||
并在横滚行内居中;scrollspy 滚动高亮当前 Tab。无 JS 时为原生锚点跳转,
|
||
区块全部平铺可读。 */
|
||
(function () {
|
||
var tabbed = document.querySelector('[data-sf-tp-tabbed]');
|
||
if (!tabbed) return;
|
||
var nav = tabbed.querySelector('.sf-tp-product-tabs');
|
||
var tabs = Array.prototype.slice.call(tabbed.querySelectorAll('.sf-tp-tab'));
|
||
var targets = tabs.map(function (t) {
|
||
return document.getElementById((t.getAttribute('href') || '').slice(1));
|
||
});
|
||
function activate(i, center) {
|
||
tabs.forEach(function (t, j) {
|
||
var on = i === j;
|
||
t.classList.toggle('active', on);
|
||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||
});
|
||
if (center && tabs[i] && tabs[i].scrollIntoView) {
|
||
tabs[i].scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||
}
|
||
}
|
||
tabs.forEach(function (tab, i) {
|
||
tab.addEventListener('click', function (e) {
|
||
var target = targets[i];
|
||
if (!target) return;
|
||
e.preventDefault();
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
activate(i, true);
|
||
});
|
||
});
|
||
var current = -1;
|
||
function spy() {
|
||
var mark = (nav ? nav.getBoundingClientRect().bottom : 0) + 32;
|
||
var idx = 0;
|
||
targets.forEach(function (el, i) {
|
||
if (el && el.getBoundingClientRect().top <= mark) idx = i;
|
||
});
|
||
if (idx !== current) { current = idx; activate(idx, true); }
|
||
}
|
||
window.addEventListener('scroll', spy, { passive: true });
|
||
spy();
|
||
})();
|
||
</script>
|
||
{% set tp_related = (groups['product'] + groups['vehicle']) | rejectattr('route', 'equalto', page.route) | list %}
|
||
{% if tp_related %}
|
||
<section class="section">
|
||
<div class="wrap">
|
||
<div class="sf-tp-section-head" data-reveal>
|
||
<div>
|
||
<span class="eyebrow">RELATED PRODUCTS</span>
|
||
<h2>You may also need</h2>
|
||
</div>
|
||
<a class="sf-tp-more" href="/products/">Browse all products {{ icon('arrow-right', cls='sf-tp-icon--sm') }}</a>
|
||
</div>
|
||
<div class="sf-tp-card-grid">
|
||
{% for tp_item in tp_related[:4] %}
|
||
<article class="sf-tp-card" data-reveal style="--reveal-i: {{ loop.index0 % 4 }}">
|
||
<div class="sf-tp-card-media">{{ cover(tp_item, 'product') }}</div>
|
||
<div class="sf-tp-card-body">
|
||
<h3>{{ tp_item.title }}</h3>
|
||
<p>{{ (tp_item.summary or 'Published specifications available on request.').split('.')[0] }}.</p>
|
||
<a class="sf-tp-more" href="{{ safe_href(tp_item.route) }}">View details {{ icon('arrow-right', cls='sf-tp-icon--sm') }}</a>
|
||
</div>
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{% endif %}
|
||
<section class="section section--soft sf-tp-product-quote">
|
||
<div class="wrap sf-tp-quote-grid">
|
||
<div class="sf-tp-quote-info" data-reveal>
|
||
<span class="eyebrow">REQUEST A QUOTE</span>
|
||
<h2>Inquire about this product</h2>
|
||
<p>Send the specifications you need for <strong>{{ page.title }}</strong> and our trade desk replies with pricing, lead time and export packaging within one business day.</p>
|
||
<div class="sf-tp-quote-list">
|
||
{% if footer_email %}
|
||
<div class="sf-tp-quote-item">
|
||
<div class="icon-circle" aria-hidden="true">{{ icon('mail') }}</div>
|
||
<div><strong>Email</strong><p><a href="mailto:{{ footer_email }}">{{ footer_email }}</a></p></div>
|
||
</div>
|
||
{% endif %}
|
||
<div class="sf-tp-quote-item">
|
||
<div class="icon-circle" aria-hidden="true">{{ icon('check') }}</div>
|
||
<div><strong>Direct sourcing desk</strong><p>One contact from inquiry to container loading.</p></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sf-tp-quote-form" data-reveal style="--reveal-i: 1">
|
||
{% if form_endpoint %}
|
||
<h3>Get a quote for {{ page.title }}</h3>
|
||
<form action="{{ form_endpoint }}" method="POST">
|
||
<label>Name <input name="name" autocomplete="name" required maxlength="100"></label>
|
||
<label>Email <input name="email" type="email" autocomplete="email" required maxlength="180"></label>
|
||
<label>Message <textarea name="message" required minlength="6" maxlength="5000" placeholder="I would like a quotation for {{ page.title }}..."></textarea></label>
|
||
<input name="website" tabindex="-1" autocomplete="off" class="pro-honeypot" aria-hidden="true">
|
||
<button type="submit" class="sf-tp-btn">Submit enquiry {{ icon('arrow-right') }}</button>
|
||
<p class="sf-tp-quote-note">Submissions are handled by the external form service configured by the site administrator.</p>
|
||
</form>
|
||
{% else %}
|
||
<h3>Get a quote for {{ page.title }}</h3>
|
||
<p>Share your specifications and target market through the contact page; our trade desk replies within one business day.</p>
|
||
<a class="sf-tp-btn" href="/contact/">Contact us {{ icon('arrow-right') }}</a>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</section>
|