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

+81
View File
@@ -0,0 +1,81 @@
<?php
/**
* fix_colombia_quote.php (2026-11-04, Ticket 0038-FE Punkt 3)
*
* the-national-police-of-colombia: das lose Zitat-Element #12037 traegt nur
* die ROLLE ("Director of the Engineering Department of Cosmos Sistemas
* Integrado"), waehrend der Usecase-Record quote_person gefuellt, quote_role
* aber leer hat. Fix: Element soft-loeschen (+ Inline-Zaehler), quote_role
* am Record setzen.
*
* php migrations/fix_colombia_quote.php # INSPECT (default)
* php migrations/fix_colombia_quote.php --apply # schreibt
*/
declare(strict_types=1);
$apply = in_array('--apply', $argv, true);
$settings = require __DIR__ . '/../config/system/settings.php';
$db = $settings['DB']['Connections']['Default'];
$pdo = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $db['host'], $db['dbname']),
$db['user'],
$db['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$role = 'Director of the Engineering Department of Cosmos Sistemas Integrado';
echo $apply ? "APPLY\n\n" : "INSPECT - nichts wird geschrieben (--apply zum Schreiben)\n\n";
$story = $pdo->query(
"SELECT uid, title, quote_person, quote_role, content_elements FROM tx_vitec_domain_model_usecase
WHERE deleted = 0 AND slug = 'the-national-police-of-colombia'"
)->fetch(PDO::FETCH_ASSOC);
if (!$story) {
echo "ABBRUCH: Story the-national-police-of-colombia nicht gefunden.\n";
exit(1);
}
printf(
"Story #%d \"%s\": quote_person=\"%s\" quote_role=\"%s\" content_elements=%d\n",
$story['uid'], $story['title'], $story['quote_person'], $story['quote_role'], $story['content_elements']
);
$element = $pdo->query(
'SELECT uid, pid, CType, deleted, tx_vitec_usecase_content, bodytext, header FROM tt_content WHERE uid = 12037'
)->fetch(PDO::FETCH_ASSOC);
if (!$element) {
echo "Element #12037 existiert nicht.\n";
exit(1);
}
printf(
"Element #12037: CType=%s deleted=%d usecase=%d text=\"%s\"\n",
$element['CType'], $element['deleted'], $element['tx_vitec_usecase_content'],
mb_substr(trim(strip_tags((string)$element['bodytext'] . ' ' . (string)$element['header'])), 0, 90)
);
if ((int)$element['tx_vitec_usecase_content'] !== (int)$story['uid']) {
echo "ABBRUCH: Element #12037 haengt nicht an dieser Story.\n";
exit(1);
}
if ((int)$element['deleted'] === 1) {
echo "Element ist bereits geloescht.\n";
}
if (!$apply) {
echo "\nWuerde: Element #12037 loeschen, content_elements neu zaehlen, quote_role setzen auf:\n \"$role\"\n";
exit(0);
}
if ((int)$element['deleted'] === 0) {
$pdo->prepare('UPDATE tt_content SET deleted = 1, tstamp = ? WHERE uid = 12037')->execute([time()]);
echo "Element #12037 geloescht.\n";
}
$count = (int)$pdo->query(
'SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND tx_vitec_usecase_content = ' . (int)$story['uid']
)->fetchColumn();
$pdo->prepare('UPDATE tx_vitec_domain_model_usecase SET content_elements = ?, quote_role = ?, tstamp = ? WHERE uid = ?')
->execute([$count, $role, time(), (int)$story['uid']]);
printf("Record aktualisiert: quote_role gesetzt, content_elements = %d.\n", $count);
echo "Danach: vendor/bin/typo3 cache:flush\n";
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Builds the "Why VITEC?" icon band on the product OVERVIEW pages
* (/products/<slug>, contract 0036-FE variant 3b): one vitec_introparagraph
* headline plus one vitec_card (icon_card variant - the design's "centered"
* card; 0036 named a variant that does not exist) per staged agency icon of
* that page's context under fileadmin/icons/delivered/products/<context>/.
* Card titles are derived from the icon filename theme ("ScalableArchitecture"
* -> "Scalable architecture"); bodytext stays empty until the agency
* delivers copy (0036: a card without text is fine).
*
* The band lands directly AFTER the first element of the main column (the
* intro). Idempotent: a page already carrying a "Why VITEC?" introparagraph
* is skipped - editors own the band from then on. The Vision sub-group icon
* sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial
* decision, reported only.
*
* vendor/bin/typo3 vitec:build-why-vitec-bands
* vendor/bin/typo3 vitec:build-why-vitec-bands --apply
*/
#[AsCommand(
name: 'vitec:build-why-vitec-bands',
description: 'Create the Why-VITEC icon bands on the product overview pages from the staged icons (dry-run by default)'
)]
final class BuildWhyVitecBandsCommand extends Command
{
private const ICON_BASE = 'icons/delivered/products/';
private const HEADER = 'Why VITEC?';
private const COLPOS = 0;
/** staged icon context => overview page slug */
private const BANDS = [
'activesqx' => '/products/activesqx',
'aetria' => '/products/aetria',
'aligo' => '/products/aligo',
'avedia-modular-system' => '/products/avedia-modular-system',
'image-graphics-cards' => '/products/image-graphics-cards',
'operator-workstations' => '/products/operator-workstations',
'prism-transcoder' => '/products/prism-transcoder',
];
/** acronyms kept upper-case when the filename theme becomes a card title */
private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI'];
private int $newCounter = 0;
protected function configure(): void
{
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count(self::BANDS) . ' band(s)');
$built = 0;
foreach (self::BANDS as $context => $slug) {
$page = $pool->getConnectionForTable('pages')->fetchAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)',
[$slug]
);
if (!$page) {
$output->writeln('<error>' . $slug . ': page not found - skipped</error>');
continue;
}
$pageUid = (int)$page['uid'];
$existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne(
"SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?",
[$pageUid, self::HEADER]
);
if ($existing > 0) {
$output->writeln($slug . ': band exists - untouched');
continue;
}
$folder = self::ICON_BASE . $context;
$icons = [];
if ($storage->hasFolder($folder)) {
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
if (mb_strtolower($file->getExtension()) === 'svg' && preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
$icons[] = ['identifier' => $file->getIdentifier(), 'title' => $this->themeTitle($m[1])];
}
}
}
if ($icons === []) {
$output->writeln('<comment>' . $slug . ': no staged icons under fileadmin/' . $folder . ' - skipped</comment>');
continue;
}
$output->writeln(sprintf("\n<info>%s</info> (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons)));
foreach ($icons as $icon) {
$output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')');
}
$built++;
if (!$apply) {
continue;
}
// current main-column order (top-level elements only: the band
// must not land inside a b13 container)
$elements = $pool->getConnectionForTable('tt_content')->fetchAllAssociative(
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ?
AND (tx_container_parent IS NULL OR tx_container_parent = 0)
ORDER BY sorting, uid',
[$pageUid, self::COLPOS]
);
$existingUids = array_map(static fn(array $r): string => (string)(int)$r['uid'], $elements);
$datamap = [];
$newIds = [];
$introId = $this->newId();
$datamap['tt_content'][$introId] = [
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_introparagraph',
'header' => self::HEADER, 'bodytext' => '',
];
$newIds[] = $introId;
foreach ($icons as $icon) {
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => $pageUid,
'table_local' => 'sys_file',
'uid_local' => $storage->getFile($icon['identifier'])->getUid(),
];
$cardId = $this->newId();
$datamap['tt_content'][$cardId] = [
'pid' => $pageUid, 'colPos' => self::COLPOS, 'CType' => 'vitec_card',
'header' => $icon['title'], 'bodytext' => '',
'vitec_layout_variant' => 'icon_card',
'vitec_icon' => $refId,
];
$newIds[] = $cardId;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
// final order: first element (intro), then the band, then the rest
$order = array_merge(
array_slice($existingUids, 0, 1),
array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds),
array_slice($existingUids, 1)
);
$sorting = 0;
foreach ($order as $uid) {
if ((int)$uid > 0) {
$sorting += 256;
$pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => (int)$uid]);
}
}
}
$output->writeln(sprintf(
"\n<info>%d band(s) %s.</info> Vision sub-group icon sets (vision, visionav) stay unassigned - one page, two sets (editorial).",
$built, $apply ? 'written - flush the cache' : 'planned (dry run - nothing written)'
));
return Command::SUCCESS;
}
/** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */
private function themeTitle(string $theme): string
{
$words = preg_split('/(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\d)/', $theme, -1, PREG_SPLIT_NO_EMPTY) ?: [$theme];
$out = [];
foreach ($words as $i => $word) {
if (in_array(strtoupper($word), self::ACRONYMS, true)) {
$out[] = strtoupper($word);
} elseif (preg_match('/\d/', $word)) {
$out[] = $word;
} else {
$out[] = $i === 0 ? ucfirst(mb_strtolower($word)) : mb_strtolower($word);
}
}
return implode(' ', $out);
}
private function newId(): string
{
return 'NEWWV' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
}
@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Seeds the "Why choose <product>?" cards (tx_vitec_product_feature,
* contract 0036-FE) from the `applications` RTE HTML plus the staged agency
* icons:
*
* - `applications` pairs `<p><strong>Title</strong></p><p>Text</p>` become
* one feature record each (several text paragraphs are joined); the
* leading <h3> stays where it is - the JSON heading is derived from it.
* - icons come from fileadmin/icons/delivered/products/<context>/ (the
* 2026-11-03 staging); <context> is the product slug unless ICON_DIRS
* maps it. Matching: normalised title vs. the filename theme (the part
* after the last "-"), stop words removed, Levenshtein <= 3 as typo
* tolerance (the delivery contains e.g. "EncodeDecondeOnOneCard").
* - a product that already HAS feature records is never touched - after
* the seed the editors own the cards.
*
* Unmatched icons and unmatched cards are reported so the editors can map
* the leftovers by hand (0036-FE asks for exactly that list).
*
* vendor/bin/typo3 vitec:seed-product-features
* vendor/bin/typo3 vitec:seed-product-features --only=activesqx2 --apply
*/
#[AsCommand(
name: 'vitec:seed-product-features',
description: 'Seed the Why-choose feature cards from the applications HTML + staged icons (dry-run by default)'
)]
final class SeedProductFeaturesCommand extends Command
{
private const TABLE = 'tx_vitec_product_feature';
private const ICON_BASE = 'icons/delivered/products/';
/** product slug => staging context where the names differ */
private const ICON_DIRS = [
'avedia-platform-iptv-signage-cmsserver' => 'avedia-platform',
'ez-tv-platform-iptv-signage-cmsserver' => 'ez-tv-platform',
'x-player-end-points-xp23-xp25-xp26' => 'x-player-end-points-product',
];
private const STOPWORDS = ['and', 'or', 'the', 'a', 'an', 'of', 'on', 'in', 'to', 'with', 'for', 'your', 'at', 'is'];
private int $newCounter = 0;
protected function configure(): void
{
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only product slugs containing this substring');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$products = $pool->getConnectionForTable('tx_vitec_domain_model_product')->fetchAllAssociative(
'SELECT uid, pid, title, slug, applications FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title)'
);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · ' . count($products) . ' product(s)');
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$seeded = 0;
$cards = 0;
$iconsMatched = 0;
$skippedExisting = 0;
$skippedEmpty = 0;
foreach ($products as $product) {
$uid = (int)$product['uid'];
$slug = (string)$product['slug'];
if ($only !== '' && !str_contains(mb_strtolower($slug), $only)) {
continue;
}
$existing = (int)$pool->getConnectionForTable(self::TABLE)->fetchOne(
'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE deleted = 0 AND product = ?',
[$uid]
);
if ($existing > 0) {
$skippedExisting++;
continue;
}
$pairs = $this->parseApplications((string)$product['applications']);
if ($pairs === []) {
$skippedEmpty++;
continue;
}
// staged icons of this product, filename theme => identifier
$context = self::ICON_DIRS[$slug] ?? $slug;
$iconByTheme = [];
$folder = self::ICON_BASE . $context;
if ($storage->hasFolder($folder)) {
foreach ($storage->getFilesInFolder($storage->getFolder($folder)) as $file) {
if (mb_strtolower($file->getExtension()) !== 'svg') {
continue;
}
if (preg_match('/-([A-Za-z0-9]+)\.svg$/', $file->getName(), $m)) {
$iconByTheme[$m[1]] = $file->getIdentifier();
}
}
}
$output->writeln(sprintf("\n<info>%s</info> (uid %d) - %d card(s), %d staged icon(s)", $slug, $uid, count($pairs), count($iconByTheme)));
// match icons onto the pairs
$usedThemes = [];
foreach ($pairs as $i => $pair) {
$theme = $this->matchTheme($pair['title'], array_diff_key($iconByTheme, $usedThemes));
if ($theme !== null) {
$pairs[$i]['icon'] = $iconByTheme[$theme];
$usedThemes[$theme] = true;
}
}
foreach ($pairs as $pair) {
$output->writeln(sprintf(
' "%s"%s%s',
$pair['title'],
$pair['text'] !== '' ? '' : ' <comment>(no text)</comment>',
isset($pair['icon']) ? ' + icon ' . basename((string)$pair['icon']) : ' <comment>- no icon</comment>'
));
}
foreach (array_diff_key($iconByTheme, $usedThemes) as $theme => $identifier) {
$output->writeln(' <comment>UNMATCHED icon: ' . basename($identifier) . '</comment>');
}
$cards += count($pairs);
$iconsMatched += count($usedThemes);
$seeded++;
if (!$apply) {
continue;
}
$datamap = [];
$featureIds = [];
foreach ($pairs as $pair) {
$featureId = $this->newId();
$record = [
'pid' => (int)$product['pid'],
'product' => $uid,
'title' => $pair['title'],
'cardtext' => $pair['text'],
];
if (isset($pair['icon'])) {
$refId = $this->newId();
$datamap['sys_file_reference'][$refId] = [
'pid' => (int)$product['pid'],
'table_local' => 'sys_file',
'uid_local' => $storage->getFile((string)$pair['icon'])->getUid(),
];
$record['icon'] = $refId;
}
$datamap[self::TABLE][$featureId] = $record;
$featureIds[] = $featureId;
}
$datamap['tx_vitec_domain_model_product'][$uid] = ['features' => implode(',', $featureIds)];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln(' <error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
// DataHandler puts NEW records at the TOP - write explicit sorting
$sorting = 0;
foreach ($featureIds as $featureId) {
$realUid = (int)($dataHandler->substNEWwithIDs[$featureId] ?? 0);
if ($realUid > 0) {
$sorting += 256;
$pool->getConnectionForTable(self::TABLE)->update(self::TABLE, ['sorting' => $sorting], ['uid' => $realUid]);
}
}
}
$output->writeln(sprintf(
"\n<info>%d product(s) seeded (%d cards, %d icons matched), %d with existing cards untouched, %d without applications pairs%s</info>",
$seeded, $cards, $iconsMatched, $skippedExisting, $skippedEmpty,
$apply ? '' : ' (dry run - nothing written)'
));
if ($apply && $seeded > 0) {
$output->writeln('<info>Flush the cache: vendor/bin/typo3 cache:flush</info>');
}
return Command::SUCCESS;
}
/**
* The `<p><strong>Title</strong></p><p>Text…</p>` pairs of the
* applications HTML, in order. Several text paragraphs after one title
* are joined; the <h3> heading and empty paragraphs are skipped.
*
* @return array<int,array{title:string,text:string,icon?:string}>
*/
private function parseApplications(string $html): array
{
if (trim($html) === '') {
return [];
}
$pairs = [];
$current = null;
if (!preg_match_all('#<p[^>]*>(.*?)</p>#is', $html, $matches)) {
return [];
}
foreach ($matches[1] as $inner) {
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if ($plain === '') {
continue;
}
$strongOnly = preg_match('#^\s*<strong[^>]*>(.*?)</strong>\s*$#is', trim($inner), $m)
? trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($m[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')))
: null;
if ($strongOnly !== null && $strongOnly !== '') {
if ($current !== null) {
$pairs[] = $current;
}
$current = ['title' => $strongOnly, 'text' => ''];
continue;
}
if ($current !== null) {
$current['text'] = trim($current['text'] . ' ' . $plain);
}
}
if ($current !== null) {
$pairs[] = $current;
}
return $pairs;
}
/**
* The icon theme that matches a card title: exact on the normalised
* strings (stop words removed on both sides), else the closest theme
* within Levenshtein distance 3 (typo tolerance).
*
* @param array<string,string> $iconByTheme theme => identifier
*/
private function matchTheme(string $title, array $iconByTheme): ?string
{
if ($iconByTheme === []) {
return null;
}
$titleNorms = array_unique([$this->norm($title, false), $this->norm($title, true)]);
$best = null;
$bestDistance = PHP_INT_MAX;
foreach (array_keys($iconByTheme) as $theme) {
$themeNorms = array_unique([$this->norm($theme, false), $this->norm($theme, true)]);
foreach ($titleNorms as $a) {
foreach ($themeNorms as $b) {
if ($a === $b) {
return $theme;
}
if (abs(strlen($a) - strlen($b)) <= 3) {
$distance = levenshtein($a, $b);
if ($distance < $bestDistance) {
$bestDistance = $distance;
$best = $theme;
}
}
}
}
}
return $bestDistance <= 3 ? $best : null;
}
/** lowercase, letters/digits only; optionally with stop words removed (camelCase themes are split first) */
private function norm(string $value, bool $dropStopwords): string
{
$words = preg_split('/(?=[A-Z][a-z])|[^A-Za-z0-9]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($dropStopwords) {
$words = array_filter($words, static fn(string $w): bool => !in_array(mb_strtolower($w), self::STOPWORDS, true));
}
return mb_strtolower(implode('', $words));
}
private function newId(): string
{
return 'NEWPF' . str_pad((string)(++$this->newCounter), 4, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
}
}
@@ -258,6 +258,11 @@ class ProductShowJsonRenderer
'resources' => $this->resourcesBand($uid, (string)(($product['resources_layout'] ?? '') ?: '50-50')), 'resources' => $this->resourcesBand($uid, (string)(($product['resources_layout'] ?? '') ?: '50-50')),
]; ];
// "Why choose <product>?" cards (contract 0036-FE): null while the
// product has no feature records - the front end then keeps parsing
// the `applications` HTML above.
$data['features'] = $this->featuresBand($uid, (string)$data['applications'], (string)$data['title']);
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit // schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
// @graph string. The frontend injects it as <script type="application/ld+json">. // @graph string. The frontend injects it as <script type="application/ld+json">.
// Supersedes the manual `structureddata` field above (kept for backwards // Supersedes the manual `structureddata` field above (kept for backwards
@@ -267,6 +272,67 @@ class ProductShowJsonRenderer
return $data; return $data;
} }
/**
* The "Why choose" card band: editor-maintained tx_vitec_product_feature
* records in sorting order, icons resolved to urls. The heading is the
* first <h3> of the applications HTML, falling back to the generic
* "Why choose <title>?".
*
* @return array{heading:string,items:array<int,array<string,mixed>>}|null
*/
protected function featuresBand(int $productUid, string $applicationsHtml, string $productTitle): ?array
{
try {
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$rows = $pool->getConnectionForTable('tx_vitec_product_feature')->fetchAllAssociative(
'SELECT uid, title, cardtext FROM tx_vitec_product_feature
WHERE deleted = 0 AND hidden = 0 AND product = ? ORDER BY sorting, uid',
[$productUid]
);
if ($rows === []) {
return null;
}
$icons = [];
foreach ($pool->getConnectionForTable('sys_file_reference')->fetchAllAssociative(
"SELECT r.uid_foreign, r.alternative, f.identifier 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_product_feature'
AND r.fieldname = 'icon'
AND r.uid_foreign IN (" . implode(',', array_map('intval', array_column($rows, 'uid'))) . ')
ORDER BY r.uid_foreign, r.sorting_foreign'
) as $iconRow) {
$icons[(int)$iconRow['uid_foreign']] ??= [
'url' => '/fileadmin' . (string)$iconRow['identifier'],
'alternative' => trim((string)($iconRow['alternative'] ?? '')),
];
}
$heading = '';
if (preg_match('#<h3[^>]*>(.*?)</h3>#is', $applicationsHtml, $m)) {
$heading = trim(strip_tags($m[1]));
}
if ($heading === '') {
$heading = 'Why choose ' . $productTitle . '?';
}
$items = [];
foreach ($rows as $row) {
$icon = $icons[(int)$row['uid']] ?? null;
if ($icon !== null && $icon['alternative'] === '') {
$icon['alternative'] = (string)$row['title'];
}
$items[] = [
'title' => (string)$row['title'],
'text' => trim((string)($row['cardtext'] ?? '')),
'icon' => $icon,
];
}
return ['heading' => $heading, 'items' => $items];
} catch (\Throwable) {
return null;
}
}
/** /**
* Build the product's structured-data @graph from the already-serialized * Build the product's structured-data @graph from the already-serialized
* product array. Exception-safe: returns '' on any failure. * product array. Exception-safe: returns '' on any failure.
@@ -23,7 +23,7 @@ return [
], ],
], ],
'types' => [ 'types' => [
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, related_texts, solutions, '1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, features, highlights, contentelement, contentelementcta, portfolio, showdatapath, textrelatedproducts, relatedprodukt, related_texts, solutions,
--div--;Resources, resources, resources_layout, --div--;Resources, resources, resources_layout,
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata, --div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile, --div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
@@ -484,6 +484,27 @@ return [
], ],
], ],
], ],
// "Why choose <product>?" cards (contract 0036-FE, 2026-11-04): the
// structured twin of the `applications` RTE blob - one record per card
// (title, text, optional icon). The front end renders `features` when
// items exist and falls back to parsing `applications` otherwise, so
// editors may keep working in either place during the transition.
'features' => [
'exclude' => true,
'label' => 'Why-choose Features',
'description' => '"Why choose" cards of the detail page: title, text and icon per card. When at least one card exists, the front end shows these instead of parsing the Applications HTML.',
'config' => [
'type' => 'inline',
'foreign_table' => 'tx_vitec_product_feature',
'foreign_field' => 'product',
'foreign_sortby' => 'sorting',
'appearance' => [
'collapseAll' => true,
'levelLinksPosition' => 'top',
'useSortable' => true,
],
],
],
'resources' => [ 'resources' => [
'exclude' => true, 'exclude' => true,
'label' => 'Resources', 'label' => 'Resources',
@@ -0,0 +1,85 @@
<?php
/**
* "Why choose <product>?" cards of the product detail page (contract
* 0036-FE, 2026-11-04): title + short text + optional icon, rendered as the
* centered card band under the intro ("Find the right appliance for your
* application" in the Figma). Child table of tx_vitec_domain_model_product
* via the `features` inline field - edited only there (hideTable), no
* Extbase model on purpose: ProductShowJsonRenderer queries the rows
* directly. Seeded once from the `applications` HTML pairs plus the staged
* agency icons by vitec:seed-product-features; afterwards editors own the
* records (the seeder never touches a product that already has cards).
*/
return [
'ctrl' => [
'title' => 'Product Feature (Why choose)',
'label' => 'title',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
'hideTable' => true,
'enablecolumns' => [
'disabled' => 'hidden',
],
'iconfile' => 'EXT:vitec/Resources/Public/Icons/tx_vitec_domain_model_download.gif',
'security' => [
'ignorePageTypeRestriction' => true,
],
],
'types' => [
'1' => ['showitem' => 'title, cardtext, icon, hidden'],
],
'columns' => [
'hidden' => [
'exclude' => true,
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.visible',
'config' => [
'type' => 'check',
'renderType' => 'checkboxToggle',
'items' => [
[
'label' => '',
'invertStateDisplay' => true,
],
],
],
],
'product' => [
'config' => [
'type' => 'passthrough',
],
],
'title' => [
'exclude' => true,
'label' => 'Card title',
'config' => [
'type' => 'input',
'size' => 40,
'eval' => 'trim',
'default' => '',
],
],
'cardtext' => [
'exclude' => true,
'label' => 'Card text',
'config' => [
'type' => 'text',
'cols' => 40,
'rows' => 3,
'eval' => 'trim',
],
],
'icon' => [
'exclude' => true,
'label' => 'Icon',
'description' => 'Feature icon (SVG). The agency set lives under fileadmin/icons/delivered/products/.',
'config' => [
'type' => 'file',
'maxitems' => 1,
'allowed' => 'common-image-types,svg',
],
],
],
];
+18
View File
@@ -63,6 +63,7 @@ CREATE TABLE tx_vitec_domain_model_product (
resources int(11) DEFAULT '0' NOT NULL, resources int(11) DEFAULT '0' NOT NULL,
resources_layout varchar(20) DEFAULT '50-50' NOT NULL, resources_layout varchar(20) DEFAULT '50-50' NOT NULL,
datasheets int(11) DEFAULT '0' NOT NULL, datasheets int(11) DEFAULT '0' NOT NULL,
features int(11) DEFAULT '0' NOT NULL,
KEY parent (pid) KEY parent (pid)
); );
CREATE TABLE tx_vitec_domain_model_download ( CREATE TABLE tx_vitec_domain_model_download (
@@ -135,6 +136,23 @@ CREATE TABLE tx_vitec_product_resource (
KEY product (product) KEY product (product)
); );
CREATE TABLE tx_vitec_product_feature (
uid int(11) NOT NULL auto_increment,
pid int(11) DEFAULT '0' NOT NULL,
tstamp int(11) DEFAULT '0' NOT NULL,
crdate int(11) DEFAULT '0' NOT NULL,
deleted tinyint(4) DEFAULT '0' NOT NULL,
hidden tinyint(4) DEFAULT '0' NOT NULL,
sorting int(11) DEFAULT '0' NOT NULL,
product int(11) unsigned DEFAULT '0' NOT NULL,
title varchar(255) DEFAULT '' NOT NULL,
cardtext text,
icon int(11) unsigned DEFAULT '0' NOT NULL,
PRIMARY KEY (uid),
KEY parent (pid),
KEY product (product)
);
CREATE TABLE tx_vitec_domain_model_usecase ( CREATE TABLE tx_vitec_domain_model_usecase (
title varchar(255) DEFAULT '' NOT NULL, title varchar(255) DEFAULT '' NOT NULL,
slug varchar(255) DEFAULT '' NOT NULL, slug varchar(255) DEFAULT '' NOT NULL,
+23 -3
View File
@@ -198,7 +198,8 @@ foreach ($products as $p) {
$resourceLines[] = $parts === [] ? ('#' . $r['uid']) : implode(' · ', $parts); $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>' . '<a href="/product/' . h(trim((string)$p['slug'], '/')) . '" target="_blank" rel="noopener">/product/' . h(trim((string)$p['slug'], '/')) . '</a>'
. ' · uid ' . $uid . ' · uid ' . $uid
. ' · resources_layout: ' . h((string)$p['resources_layout']) . ' · resources_layout: ' . h((string)$p['resources_layout'])
@@ -219,14 +220,14 @@ foreach ($products as $p) {
. listBlock('Datasheets', $ds) . listBlock('Datasheets', $ds)
. listBlock('Solutions', $sol); . 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']) . '<td class="name"><span class="caret">▸</span>' . h((string)$p['title'])
. ($flags !== [] ? ' <small class="flags">' . h(implode(' ', $flags)) . '</small>' : '') . ($flags !== [] ? ' <small class="flags">' . h(implode(' ', $flags)) . '</small>' : '')
. '</td>' . $cells . '</tr>' . "\n" . '</td>' . $cells . '</tr>' . "\n"
. '<tr class="detail"><td colspan="' . (count($columns) + 1) . '"><div class="detailwrap">' . $detail . '</div></td></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) { foreach ($columns as $column) {
$headCells .= '<th title="' . h($column) . ' filled: ' . $filledPerColumn[$column] . '/' . count($products) . '">' $headCells .= '<th title="' . h($column) . ' filled: ' . $filledPerColumn[$column] . '/' . count($products) . '">'
. h($column) . '<small>' . $filledPerColumn[$column] . '</small></th>'; . 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%} 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} 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} 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} 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} td.name,th.name{text-align:left;font-weight:600;max-width:340px;white-space:normal}
tr.prod{cursor:pointer} tr.prod{cursor:pointer}
@@ -267,6 +271,8 @@ tr.detail{display:none}
tr.detail.show{display:table-row} tr.detail.show{display:table-row}
tr.detail>td{background:#fafbfd;text-align:left;white-space:normal;padding:0} tr.detail>td{background:#fafbfd;text-align:left;white-space:normal;padding:0}
.detailwrap{padding:14px 18px;max-width:1100px} .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{color:#666;font-size:12.5px;margin-bottom:10px}
.meta a{color:#26358c} .meta a{color:#26358c}
.fld{margin-bottom:12px} .fld{margin-bottom:12px}
@@ -308,6 +314,20 @@ document.querySelectorAll('tr.prod').forEach(function (row) {
row.nextElementSibling.classList.toggle('show'); 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 lb = document.getElementById('lightbox');
var lbImg = lb.querySelector('img'); var lbImg = lb.querySelector('img');
var lbCap = lb.querySelector('.cap'); var lbCap = lb.querySelector('.cap');