Files
VITEC-website/public/product-check/index.php
T
orascheandClaude Fable 5 46b12d4a58 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>
2026-11-04 19:55:01 +01:00

348 lines
15 KiB
PHP

<?php
declare(strict_types=1);
/**
* VITEC Product Content Check (2026-11-03) - live aufrufbare Audit-Seite,
* KEIN Plugin (nichts, was Arne in React nachbauen muesste):
*
* https://dev.vitec.com/product-check/
*
* Eine Tabellenzeile pro Produkt (alphabetisch), pro Spalte ein Feld mit
* Check/Kreuz nach Fuellstand; jede Zeile aufklappbar mit ALLEN Inhalten,
* Bilder als Thumbnail mit Klick-Vergroesserung (Lightbox). Der
* Refresh-Button laedt die Seite mit Cache-Buster neu - gelesen wird bei
* jedem Aufruf frisch per PDO aus der Datenbank (Pattern migrations/
* build_product_audit.php, das weiter die statische Badge-Ansicht baut).
*
* Interna-Tool: noindex, kein Cache, auf dem Live-Host gesperrt.
*/
error_reporting(E_ALL);
ini_set('display_errors', '1');
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
header('X-Robots-Tag: noindex, nofollow');
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($host !== '' && preg_match('/^(www\.)?vitec\.com$/i', $host)) {
http_response_code(403);
exit('Not available on this host.');
}
$settings = require dirname(__DIR__, 2) . '/config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
$db['user'] ?? '',
$db['password'] ?? '',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
// ---- data -------------------------------------------------------------------
$products = $pdo->query(
"SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
capabilities, applications, resources, resources_layout,
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title), uid"
)->fetchAll();
// FAL references per product and fieldname, with file identifiers
$fileRefs = $pdo->query(
"SELECT r.uid_foreign, r.fieldname, f.identifier, f.name
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile')
ORDER BY r.uid_foreign, r.fieldname, r.sorting_foreign"
)->fetchAll();
$files = [];
foreach ($fileRefs as $r) {
$files[(int)$r['uid_foreign']][$r['fieldname']][] = ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']];
}
// inline resource cards
$resourceRows = $pdo->query(
'SELECT * FROM tx_vitec_product_resource WHERE deleted = 0 AND hidden = 0 ORDER BY product, sorting'
)->fetchAll();
$resources = [];
foreach ($resourceRows as $r) {
$resources[(int)$r['product']][] = $r;
}
// MM: datasheets (download records) and solutions, in MM order
$datasheets = [];
foreach ($pdo->query(
'SELECT mm.uid_local AS product, d.title FROM tx_vitec_product_datasheet_mm mm
JOIN tx_vitec_domain_model_download d ON d.uid = mm.uid_foreign AND d.deleted = 0
ORDER BY mm.uid_local, mm.sorting'
)->fetchAll() as $r) {
$datasheets[(int)$r['product']][] = (string)$r['title'];
}
$solutions = [];
foreach ($pdo->query(
'SELECT mm.uid_local AS product, s.title FROM tx_vitec_product_solution_mm mm
JOIN tx_vitec_domain_model_solution s ON s.uid = mm.uid_foreign AND s.deleted = 0
ORDER BY mm.uid_local, mm.sorting'
)->fetchAll() as $r) {
$solutions[(int)$r['product']][] = (string)$r['title'];
}
// ---- helpers ------------------------------------------------------------------
function h(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES | ENT_HTML5);
}
/** check/cross cell; a count > 1 is shown next to the check */
function cell(bool $ok, int $count = 0): string
{
if (!$ok) {
return '<td class="no">✗</td>';
}
return '<td class="ok">✓' . ($count > 1 ? '<small>' . $count . '</small>' : '') . '</td>';
}
function filled(?string $v): bool
{
return trim(strip_tags((string)$v)) !== '';
}
/** labeled block in the detail pane; HTML of RTE fields is rendered */
function textBlock(string $label, ?string $value): string
{
if (!filled($value)) {
return '';
}
return '<div class="fld"><div class="fldlabel">' . h($label) . '</div><div class="fldval">' . $value . '</div></div>';
}
/** @param array<int,array{url:string,name:string}> $images */
function imageBlock(string $label, array $images): string
{
if ($images === []) {
return '';
}
$out = '<div class="fld"><div class="fldlabel">' . h($label) . ' (' . count($images) . ')</div><div class="thumbs">';
foreach ($images as $img) {
$out .= '<a href="' . h($img['url']) . '" class="zoom" title="' . h($img['name']) . '">'
. '<img loading="lazy" src="' . h($img['url']) . '" alt="' . h($img['name']) . '"></a>';
}
return $out . '</div></div>';
}
/** @param string[] $items */
function listBlock(string $label, array $items): string
{
if ($items === []) {
return '';
}
return '<div class="fld"><div class="fldlabel">' . h($label) . ' (' . count($items) . ')</div><ul class="fldlist"><li>'
. implode('</li><li>', array_map('h', $items)) . '</li></ul></div>';
}
// ---- columns ------------------------------------------------------------------
$columns = ['Hero', 'Photos', 'Video', 'Teaser', 'Subtitle', 'Description', 'Descr. 2', 'Highlights', 'Capabilities', 'Applications', 'Resources', 'Datasheets', 'Solutions'];
$filledPerColumn = array_fill_keys($columns, 0);
$rowsHtml = '';
foreach ($products as $p) {
$uid = (int)$p['uid'];
$f = $files[$uid] ?? [];
$res = $resources[$uid] ?? [];
$ds = $datasheets[$uid] ?? [];
$sol = $solutions[$uid] ?? [];
$checks = [
'Hero' => [count($f['heroimage'] ?? []), true],
'Photos' => [count($f['productimage'] ?? []), true],
'Video' => [count($f['videofile'] ?? []), true],
'Teaser' => [filled($p['teaser']) ? 1 : 0, false],
'Subtitle' => [filled($p['subtitle']) ? 1 : 0, false],
'Description' => [filled($p['description']) ? 1 : 0, false],
'Descr. 2' => [filled($p['description2']) ? 1 : 0, false],
'Highlights' => [filled($p['highlights']) ? 1 : 0, false],
'Capabilities' => [filled($p['capabilities']) ? 1 : 0, false],
'Applications' => [filled($p['applications']) ? 1 : 0, false],
'Resources' => [count($res), true],
'Datasheets' => [count($ds), true],
'Solutions' => [count($sol), true],
];
$cells = '';
foreach ($columns as $column) {
[$count, $showCount] = $checks[$column];
$filledPerColumn[$column] += $count > 0 ? 1 : 0;
$cells .= cell($count > 0, $showCount ? $count : 0);
}
$flags = [];
foreach (['hidden' => 'hidden', 'legacy' => 'legacy', 'supportproduct' => 'support', 'subproduct' => 'sub', 'hideonwebsite' => 'hideWeb', 'hideonproducts' => 'hideList', 'showdatapath' => 'datapath'] as $col => $label) {
if ((int)$p[$col] === 1) {
$flags[] = $label;
}
}
// detail pane: every content of the record
$resourceLines = [];
foreach ($res as $r) {
$parts = [];
foreach (['title', 'header', 'label', 'kind', 'link'] as $key) {
if (isset($r[$key]) && trim((string)$r[$key]) !== '') {
$parts[] = (string)$r[$key];
}
}
$resourceLines[] = $parts === [] ? ('#' . $r['uid']) : implode(' · ', $parts);
}
$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'])
. ($flags !== [] ? ' · Flags: ' . h(implode(', ', $flags)) : '')
. '</div>'
. imageBlock('Hero Image', $f['heroimage'] ?? [])
. imageBlock('Product Images', $f['productimage'] ?? [])
. imageBlock('OG Image', $f['ogimage'] ?? [])
. listBlock('Video Files', array_map(static fn(array $v): string => $v['name'] . ' (' . $v['url'] . ')', $f['videofile'] ?? []))
. textBlock('Teaser', (string)$p['teaser'])
. textBlock('Subtitle', (string)$p['subtitle'])
. textBlock('Description', (string)$p['description'])
. textBlock('Description 2', (string)$p['description2'])
. textBlock('Highlights', (string)$p['highlights'])
. textBlock('Capabilities', (string)$p['capabilities'])
. textBlock('Applications', (string)$p['applications'])
. listBlock('Resources', $resourceLines)
. listBlock('Datasheets', $ds)
. listBlock('Solutions', $sol);
$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" 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>';
}
// ---- page ---------------------------------------------------------------------
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>VITEC Product Content Check</title>
<style>
:root{--ok:#1a7f37;--miss:#c0392b;--line:#e3e5e8;color-scheme:light}
body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c1e21}
header{position:sticky;top:0;background:#fff;border-bottom:1px solid var(--line);padding:10px 20px;z-index:3;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
h1{font-size:17px;margin:0}
.sub{color:#777;font-size:12.5px}
#refresh{margin-left:auto;border:1px solid #26358c;background:#26358c;color:#fff;border-radius:6px;padding:6px 16px;font-size:13px;cursor:pointer}
#refresh:hover{background:#1b2766}
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}
tr.prod:hover td{background:#f2f5ff}
td.ok{color:var(--ok);font-weight:700}
td.ok small{color:#7fae8c;font-weight:400;margin-left:2px}
td.no{color:var(--miss);background:#fdf0ee}
.caret{display:inline-block;margin-right:6px;color:#888;transition:transform .12s}
tr.prod.open .caret{transform:rotate(90deg)}
.flags{color:#8a6d00;background:#fff7e0;border:1px solid #eadfa8;border-radius:8px;padding:0 6px;font-weight:400}
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}
.fldlabel{font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#555;margin-bottom:3px}
.fldval{background:#fff;border:1px solid var(--line);border-radius:6px;padding:8px 12px;overflow-wrap:anywhere}
.fldval p:first-child{margin-top:0}.fldval p:last-child{margin-bottom:0}
.fldlist{margin:0;background:#fff;border:1px solid var(--line);border-radius:6px;padding:8px 12px 8px 28px}
.thumbs{display:flex;flex-wrap:wrap;gap:8px}
.thumbs a{display:flex;width:140px;height:92px;align-items:center;justify-content:center;background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.thumbs img{max-width:100%;max-height:100%;object-fit:contain}
#lightbox{position:fixed;inset:0;background:rgba(10,12,20,.82);display:none;align-items:center;justify-content:center;z-index:9;cursor:zoom-out}
#lightbox.show{display:flex}
#lightbox img{max-width:92vw;max-height:92vh;background:#fff;border-radius:4px}
#lightbox .cap{position:fixed;bottom:14px;left:0;right:0;text-align:center;color:#eee;font-size:13px}
</style>
</head>
<body>
<header>
<h1>VITEC Product Content Check</h1>
<span class="sub"><?= count($products) ?> products · as of <?= h(date('Y-m-d H:i:s')) ?> · click a row for details</span>
<button id="refresh" type="button">↻ Refresh</button>
</header>
<main>
<table>
<thead><tr><?= $headCells ?></tr></thead>
<tbody>
<?= $rowsHtml ?>
</tbody>
</table>
</main>
<div id="lightbox"><img src="" alt=""><div class="cap"></div></div>
<script>
document.getElementById('refresh').addEventListener('click', function () {
location.href = location.pathname + '?r=' + Date.now();
});
document.querySelectorAll('tr.prod').forEach(function (row) {
row.addEventListener('click', function () {
row.classList.toggle('open');
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');
document.querySelectorAll('a.zoom').forEach(function (a) {
a.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
lbImg.src = a.getAttribute('href');
lbCap.textContent = a.getAttribute('title') || '';
lb.classList.add('show');
});
});
lb.addEventListener('click', function () { lb.classList.remove('show'); lbImg.src = ''; });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { lb.classList.remove('show'); lbImg.src = ''; } });
</script>
</body>
</html>