Products: 20 overview pages and megamenu catalog v2

Rebuild the product section after the agency sitemap and menu prototype
of 2026-11-02:

- vitec:build-product-overviews builds sysfolder labels (3 categories,
  9 groups) below /products with 20 overview pages /products/<slug>
  (layout 9: hero, intro per group, product cards, contact CTA) and two
  menu shortcuts. Dry-run by default, idempotent, explicit sorting.
- ProvideProductCatalog reads that page tree and emits catalog version 2:
  categories[].groups[].items[] of type page (productCount, products,
  image) or shortcut (target indices), plus viewAll, news and search.
- Transitional v1 fields (groups[].products/link/uid, categories[].uid/
  link, product teaser) keep the deployed front end alive; it read the
  v1 shape and rendered a white page without them. Remove once the
  front end reads items.
- ProvideInsightsSpotlight::latestNews() is public with a count.
- Product type-ahead (type 7385) adds matching overview pages and a
  per-product overview with its menu path.
- Spec v1.28.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-02 16:06:44 +01:00
1 parent da2ebdca23
commit 9fb7f0ca77
5 files changed
+917 -132

No files matched your search

@@ -0,0 +1,527 @@
<?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\Utility\GeneralUtility;
/**
* Builds the product section structure of the agency's "VITEC Product
* Sitemap" / "Product Menu Prototype" (delivery 2026-11-02) in the page tree
* below /products:
*
* /products
* [folder] category (A, menu label) e.g. "Platforms and End-Points"
* [folder] sub-category (B, menu label) e.g. "End-Points"
* overview page (C, /products/<slug>) e.g. "X-Player End-Points"
* [shortcut] menu shortcut e.g. "Video Wall Controllers"
*
* The page tree is the single source of the products menu (ProvideProduct
* Catalog reads it): editors rename, reorder or move overview pages there.
* A and B are sysfolders - never routed, never in navigation menus. The 20
* overview pages get flat slugs (decision Olli 2026-11-02) and, when they
* are new or still empty, generated content: hero (page title), one intro
* paragraph per grouping title (or "Product range"), a product card per
* sub-product (record teaser, link /product/<slug>), contact pre-footer.
* Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images.
*
* Decisions without customer confirmation (Olli: proceed, nothing deleted):
* - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the
* existing records (UHD2, SD14, HD4, ...) - very likely an Excel
* auto-fill artefact; cards show the RECORD title
* - products outside the sitemap (APEX, Arqa family, Avedia/EZ TV
* End-Points, VisionSC-SDI4, ActiveSQX) stay as records, just not in
* the new structure; the nine old category pages stay as well
* - "Avedia Modular Chassis" exists twice in the sitemap and as two
* records - each occurrence is pinned to one record by slug
*
* Idempotent: existing folders/pages/shortcuts are found by title (folders,
* shortcuts) or slug (overview pages); content is only generated on pages
* without any content element, so editorial work is never overwritten.
*
* vendor/bin/typo3 vitec:build-product-overviews
* vendor/bin/typo3 vitec:build-product-overviews --apply
*/
#[AsCommand(
name: 'vitec:build-product-overviews',
description: 'Build the product menu structure (folders, overview pages, shortcuts) from the 2026-11 sitemap (dry-run by default)'
)]
final class BuildProductOverviewsCommand extends Command
{
private const LAYOUT = 9;
private const DOKTYPE_PAGE = 1;
private const DOKTYPE_SHORTCUT = 4;
private const DOKTYPE_FOLDER = 254;
private const COLPOS_HERO = 1;
private const COLPOS_MAIN = 0;
private const COLPOS_PREFOOTER = 3;
/**
* The agency sitemap (VITEC Product Sitemap.html, 2026-11-02). Items are
* product titles; ['slug' => ...] pins a record where the title is
* ambiguous or differs; ['group' => ...] is a grouping title (not a page).
* Shortcuts point to a group (B) or an overview page by slug.
*/
private const STRUCTURE = [
['Platforms and End-Points', [
['IPTV Distribution & Signage Platform', [
['page' => 'Avedia', 'slug' => 'avedia', 'items' => ['Avedia Platform']],
['page' => 'EZ TV', 'slug' => 'ez-tv', 'items' => ['EZ TV Platform']],
]],
['End-Points', [
['page' => 'X-Player End-Points', 'slug' => 'x-player-end-points', 'items' => ['X-Player End-Points', '95-Series End-Points']],
['page' => 'X-View Integrated Displays', 'slug' => 'x-view-integrated-displays', 'items' => ['X-View Integrated Displays']],
['shortcut' => 'Video Wall Controllers', 'toGroup' => 'Video Walls'],
]],
['Control Room Platform', [
['page' => 'Aetria', 'slug' => 'aetria', 'items' => ['Aetria WallControl', 'Aetria Command Center', 'Aetria Network Manager', 'Aetria Touch']],
['page' => 'Operator Workstations', 'slug' => 'operator-workstations', 'items' => ['Aetria Workstation', 'Aligo Workstation']],
['shortcut' => 'Aligo KVM', 'toPage' => 'aligo'],
]],
]],
['IP Video Streaming, Distribution & KVM', [
['Encoders & Decoders', [
['page' => 'Appliances', 'slug' => 'appliances', 'items' => [
['MGW Diamond-H', 'HDMI encoder'], ['MGW Diamond', 'SDI encoder'], ['MGW Ace encoder', 'ultra-low latency encoder'],
['MGW Ace decoder', 'ultra-low latency decoder'], ['MGW Pico', 'portable encoder'],
]],
['page' => 'Modular Systems', 'slug' => 'modular-systems', 'items' => [
['group' => 'VITEC OG', 'items' => [
['MGW Diamond OG', 'SDI & composite blade encoder'], ['MGW Diamond+ OG', 'SDI blade encoder'],
['MGW Diamond-Hx OG', 'HDMI/DVI blade encoder'], ['MGW Ace Decoder OG', 'ultra-low latency blade decoder'],
['Diamond IP-OG', 'IP to IP encoder'],
]],
['group' => 'Avedia', 'items' => [
['38-Series Encoders', 'modular blade systems'], ['39-Series Encoders', 'modular blade systems'],
['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210'],
]],
['group' => 'Blade System', 'items' => [
['MGES7000', 'High Density 4K/UHD/HD HEVC & H.264 IPTV Encoding Blade'],
['MGES6000', 'Broadcast-quality HD/SD H.264 Quad-Input IPTV Encoder'],
]],
]],
['page' => 'Rugged Appliances', 'slug' => 'rugged-appliances', 'items' => ['MGW Diamond TOUGH', 'MGW Pico+ TOUGH', 'MGW Pico TOUGH']],
]],
['RF Gateway', [
['page' => 'Avedia Modular System', 'slug' => 'avedia-modular-system', 'items' => [
'45-series RF gateways', ['Avedia Modular Chassis', 'c1101, c1103, c1210', 'avedia-modular-chassis-c1101-c1103-c1210-1'],
]],
]],
['IP Gateway & Transcoder', [
['page' => 'ChannelLink IP Gateway', 'slug' => 'channellink-ip-gateway', 'items' => ['ChannelLink']],
['page' => 'PRISM Transcoder', 'slug' => 'prism-transcoder', 'items' => [
['PRISM', '1RU rack-mount transcoder'], ['SFF PRISM', 'Small format appliance transcoder'],
]],
]],
['AVoIP & KVM', [
['page' => 'Aligo', 'slug' => 'aligo', 'items' => ['QTX100', 'TX100', 'RX100', 'TX100 SQX']],
]],
]],
['Video Wall Controllers & Management', [
['Video Walls', [
['page' => 'VSN-Series Video Wall Controllers', 'slug' => 'vsn-series-video-wall-controllers', 'items' => ['VSN V3', 'VSNMicro 600', 'VSNMini 300']],
['page' => 'Video Wall Management Software', 'slug' => 'video-wall-management-software', 'items' => [
['Aetria', null, 'aetria'], 'Wall Control 10',
]],
['page' => 'VMS Plugins & Integrations', 'slug' => 'vms-plugins-and-integrations', 'items' => ['Genetec', 'Milestone']],
['page' => 'X-Series Multi-Display Processors', 'slug' => 'x-series-multi-display-processors', 'items' => ['Fx4', 'Fx4-HDR', 'Fx4-SDI', 'Hx4']],
]],
['Capture & Graphics', [
['page' => 'Image Graphics Cards', 'slug' => 'image-graphics-cards', 'items' => ['Image 2K', 'Image 4K', 'IQS4']],
['page' => 'Vision Video Capture Cards', 'slug' => 'vision-video-capture-cards', 'items' => [
['group' => 'VisionSC', 'items' => ['VisionSC-UHD3', 'VisionSC-DP3', 'VisionSC-SD15', 'VisionSC-HD4+', 'VisionSC-S3', 'VisionSC-A3']],
['group' => 'VisionIO', 'desc' => 'real-time capture + overlay', 'items' => ['VisionIO-SD15', 'VisionIO-XD3']],
['group' => 'VisionAV', 'desc' => 'video + audio capture', 'items' => ['VisionAV-SDI', 'VisionAV-HD', 'VisionAV', 'VisionHD5']],
['group' => 'Vision', 'desc' => 'DVI & SDI capture cards', 'items' => ['VisionRGB-E1S', 'VisionRGB-E2S', 'VisionSD4+1S', 'VisionSD12', 'VisionSD8']],
['group' => 'VisionLC', 'desc' => 'low-profile capture cards', 'items' => ['VisionLC-HD', 'VisionLC-HD3', 'VisionLC-SDI']],
]],
['page' => 'ActiveSQX', 'slug' => 'activesqx', 'desc' => 'IP encode/decode', 'items' => ['ActiveSQX2']],
]],
]],
];
/**
* Sitemap names that do not exist as records - mapped onto the existing
* record by slug. The Vision "+1" names are almost certainly an Excel
* auto-fill artefact (same pattern in the team tracker).
*/
private const NAME_TO_SLUG = [
'VisionSC-UHD3' => 'visionsc-uhd2',
'VisionSC-DP3' => 'visionsc-dp2',
'VisionSC-SD15' => 'visionsc-sd14',
'VisionSC-S3' => 'visionsc-s2',
'VisionSC-A3' => 'visionsc-a2',
'VisionIO-SD15' => 'visionio-sd14',
'VisionIO-XD3' => 'visionio-xd2',
'VisionHD5' => 'visionhd4',
'VisionLC-HD3' => 'visionlc-hd2',
];
/** @var array<string,array<string|int,array<string,mixed>>> */
private array $datamap = [];
/** @var array<string,string[]> parent id => ordered child page ids */
private array $pageOrder = [];
/** @var array<string,array<int,string[]>> page id => colPos => ordered content ids */
private array $contentOrder = [];
/** @var string[] */
private array $report = [];
private int $newCounter = 0;
/** @var array<int,array{uid:int,title:string,slug:string,teaser:string}>|null */
private ?array $products = null;
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');
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)");
if (!$root) {
$output->writeln('<error>/products not found.</error>');
return Command::FAILURE;
}
$rootId = (string)(int)$root['uid'];
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /products #' . $rootId);
$groupFolderIds = []; // B title => id
$overviewBySlug = []; // slug => id
$shortcuts = []; // [id, spec]
foreach (self::STRUCTURE as [$categoryTitle, $groups]) {
$categoryId = $this->folder($rootId, $categoryTitle, 'category');
foreach ($groups as [$groupTitle, $entries]) {
$groupId = $this->folder($categoryId, $groupTitle, 'sub-category');
$groupFolderIds[$groupTitle] = $groupId;
foreach ($entries as $entry) {
if (isset($entry['shortcut'])) {
$shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry];
continue;
}
$overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry);
}
}
}
// shortcut targets (a group folder or an overview page)
foreach ($shortcuts as [$shortcutId, $spec]) {
$target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null);
if ($target === null) {
$this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found';
continue;
}
$this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target;
$this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0;
}
foreach ($this->report as $line) {
$output->writeln($line);
}
if (!$apply) {
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
$this->applyOrder($dataHandler->substNEWwithIDs);
$output->writeln("\n<info>Written.</info> Next, per overview page:");
foreach ($overviewBySlug as $slug => $id) {
$output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply');
}
$output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush');
return Command::SUCCESS;
}
// ---- structure -------------------------------------------------------------------
private function folder(string $parentId, string $title, string $kind): string
{
$existing = ctype_digit($parentId) ? $this->childByTitle((int)$parentId, $title, self::DOKTYPE_FOLDER) : null;
if ($existing !== null) {
$this->report[] = sprintf('%s folder "%s" #%d (exists)', $kind, $title, $existing);
$this->pageOrder[$parentId][] = (string)$existing;
return (string)$existing;
}
$id = $this->newId();
$this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0];
$this->pageOrder[$parentId][] = $id;
$this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title);
return $id;
}
private function shortcutPage(string $groupId, string $title): string
{
$existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null;
if ($existing !== null) {
$this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing);
$this->pageOrder[$groupId][] = (string)$existing;
return (string)$existing;
}
$id = $this->newId();
$this->datamap['pages'][$id] = ['pid' => $this->pid($groupId), 'doktype' => self::DOKTYPE_SHORTCUT, 'title' => $title, 'hidden' => 0];
$this->pageOrder[$groupId][] = $id;
$this->report[] = sprintf(' shortcut "%s" CREATE', $title);
return $id;
}
/** @param array<string,mixed> $entry */
private function overviewPage(string $groupId, array $entry): string
{
$slug = '/products/' . $entry['slug'];
$pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
$existing = $pages->fetchAssociative(
'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]
);
[$cards, $names] = $this->resolveItems($entry['items']);
if ($existing) {
$pageId = (string)(int)$existing['uid'];
$this->pageOrder[$groupId][] = $pageId;
$updates = [];
if ((string)(int)$existing['pid'] !== $groupId) {
$updates['pid'] = $this->pid($groupId); // moved into its folder
}
if ($updates !== []) {
$this->datamap['pages'][$pageId] = $updates;
}
$contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content')
->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [(int)$pageId]);
$this->report[] = sprintf(' page "%s" %s #%s (exists, %d elements%s)', $entry['page'], $slug, $pageId, $contentCount,
$contentCount > 0 ? ' - content left untouched' : ' - content generated');
if ($contentCount === 0) {
$this->content($pageId, $entry, $cards);
}
} else {
$pageId = $this->newId();
$title = (string)$entry['page'];
$description = 'VITEC ' . $title . ': ' . implode(', ', $names) . '.';
$this->datamap['pages'][$pageId] = [
'pid' => $this->pid($groupId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0,
'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT,
'seo_title' => $title . ' | VITEC', 'description' => $description,
'og_title' => $title . ' | VITEC', 'og_description' => $description,
'abstract' => implode(', ', $names),
];
$this->pageOrder[$groupId][] = $pageId;
$this->report[] = sprintf(' page "%s" %s CREATE (%d product cards)', $entry['page'], $slug, count(array_filter($cards, fn($c) => !isset($c['group']))));
$this->content($pageId, $entry, $cards);
}
foreach ($cards as $card) {
if (isset($card['group'])) {
$this->report[] = ' [' . $card['group'] . ']';
} elseif ($card['record'] === null) {
$this->report[] = ' ! "' . $card['name'] . '" - NO record, card without link';
} elseif ($card['mapped']) {
$this->report[] = ' "' . $card['name'] . '" -> ' . $card['record']['title'] . ' (/product/' . $card['record']['slug'] . ', name mapped)';
}
}
return $pageId;
}
/**
* Flattens the sitemap items into card specs, grouping titles kept as
* markers.
*
* @param array<int,mixed> $items
* @return array{0:array<int,array<string,mixed>>,1:string[]}
*/
private function resolveItems(array $items): array
{
$cards = [];
$names = [];
foreach ($items as $item) {
if (is_array($item) && isset($item['group'])) {
$cards[] = ['group' => $item['group'], 'desc' => $item['desc'] ?? ''];
[$sub, $subNames] = $this->resolveItems($item['items']);
$cards = array_merge($cards, $sub);
$names = array_merge($names, $subNames);
continue;
}
[$name, $desc, $slug] = is_array($item) ? [$item[0], $item[1] ?? '', $item[2] ?? null] : [$item, '', null];
$slug ??= self::NAME_TO_SLUG[$name] ?? null;
$record = $slug !== null ? $this->productBySlug($slug) : $this->productByTitle($name);
$cards[] = ['name' => $name, 'desc' => (string)$desc, 'record' => $record, 'mapped' => isset(self::NAME_TO_SLUG[$name])];
$names[] = $record['title'] ?? $name;
}
return [$cards, $names];
}
/**
* Generated content of a new or empty overview page (layout 9 zones).
*
* @param array<string,mixed> $entry
* @param array<int,array<string,mixed>> $cards
*/
private function content(string $pageId, array $entry, array $cards): void
{
$this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', [
'header' => (string)$entry['page'], 'header_layout' => 1,
'bodytext' => isset($entry['desc']) ? $this->p(ucfirst((string)$entry['desc'])) : '',
]);
if (!isset($cards[0]['group'])) {
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']);
}
foreach ($cards as $card) {
if (isset($card['group'])) {
$this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', [
'header' => (string)$card['group'],
'bodytext' => $card['desc'] !== '' ? $this->p(ucfirst((string)$card['desc'])) : '',
]);
continue;
}
$record = $card['record'];
$teaser = trim(strip_tags((string)($record['teaser'] ?? '')));
$fields = [
'vitec_layout_variant' => 'text_only',
'header' => $record['title'] ?? $card['name'],
'bodytext' => $this->p($teaser !== '' ? $teaser : ucfirst((string)$card['desc'])),
'vitec_primary_cta_label' => 'Learn more',
];
if ($record !== null) {
$fields['vitec_primary_cta'] = '/product/' . $record['slug'];
}
$this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields);
}
$this->element($pageId, self::COLPOS_PREFOOTER, 'vitec_ctabanner', [
'header' => 'Talk to a VITEC expert',
'bodytext' => $this->p('Not sure which product fits your project? Our team helps you choose.'),
'vitec_primary_cta_label' => 'Contact us',
'vitec_primary_cta' => $this->contactLink(),
'vitec_layout_variant' => 'centered',
'vitec_background_variant' => 'orange',
]);
}
/** @param array<string,mixed> $fields */
private function element(string $pageId, int $colPos, string $ctype, array $fields): void
{
$id = $this->newId();
$this->datamap['tt_content'][$id] = ['pid' => $this->pid($pageId), 'colPos' => $colPos, 'CType' => $ctype] + $fields;
$this->contentOrder[$pageId][$colPos][] = $id;
}
// ---- lookups -----------------------------------------------------------------------
private function childByTitle(int $parentUid, string $title, int $doktype): ?int
{
$uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne(
'SELECT uid FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype = ? AND sys_language_uid IN (0,-1) ORDER BY uid',
[$parentUid, $title, $doktype]
);
return $uid !== false ? (int)$uid : null;
}
/** @return array<int,array{uid:int,title:string,slug:string,teaser:string}> */
private function products(): array
{
return $this->products ??= GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tx_vitec_domain_model_product')
->fetchAllAssociative('SELECT uid, title, slug, teaser FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY uid');
}
/** @return array<string,mixed>|null */
private function productBySlug(string $slug): ?array
{
foreach ($this->products() as $row) {
if ((string)$row['slug'] === $slug) {
return $row;
}
}
return null;
}
/** @return array<string,mixed>|null exact (normalized) title, first visible match by uid */
private function productByTitle(string $title): ?array
{
foreach ($this->products() as $row) {
if ($this->norm((string)$row['title']) === $this->norm($title)) {
return $row;
}
}
return null;
}
private function contactLink(): string
{
$row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')
->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/contact'");
return $row ? 't3://page?uid=' . (int)$row['uid'] : '';
}
// ---- plumbing ----------------------------------------------------------------------
/**
* DataHandler puts every NEW record with a positive pid at the TOP of
* its parent/column (lesson 2026-10-07) - sorting is written explicitly
* in sitemap order for pages and content alike.
*
* @param array<string,int> $subst
*/
private function applyOrder(array $subst): void
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$resolve = static fn(string $id): int => ctype_digit($id) ? (int)$id : (int)($subst[$id] ?? 0);
foreach ($this->pageOrder as $parentId => $children) {
$sorting = 0;
foreach ($children as $childId) {
$uid = $childId !== '' ? $resolve($childId) : 0;
if ($uid > 0) {
$sorting += 256;
$pool->getConnectionForTable('pages')->update('pages', ['sorting' => $sorting], ['uid' => $uid]);
}
}
}
foreach ($this->contentOrder as $pageId => $columns) {
foreach ($columns as $ids) {
$sorting = 0;
foreach ($ids as $id) {
$uid = $resolve($id);
if ($uid > 0) {
$sorting += 256;
$pool->getConnectionForTable('tt_content')->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
}
}
}
}
}
private function pid(string $id): int|string
{
return ctype_digit($id) ? (int)$id : $id;
}
private function p(string $text): string
{
$text = trim($text);
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_NOQUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string
{
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
}
private function newId(): string
{
return 'NEWPO' . (++$this->newCounter);
}
}
@@ -50,8 +50,13 @@ final class ProvideInsightsSpotlight
]);
}
/** @return array<int,array<string,mixed>> */
private function latestNews(int $stage): array
/**
* Public since 2026-11-02: the products catalog panel shows the same
* news cards ("Product news" slot) - one query, one card shape.
*
* @return array<int,array<string,mixed>>
*/
public function latestNews(int $stage, int $count = self::NEWS_COUNT): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
@@ -63,7 +68,7 @@ final class ProvideInsightsSpotlight
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
)
->orderBy('datetime', 'DESC')
->setMaxResults(self::NEWS_COUNT)
->setMaxResults($count)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return [];
@@ -5,50 +5,69 @@ declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\FileRepository;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Supplies the product catalog for megamenu entries with the layout
* `catalog` (2026-10-09, modelled on the Ross Video "Products" menu):
* main categories (middle column) -> subcategories as group headings ->
* products with a one-line teaser (right column), plus the endpoint of the
* product type-ahead in the panel's search bar.
* Supplies the products panel of the megamenu (entry layout `catalog`),
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
* the menu ends at the product OVERVIEW pages.
*
* Same tree and visibility rules as the product list and the Product
* Finder: level 1 below the root category "Product" are the main
* categories, their direct children the subcategories; products need
* legacy/supportproduct/hideonwebsite/hideonproducts/subproduct = 0. Within
* a group the uid order is kept (records were created in sitemap order).
* Links follow the Product Finder convention /products?cat=<main>&subcat=<sub>.
* categories[] (A, menu label) = sysfolders directly below /products
* groups[] (B, menu label) = sysfolders below those
* items[] type "page" = overview pages (/products/<slug>)
* type "shortcut" = shortcut pages, resolved to a target
* group or overview page of this tree
*
* Three queries for the whole catalog (categories, assignments, products) -
* the menu travels with every page.
* The page tree built by vitec:build-product-overviews is the single
* source: editors rename, reorder, hide (hidden/nav_hide) or move pages
* there. An overview item lists the products of its page (the vitec_card
* elements linking /product/<slug>, in creation order) - the sub-product
* pages the visitor reaches from the overview page; the menu does not show
* them, the count and the list serve the "N sub-products" line and search.
*
* Next to the tree: viewAll (/products), news (the latest news cards,
* shared with the Insights spotlight) and the type-ahead endpoint. The
* entry's teaser cards stay the editorial "Product highlight".
*
* Version 1 (2026-10-09) listed sys_category groups with their products;
* the nine old category pages are plain pages below /products and are not
* part of this tree.
*/
#[AsEventListener(identifier: 'vitec/megamenu-product-catalog')]
final class ProvideProductCatalog
{
private const ROOT_TITLE = 'Product';
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
private const PRODUCTS_PAGE = '/products';
private const PRODUCTS_SLUG = '/products';
private const SEARCH_ENDPOINT = '/_json/?type=7385';
private const TEASER_LENGTH = 120;
private const DOKTYPE_PAGE = 1;
private const DOKTYPE_SHORTCUT = 4;
private const DOKTYPE_FOLDER = 254;
private const NEWS_COUNT = 2;
public function __construct(private readonly ConnectionPool $connectionPool) {}
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly FileRepository $fileRepository,
private readonly ProvideInsightsSpotlight $spotlight,
) {}
public function __invoke(BuildCatalogEvent $event): void
{
try {
$stage = $event->getStage();
$event->setCatalog([
'version' => 2,
'search' => [
'endpoint' => self::SEARCH_ENDPOINT,
'param' => 'q',
'minChars' => 2,
'placeholder' => 'Search products',
],
'categories' => $this->categories(),
'viewAll' => ['title' => 'View all products', 'link' => self::PRODUCTS_SLUG],
'news' => $this->spotlight->latestNews($stage, self::NEWS_COUNT),
'categories' => $this->categories($stage),
]);
} catch (\Throwable) {
// fail-soft: the menu renders without the catalog rather than not at all
@@ -56,141 +75,206 @@ final class ProvideProductCatalog
}
/** @return array<int,array<string,mixed>> */
private function categories(): array
private function categories(int $stage): array
{
$root = $this->rootUid();
$root = $this->productsRootUid();
if ($root === 0) {
return [];
}
$mains = $this->childCategories([$root]);
$subs = $this->childCategories(array_column($mains, 'uid'));
$subsByParent = [];
foreach ($subs as $sub) {
$subsByParent[(int)$sub['parent']][] = $sub;
}
$categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]);
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_FOLDER]);
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
$overviewUids = array_map('intval', array_column(
array_filter($itemRows, static fn(array $r): bool => (int)$r['doktype'] === self::DOKTYPE_PAGE), 'uid'
));
$productsByPage = $this->productsOnPages($overviewUids);
$categoryUids = array_merge(array_column($mains, 'uid'), array_column($subs, 'uid'));
$productsByCategory = $this->productsByCategory(array_map('intval', $categoryUids));
$catalog = [];
foreach ($mains as $main) {
$mainUid = (int)$main['uid'];
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
$groupPos = [];
$pagePos = [];
$categories = [];
foreach ($categoryRows as $c => $categoryRow) {
$groups = [];
// products filed directly on the main category: an untitled first group
if (!empty($productsByCategory[$mainUid])) {
$groups[] = [
'uid' => $mainUid,
'title' => '',
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
'products' => $productsByCategory[$mainUid],
];
}
foreach ($subsByParent[$mainUid] ?? [] as $sub) {
$subUid = (int)$sub['uid'];
if (empty($productsByCategory[$subUid])) {
continue; // no empty headings in the menu
}
$groups[] = [
'uid' => $subUid,
'title' => (string)$sub['title'],
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid . '&subcat=' . $subUid,
'products' => $productsByCategory[$subUid],
];
}
if ($groups === []) {
foreach (array_values(array_filter($groupRows, fn(array $g): bool => (int)$g['pid'] === (int)$categoryRow['uid'])) as $g => $groupRow) {
$groupPos[(int)$groupRow['uid']] = [$c, $g];
$items = [];
foreach (array_values(array_filter($itemRows, fn(array $i): bool => (int)$i['pid'] === (int)$groupRow['uid'])) as $p => $itemRow) {
if ((int)$itemRow['doktype'] === self::DOKTYPE_SHORTCUT) {
$items[] = ['type' => 'shortcut', 'title' => (string)$itemRow['title'], 'shortcut' => (int)$itemRow['shortcut']];
continue;
}
$catalog[] = [
'uid' => $mainUid,
'title' => (string)$main['title'],
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
'groups' => $groups,
$uid = (int)$itemRow['uid'];
$pagePos[$uid] = [$c, $g, $p];
$products = $productsByPage[$uid] ?? [];
$item = [
'type' => 'page',
'uid' => $uid,
'title' => (string)$itemRow['title'],
'link' => (string)$itemRow['slug'],
'productCount' => count($products),
'products' => $products,
];
if ($stage >= 2) {
$item['image'] = $this->pageImage($uid);
}
return $catalog;
$items[] = $item;
}
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items] + $this->legacyGroupFields($items);
}
// legacy (v1) fields - see legacyGroupFields()
$categories[] = ['uid' => (int)$categoryRow['uid'], 'title' => (string)$categoryRow['title'], 'link' => self::PRODUCTS_SLUG, 'groups' => $groups];
}
private function rootUid(): int
// shortcuts: target = a group of this tree (opens that group) or an
// overview page (opens its group, highlights the page); anything else
// is dropped - a shortcut must not lead out of the menu silently
foreach ($categories as $c => $category) {
foreach ($category['groups'] as $g => $group) {
foreach ($group['items'] as $i => $item) {
if ($item['type'] !== 'shortcut') {
continue;
}
$targetUid = $item['shortcut'];
if (isset($groupPos[$targetUid])) {
[$tc, $tg] = $groupPos[$targetUid];
$resolved = ['category' => $tc, 'group' => $tg];
$link = '';
} elseif (isset($pagePos[$targetUid])) {
[$tc, $tg, $tp] = $pagePos[$targetUid];
$resolved = ['category' => $tc, 'group' => $tg, 'page' => $tp];
$link = (string)$categories[$tc]['groups'][$tg]['items'][$tp]['link'];
} else {
unset($categories[$c]['groups'][$g]['items'][$i]);
continue;
}
$categories[$c]['groups'][$g]['items'][$i] = [
'type' => 'shortcut',
'title' => $item['title'],
'link' => $link,
'target' => $resolved,
];
}
$categories[$c]['groups'][$g]['items'] = array_values($categories[$c]['groups'][$g]['items']);
}
}
return $categories;
}
/**
* TRANSITIONAL (2026-11-02): the deployed front end still reads the v1
* shape (`groups[].products` with title/teaser/link, `groups[].link`)
* and crashed the WHOLE site with v2 alone (white page). Each group
* therefore also carries the products of its overview pages, deduplicated
* by link, and the link of its first overview page. Remove once the
* front end has switched to `items` (briefing 2026-11-02).
*
* @param array<int,array<string,mixed>> $items
* @return array{uid:int,link:string,products:array<int,array<string,mixed>>}
*/
private function legacyGroupFields(array $items): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
return (int)$queryBuilder->select('uid')->from('sys_category')
$products = [];
$link = '';
foreach ($items as $item) {
if ($item['type'] !== 'page') {
continue;
}
$link = $link !== '' ? $link : (string)$item['link'];
foreach ($item['products'] as $product) {
$products[$product['link']] ??= $product;
}
}
return ['uid' => 0, 'link' => $link, 'products' => array_values($products)];
}
private function productsRootUid(): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
return (int)$queryBuilder->select('uid')->from('pages')
->where(
$queryBuilder->expr()->eq('parent', 0),
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter(self::ROOT_TITLE))
$queryBuilder->expr()->eq('slug', $queryBuilder->createNamedParameter(self::PRODUCTS_SLUG)),
$queryBuilder->expr()->in('sys_language_uid', [0, -1])
)
->setMaxResults(1)
->executeQuery()->fetchOne();
}
/**
* Visible child pages (frontend restrictions: hidden/deleted/start/end),
* nav_hide respected, in page tree order.
*
* @param int[] $parents
* @return array<int,array{uid:int,parent:int,title:string}>
* @param int[] $doktypes
* @return array<int,array<string,mixed>>
*/
private function childCategories(array $parents): array
private function children(array $parents, array $doktypes): array
{
if ($parents === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
return $queryBuilder->select('uid', 'parent', 'title')->from('sys_category')
->where($queryBuilder->expr()->in('parent', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)))
->orderBy('parent')->addOrderBy('sorting')
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
return $queryBuilder->select('uid', 'pid', 'title', 'slug', 'doktype', 'shortcut')->from('pages')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_map('intval', $parents), ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('nav_hide', 0),
$queryBuilder->expr()->in('sys_language_uid', [0, -1])
)
->orderBy('pid')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative();
}
/**
* @param int[] $categoryUids
* @return array<int,array<int,array{uid:int,title:string,teaser:string,link:string}>> category uid => products
* The products an overview page presents: its visible cards that link a
* product detail page (container children included), creation order.
*
* @param int[] $pageUids
* @return array<int,array<int,array{title:string,link:string}>> page uid => products
*/
private function productsByCategory(array $categoryUids): array
private function productsOnPages(array $pageUids): array
{
if ($categoryUids === []) {
if ($pageUids === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PRODUCT_TABLE);
$rows = $queryBuilder
->select('p.uid', 'p.title', 'p.teaser', 'p.slug', 'mm.uid_local AS category')
->from(self::PRODUCT_TABLE, 'p')
->join(
'p',
'sys_category_record_mm',
'mm',
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $queryBuilder->createNamedParameter(self::PRODUCT_TABLE)
. ' AND mm.fieldname = ' . $queryBuilder->createNamedParameter('categories')
)
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder->select('pid', 'header', 'bodytext', 'vitec_primary_cta')->from('tt_content')
->where(
$queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($categoryUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.legacy', 0),
$queryBuilder->expr()->eq('p.supportproduct', 0),
$queryBuilder->expr()->eq('p.hideonwebsite', 0),
$queryBuilder->expr()->eq('p.hideonproducts', 0),
$queryBuilder->expr()->eq('p.subproduct', 0),
$queryBuilder->expr()->neq('p.slug', $queryBuilder->createNamedParameter('', ParameterType::STRING))
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pageUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
$queryBuilder->expr()->like('vitec_primary_cta', $queryBuilder->createNamedParameter('/product/%'))
)
->orderBy('p.uid')
->orderBy('uid')
->executeQuery()->fetchAllAssociative();
$byCategory = [];
$byPage = [];
foreach ($rows as $row) {
$byCategory[(int)$row['category']][] = [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
'teaser' => $this->oneLiner((string)$row['teaser']),
'link' => '/product/' . ltrim((string)$row['slug'], '/'),
$byPage[(int)$row['pid']][] = [
'title' => (string)$row['header'],
'teaser' => trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$row['bodytext']), ENT_QUOTES | ENT_HTML5, 'UTF-8'))),
'link' => (string)$row['vitec_primary_cta'],
];
}
return $byCategory;
return $byPage;
}
/** Plain one-line teaser: tags and entities resolved, whitespace collapsed, cut at a word. */
private function oneLiner(string $value): string
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
private function pageImage(int $pageUid): ?array
{
$text = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) <= self::TEASER_LENGTH) {
return $text;
try {
$references = $this->fileRepository->findByRelation('pages', 'media', $pageUid);
if ($references === []) {
return null;
}
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
$url = $imageService->getImageUri($processed);
return [
'url' => str_starts_with($url, '/') || preg_match('#^https?://#', $url) ? $url : '/' . $url,
'alternative' => (string)($references[0]->getAlternative() ?? ''),
'width' => (int)$processed->getProperty('width'),
'height' => (int)$processed->getProperty('height'),
];
} catch (\Throwable) {
return null;
}
$cut = mb_substr($text, 0, self::TEASER_LENGTH);
$space = mb_strrpos($cut, ' ');
return rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …';
}
}
@@ -17,7 +17,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*
* { "query": "mgw d",
* "suggestions": [ "MGW Diamond", "MGW Diamond-H", ... ],
* "products": [ { "title", "teaser", "image", "url" } ] }
* "overviews": [ { "title", "link", "path": [category, group] } ],
* "products": [ { "title", "teaser", "image", "url",
* "overview": { "title", "link", "path" } | null } ] }
*
* Since menu v2 (2026-11-02) overview pages of the products tree match by
* title (every typed term contained) and lead the suggestions; each product
* names the overview page linking it.
*
* Runs through Solr (decision Olli: synonyms, ranking and the indexed
* product set stay identical to the site search), restricted to product
@@ -60,12 +66,113 @@ final class ProductMenuSearchJsonRenderer
// e.g. a typed hyphen or a complete word the prefix form misses
$documents = $this->search($connection, $query, false);
}
return $this->json($query, $this->cards($documents));
// menu v2 (2026-11-02): overview pages are hits of their own, and
// every product names the overview page it belongs to (path)
$overviews = $this->overviewPages();
$products = $this->withOverview($this->cards($documents), $overviews);
return $this->json($query, $products, $this->matchingOverviews($overviews, $query));
} catch (\Throwable) {
return $this->json($query, []);
}
}
/**
* The overview pages of the products menu tree (/products > folder >
* folder > page, see ProvideProductCatalog) with their menu path.
*
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
*/
private function overviewPages(): array
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$root = (int)$pool->getConnectionForTable('pages')->fetchOne(
"SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)"
);
if ($root === 0) {
return [];
}
$level = function (array $parents, array $doktypes) use ($pool): array {
if ($parents === []) {
return [];
}
$queryBuilder = $pool->getQueryBuilderForTable('pages');
return $queryBuilder->select('uid', 'pid', 'title', 'slug')->from('pages')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('nav_hide', 0)
)
->orderBy('sorting')
->executeQuery()->fetchAllAssociative();
};
$categories = array_column($level([$root], [254]), null, 'uid');
$groups = array_column($level(array_keys($categories), [254]), null, 'uid');
$overviews = [];
foreach ($level(array_keys($groups), [1]) as $page) {
$group = $groups[(int)$page['pid']];
$overviews[(int)$page['uid']] = [
'uid' => (int)$page['uid'],
'title' => (string)$page['title'],
'link' => (string)$page['slug'],
'path' => [(string)$categories[(int)$group['pid']]['title'], (string)$group['title']],
];
}
return $overviews;
}
/**
* Adds `overview` {title, link, path} to every product card whose detail
* page is linked from an overview page's cards (first one wins).
*
* @param array<int,array<string,mixed>> $products
* @param array<int,array<string,mixed>> $overviews
* @return array<int,array<string,mixed>>
*/
private function withOverview(array $products, array $overviews): array
{
if ($products === [] || $overviews === []) {
return $products;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder->select('pid', 'vitec_primary_cta')->from('tt_content')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_keys($overviews), ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
$queryBuilder->expr()->in('vitec_primary_cta', $queryBuilder->createNamedParameter(array_column($products, 'url'), ArrayParameterType::STRING))
)
->orderBy('uid')
->executeQuery()->fetchAllAssociative();
$overviewByUrl = [];
foreach ($rows as $row) {
$overviewByUrl[(string)$row['vitec_primary_cta']] ??= $overviews[(int)$row['pid']];
}
foreach ($products as &$product) {
$overview = $overviewByUrl[$product['url']] ?? null;
$product['overview'] = $overview === null ? null
: ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
}
return $products;
}
/**
* Overview pages whose title contains every typed term (case-insensitive).
*
* @param array<int,array<string,mixed>> $overviews
* @return array<int,array{title:string,link:string,path:string[]}>
*/
private function matchingOverviews(array $overviews, string $query): array
{
$terms = preg_split('/\s+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$hits = [];
foreach ($overviews as $overview) {
$haystack = mb_strtolower($overview['title'] . ' ' . implode(' ', $overview['path']));
if ($terms !== [] && array_reduce($terms, fn(bool $all, string $t): bool => $all && str_contains($haystack, $t), true)) {
$hits[] = ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
}
}
return $hits;
}
/** @return array<int,array<string,mixed>> Solr document fields, best first */
private function search(\ApacheSolrForTypo3\Solr\System\Solr\SolrConnection $connection, string $input, bool $prefixLastTerm): array
{
@@ -154,12 +261,16 @@ final class ProductMenuSearchJsonRenderer
return $teasers;
}
/** @param array<int,array<string,mixed>> $products */
private function json(string $query, array $products): string
/**
* @param array<int,array<string,mixed>> $products
* @param array<int,array<string,mixed>> $overviews
*/
private function json(string $query, array $products, array $overviews = []): string
{
return (string)json_encode([
'query' => $query,
'suggestions' => array_values(array_unique(array_column($products, 'title'))),
'suggestions' => array_values(array_unique(array_merge(array_column($overviews, 'title'), array_column($products, 'title')))),
'overviews' => $overviews,
'products' => $products,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
@@ -40,6 +40,7 @@
| 1.25 | 2026‑10‑23 | **Interface change (additive).** The markets branch is aligned with the agreed agency structure: `/markets` eleven market hero pages their sub‑market pages (the use‑case level stays sections per 7.17). New frontend layout **23 Market Hero Page** (zones hero = `colPos1`, main = `colPos0`): `vitec_markethero` (CTA anchors `#submarkets`) + `vitec_introparagraph` (header carries the hero H1) in the hero zone; `vitec_marketintro`, `vitec_introparagraph`, sub‑market `vitec_card`s whose `primary_cta` links the child page, `vitec_ctabanner` in main (`vitec_customerlogos` and `vitec_usecaselist` ship hidden until filled editorially). Imported by `vitec:import-market-hero-workbooks`; the tree itself by `vitec:align-market-pages` (renames with slug redirects, child slugs normalised — this also repairs embedded‑slash slugs). Sub‑market pages gained the full page‑properties set. |
| 1.26 | 2026‑10‑26 | **Interface change (additive).** Product detail resources band simplified (customer CR): the `resources` envelope gains **`sectionLayout`** — `50-50` (default) \| `33-33-33` — from the new per‑product select *Resources Section Layout* (Layout 1: Technical Highlights left, resource cards right, 50/50; Layout 2: highlights over two columns, cards in the third). Capabilities leave the band editorially; the TCA fields `capabilities`, `datasheets` and `downloads` are parked in the Misc tab — all three stay in the payload unchanged. The automatic newest‑datasheet/documentation cards remain deferred; meanwhile the manual card `restype` vocabulary gains `datasheet` and `brochure` — a pinned datasheet card links the download record and stays current because the download route always serves the newest file. |
| 1.27 | 2026‑11‑02 | **Interface change (additive).** Product *Resources Section Layout* gained the value `hardware` (“Layout 3 - Hardware option”): `resources.sectionLayout = "hardware"` — Technical Highlights over two columns, no resource cards (`resources.items` is emitted empty even when cards are maintained, so they reappear on a switch back). |
| 1.28 | 2026‑11‑02 | **Interface change (breaking for the products menu, additive elsewhere).** Product section rebuilt after the agency sitemap/menu prototype of 2026-11-02: 20 product overview pages `/products/<slug>` grouped by sysfolders (3 categories → 9 groups) below /products. The megamenu `catalog` payload is now **version 2**: `categories[].groups[].items[]` of type `page` (overview page with `productCount`/`products`/`image`) or `shortcut` (`target` indices), plus `viewAll` and `news`; the menu no longer lists products. Transitional v1 fields (`groups[].products` etc.) stay until the front end has switched. The product type-ahead (`?type=7385`) gained `overviews` and a per-product `overview` with menu path. |
This document is drafted in the style of, and adopts the terminology conventions of,
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
@@ -1182,22 +1183,79 @@ document type and the full indexed content.
(`ProductMenuSearchJsonRenderer`, USER_INT):
```json
{ "query": "mgw d",
"suggestions": [ "MGW Diamond+ OG", "MGW Diamond" ],
"products": [ { "title": "MGW Diamond+ OG", "teaser": "Broadcast-grade 4K HEVC …",
"image": "/fileadmin/_processed_/…", "url": "/product/…" } ] }
{ "query": "vision",
"suggestions": [ "Vision Video Capture Cards", "VisionSD8" ],
"overviews": [ { "title": "Vision Video Capture Cards", "link": "/products/vision-video-capture-cards",
"path": [ "Video Wall Controllers & Management", "Capture & Graphics" ] } ],
"products": [ { "title": "VisionSD8", "teaser": "Multi-channel SDI capture …",
"image": "/fileadmin/_processed_/…", "url": "/product/visionsd8",
"overview": { "title": "Vision Video Capture Cards",
"link": "/products/vision-video-capture-cards",
"path": [ "Video Wall Controllers & Management", "Capture & Graphics" ] } } ] }
```
It runs through Solr restricted to product documents; the last term is
completed as a prefix, so cards appear while typing (inputs below two
characters yield the empty shape). At most six products, one card per
product; `suggestions` are their titles for the autocomplete list. `teaser`
is the product's plain-text one-liner from the record (≤ 120 characters),
`image` the indexed 640 px tile or `null`. The endpoint and its parameters
travel with the megamenu catalog payload (`catalog.search`); the catalog
itself (`layout: "catalog"`, categories → groups → products) is produced by
the `ProvideProductCatalog` listener of EXT:evo_megamenu_json’s
`BuildCatalogEvent` - see that extension’s README.
product. Since v1.28 `overviews` lists the product overview pages whose
title (or menu path) contains every typed term, and each product carries
`overview` - the overview page that presents it, with its menu path - or
`null` for a product outside the menu tree. `suggestions` are the overview
titles followed by the product titles. `teaser` is the product's plain-text
one-liner from the record (≤ 120 characters), `image` the indexed 640 px
tile or `null`.
**Products menu (megamenu entry `layout: "catalog"`, version 2 since v1.28).**
The catalog payload is produced by the `ProvideProductCatalog` listener of
EXT:evo_megamenu_json’s `BuildCatalogEvent` from the page tree below
`/products`: sysfolders on the first level are the menu **categories**,
sysfolders below them the **groups** (both labels, never pages), and the
pages below the groups the **overview pages** (`/products/<slug>`, built by
`vitec:build-product-overviews` from the agency sitemap of 2026-11-02) or
**shortcut** pages. The menu ends at the overview pages; product detail
pages are reached from there.
```json
"catalog": {
"version": 2,
"search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" },
"viewAll": { "title": "View all products", "link": "/products" },
"news": [ { "title": "…", "date": "2026-10-28", "link": "/news/article/…", "teaser": "…", "image": {} } ],
"categories": [ { "title": "IP Video Streaming, Distribution & KVM",
"groups": [ { "title": "Encoders & Decoders",
"items": [
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
"image": null },
{ "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo",
"target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ]
}
```
- `items[].type` is `page` (an overview page) or `shortcut`. A shortcut's
`target` holds zero-based indices into `categories` / `groups` / `items`:
with `page` it points to an overview page (and `link` is that page),
without `page` it opens a whole group (`link` is `""`).
- `products` are the product cards of the overview page that link a detail
page (container children included, creation order); `productCount` is
their number - a card without a resolvable product is not counted.
- `image` (from content stage 2) is the overview page's first page media as a
640 px WebP, `null` when none is set.
- `news` are the latest news cards (same shape and query as the Insights
spotlight, here two); the entry's `teasers` remain the editorial product
highlight.
- Hidden pages and pages with *Hide in menu* drop out; order = page tree
order. Version 1 (2026-10-09, sys_category groups with products) is no
longer the source.
- **Transitional v1 fields (deprecated, removed once the front end reads
`items`).** The deployed front end still evaluated the v1 shape for the
active state of the Products trigger and crashed the whole site without
it. Until it has switched, v2 additionally carries: `categories[].uid`
(folder uid) and `categories[].link` (`/products`); `groups[].uid` (`0`),
`groups[].link` (link of the group's first overview page) and
`groups[].products` (the products of all its overview pages, deduplicated
by link); and a `teaser` (card text, plain) on every product. Do not build
on these fields.
**Operations.** Indexing runs automatically: record and page saves enter the
index queue, a scheduler task (Index Queue Worker, every 5 minutes) pushes them
@@ -1616,4 +1674,4 @@ remediation.
- Header convention — the uniform header section across CEs, plugins and containers.
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
*End of document EVO‑VITEC‑HL‑001 v1.27.*
*End of document EVO‑VITEC‑HL‑001 v1.28.*