Files
cms-SiteForge/themes/trade-pro/pages/product.html
T
浅念 f5dc2f7624 Task 15: product detail anchor tabs + per-product custom sections
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>
2026-10-29 13:49:08 +08:00

247 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{# 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>