product.features: Why-choose cards with icons (contract 0036-FE)

- new inline table tx_vitec_product_feature (title, cardtext, FAL icon)
  + `features` field on the product record; ProductShowJsonRenderer
  emits product.features {heading, items[{title, text, icon}]} and null
  while a product has no cards, so the front end keeps its
  applications-HTML fallback
- new vitec:seed-product-features: seeds the cards from the
  applications <p><strong> pairs and matches the staged agency icons by
  normalised title (camelCase split, stop words, Levenshtein <= 3 typo
  tolerance); products with existing cards are never touched
- new vitec:build-why-vitec-bands: "Why VITEC?" intro + icon_card band
  on the 7 overview pages with a staged icon set (the contract's
  "centered" variant does not exist - documented in ticket 0039-BE);
  vision/visionav sub-group sets stay unassigned
- product-check page: sortable title column and an Open Product button
- new migration fix_colombia_quote.php: stray quote element #12037
  removed, quote_role set (ticket 0038-FE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-11-04 19:55:01 +01:00
1 parent 3ccb9b149e
commit 46b12d4a58
8 files changed
+799 -4

No files matched your search

+23 -3
View File
@@ -198,7 +198,8 @@ foreach ($products as $p) {
$resourceLines[] = $parts === [] ? ('#' . $r['uid']) : implode(' · ', $parts);
}
$detail = '<div class="meta">'
$detail = '<a class="openproduct" href="/product/' . h(trim((string)$p['slug'], '/')) . '" target="_blank" rel="noopener">Open Product ↗</a>'
. '<div class="meta">'
. '<a href="/product/' . h(trim((string)$p['slug'], '/')) . '" target="_blank" rel="noopener">/product/' . h(trim((string)$p['slug'], '/')) . '</a>'
. ' · uid ' . $uid
. ' · resources_layout: ' . h((string)$p['resources_layout'])
@@ -219,14 +220,14 @@ foreach ($products as $p) {
. listBlock('Datasheets', $ds)
. listBlock('Solutions', $sol);
$rowsHtml .= '<tr class="prod" data-uid="' . $uid . '">'
$rowsHtml .= '<tr class="prod" data-uid="' . $uid . '" data-title="' . h(mb_strtolower((string)$p['title'])) . '">'
. '<td class="name"><span class="caret">▸</span>' . h((string)$p['title'])
. ($flags !== [] ? ' <small class="flags">' . h(implode(' ', $flags)) . '</small>' : '')
. '</td>' . $cells . '</tr>' . "\n"
. '<tr class="detail"><td colspan="' . (count($columns) + 1) . '"><div class="detailwrap">' . $detail . '</div></td></tr>' . "\n";
}
$headCells = '<th class="name">Product (' . count($products) . ')</th>';
$headCells = '<th class="name" id="sortname" title="Click to sort by title">Product (' . count($products) . ') <span id="sortdir">▲</span></th>';
foreach ($columns as $column) {
$headCells .= '<th title="' . h($column) . ' filled: ' . $filledPerColumn[$column] . '/' . count($products) . '">'
. h($column) . '<small>' . $filledPerColumn[$column] . '</small></th>';
@@ -253,6 +254,9 @@ main{margin:14px;overflow-x:auto}
table{border-collapse:collapse;background:#fff;border:1px solid var(--line);min-width:100%}
th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:center;font-size:13px;white-space:nowrap}
thead th{position:sticky;top:0;background:#fff;z-index:2;border-bottom:2px solid #c6cad1;cursor:default}
th#sortname{cursor:pointer;user-select:none}
th#sortname:hover{color:#26358c}
#sortdir{color:#26358c;font-size:11px}
thead th small{display:block;font-weight:400;color:#999;font-size:10.5px}
td.name,th.name{text-align:left;font-weight:600;max-width:340px;white-space:normal}
tr.prod{cursor:pointer}
@@ -267,6 +271,8 @@ tr.detail{display:none}
tr.detail.show{display:table-row}
tr.detail>td{background:#fafbfd;text-align:left;white-space:normal;padding:0}
.detailwrap{padding:14px 18px;max-width:1100px}
.openproduct{display:inline-block;background:#26358c;color:#fff;border-radius:6px;padding:6px 16px;font-size:13px;font-weight:600;text-decoration:none;margin-bottom:10px}
.openproduct:hover{background:#1b2766}
.meta{color:#666;font-size:12.5px;margin-bottom:10px}
.meta a{color:#26358c}
.fld{margin-bottom:12px}
@@ -308,6 +314,20 @@ document.querySelectorAll('tr.prod').forEach(function (row) {
row.nextElementSibling.classList.toggle('show');
});
});
var sortAsc = true;
document.getElementById('sortname').addEventListener('click', function () {
sortAsc = !sortAsc;
document.getElementById('sortdir').textContent = sortAsc ? '▲' : '▼';
var tbody = document.querySelector('tbody');
var pairs = Array.prototype.map.call(tbody.querySelectorAll('tr.prod'), function (row) {
return [row, row.nextElementSibling];
});
pairs.sort(function (a, b) {
var cmp = a[0].dataset.title.localeCompare(b[0].dataset.title);
return sortAsc ? cmp : -cmp;
});
pairs.forEach(function (pair) { tbody.appendChild(pair[0]); tbody.appendChild(pair[1]); });
});
var lb = document.getElementById('lightbox');
var lbImg = lb.querySelector('img');
var lbCap = lb.querySelector('.cap');