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:
1 parent
da2ebdca23
commit
9fb7f0ca77
5 files changed
+918
-133
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 = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
|
||||||
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
|
||||||
@@ -63,7 +68,7 @@ final class ProvideInsightsSpotlight
|
|||||||
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
|
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
|
||||||
)
|
)
|
||||||
->orderBy('datetime', 'DESC')
|
->orderBy('datetime', 'DESC')
|
||||||
->setMaxResults(self::NEWS_COUNT)
|
->setMaxResults($count)
|
||||||
->executeQuery()->fetchAllAssociative();
|
->executeQuery()->fetchAllAssociative();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -5,50 +5,69 @@ declare(strict_types=1);
|
|||||||
namespace Evomedien\Vitec\EventListener;
|
namespace Evomedien\Vitec\EventListener;
|
||||||
|
|
||||||
use Doctrine\DBAL\ArrayParameterType;
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
use Doctrine\DBAL\ParameterType;
|
|
||||||
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
|
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
|
||||||
use TYPO3\CMS\Core\Attribute\AsEventListener;
|
use TYPO3\CMS\Core\Attribute\AsEventListener;
|
||||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
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
|
* Supplies the products panel of the megamenu (entry layout `catalog`),
|
||||||
* `catalog` (2026-10-09, modelled on the Ross Video "Products" menu):
|
* version 2 (2026-11-02, agency "Product Menu Prototype"): three levels,
|
||||||
* main categories (middle column) -> subcategories as group headings ->
|
* the menu ends at the product OVERVIEW pages.
|
||||||
* products with a one-line teaser (right column), plus the endpoint of the
|
|
||||||
* product type-ahead in the panel's search bar.
|
|
||||||
*
|
*
|
||||||
* Same tree and visibility rules as the product list and the Product
|
* categories[] (A, menu label) = sysfolders directly below /products
|
||||||
* Finder: level 1 below the root category "Product" are the main
|
* groups[] (B, menu label) = sysfolders below those
|
||||||
* categories, their direct children the subcategories; products need
|
* items[] type "page" = overview pages (/products/<slug>)
|
||||||
* legacy/supportproduct/hideonwebsite/hideonproducts/subproduct = 0. Within
|
* type "shortcut" = shortcut pages, resolved to a target
|
||||||
* a group the uid order is kept (records were created in sitemap order).
|
* group or overview page of this tree
|
||||||
* Links follow the Product Finder convention /products?cat=<main>&subcat=<sub>.
|
|
||||||
*
|
*
|
||||||
* Three queries for the whole catalog (categories, assignments, products) -
|
* The page tree built by vitec:build-product-overviews is the single
|
||||||
* the menu travels with every page.
|
* 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')]
|
#[AsEventListener(identifier: 'vitec/megamenu-product-catalog')]
|
||||||
final class ProvideProductCatalog
|
final class ProvideProductCatalog
|
||||||
{
|
{
|
||||||
private const ROOT_TITLE = 'Product';
|
private const PRODUCTS_SLUG = '/products';
|
||||||
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
|
|
||||||
private const PRODUCTS_PAGE = '/products';
|
|
||||||
private const SEARCH_ENDPOINT = '/_json/?type=7385';
|
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
|
public function __invoke(BuildCatalogEvent $event): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$stage = $event->getStage();
|
||||||
$event->setCatalog([
|
$event->setCatalog([
|
||||||
|
'version' => 2,
|
||||||
'search' => [
|
'search' => [
|
||||||
'endpoint' => self::SEARCH_ENDPOINT,
|
'endpoint' => self::SEARCH_ENDPOINT,
|
||||||
'param' => 'q',
|
'param' => 'q',
|
||||||
'minChars' => 2,
|
'minChars' => 2,
|
||||||
'placeholder' => 'Search products',
|
'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) {
|
} catch (\Throwable) {
|
||||||
// fail-soft: the menu renders without the catalog rather than not at all
|
// 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>> */
|
/** @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) {
|
if ($root === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$mains = $this->childCategories([$root]);
|
$categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]);
|
||||||
$subs = $this->childCategories(array_column($mains, 'uid'));
|
$groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_FOLDER]);
|
||||||
$subsByParent = [];
|
$itemRows = $this->children(array_column($groupRows, 'uid'), [self::DOKTYPE_PAGE, self::DOKTYPE_SHORTCUT]);
|
||||||
foreach ($subs as $sub) {
|
$overviewUids = array_map('intval', array_column(
|
||||||
$subsByParent[(int)$sub['parent']][] = $sub;
|
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'));
|
// positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p]
|
||||||
$productsByCategory = $this->productsByCategory(array_map('intval', $categoryUids));
|
$groupPos = [];
|
||||||
|
$pagePos = [];
|
||||||
$catalog = [];
|
$categories = [];
|
||||||
foreach ($mains as $main) {
|
foreach ($categoryRows as $c => $categoryRow) {
|
||||||
$mainUid = (int)$main['uid'];
|
|
||||||
$groups = [];
|
$groups = [];
|
||||||
// products filed directly on the main category: an untitled first group
|
foreach (array_values(array_filter($groupRows, fn(array $g): bool => (int)$g['pid'] === (int)$categoryRow['uid'])) as $g => $groupRow) {
|
||||||
if (!empty($productsByCategory[$mainUid])) {
|
$groupPos[(int)$groupRow['uid']] = [$c, $g];
|
||||||
$groups[] = [
|
$items = [];
|
||||||
'uid' => $mainUid,
|
foreach (array_values(array_filter($itemRows, fn(array $i): bool => (int)$i['pid'] === (int)$groupRow['uid'])) as $p => $itemRow) {
|
||||||
'title' => '',
|
if ((int)$itemRow['doktype'] === self::DOKTYPE_SHORTCUT) {
|
||||||
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
|
$items[] = ['type' => 'shortcut', 'title' => (string)$itemRow['title'], 'shortcut' => (int)$itemRow['shortcut']];
|
||||||
'products' => $productsByCategory[$mainUid],
|
continue;
|
||||||
];
|
}
|
||||||
}
|
$uid = (int)$itemRow['uid'];
|
||||||
foreach ($subsByParent[$mainUid] ?? [] as $sub) {
|
$pagePos[$uid] = [$c, $g, $p];
|
||||||
$subUid = (int)$sub['uid'];
|
$products = $productsByPage[$uid] ?? [];
|
||||||
if (empty($productsByCategory[$subUid])) {
|
$item = [
|
||||||
continue; // no empty headings in the menu
|
'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);
|
||||||
|
}
|
||||||
|
$items[] = $item;
|
||||||
}
|
}
|
||||||
$groups[] = [
|
$groups[] = ['title' => (string)$groupRow['title'], 'items' => $items] + $this->legacyGroupFields($items);
|
||||||
'uid' => $subUid,
|
|
||||||
'title' => (string)$sub['title'],
|
|
||||||
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid . '&subcat=' . $subUid,
|
|
||||||
'products' => $productsByCategory[$subUid],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
if ($groups === []) {
|
// legacy (v1) fields - see legacyGroupFields()
|
||||||
continue;
|
$categories[] = ['uid' => (int)$categoryRow['uid'], 'title' => (string)$categoryRow['title'], 'link' => self::PRODUCTS_SLUG, 'groups' => $groups];
|
||||||
}
|
|
||||||
$catalog[] = [
|
|
||||||
'uid' => $mainUid,
|
|
||||||
'title' => (string)$main['title'],
|
|
||||||
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
|
|
||||||
'groups' => $groups,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return $catalog;
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function rootUid(): int
|
/**
|
||||||
|
* 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');
|
$products = [];
|
||||||
return (int)$queryBuilder->select('uid')->from('sys_category')
|
$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(
|
->where(
|
||||||
$queryBuilder->expr()->eq('parent', 0),
|
$queryBuilder->expr()->eq('slug', $queryBuilder->createNamedParameter(self::PRODUCTS_SLUG)),
|
||||||
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter(self::ROOT_TITLE))
|
$queryBuilder->expr()->in('sys_language_uid', [0, -1])
|
||||||
)
|
)
|
||||||
->setMaxResults(1)
|
->setMaxResults(1)
|
||||||
->executeQuery()->fetchOne();
|
->executeQuery()->fetchOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Visible child pages (frontend restrictions: hidden/deleted/start/end),
|
||||||
|
* nav_hide respected, in page tree order.
|
||||||
|
*
|
||||||
* @param int[] $parents
|
* @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 === []) {
|
if ($parents === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
|
||||||
return $queryBuilder->select('uid', 'parent', 'title')->from('sys_category')
|
return $queryBuilder->select('uid', 'pid', 'title', 'slug', 'doktype', 'shortcut')->from('pages')
|
||||||
->where($queryBuilder->expr()->in('parent', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)))
|
->where(
|
||||||
->orderBy('parent')->addOrderBy('sorting')
|
$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();
|
->executeQuery()->fetchAllAssociative();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int[] $categoryUids
|
* The products an overview page presents: its visible cards that link a
|
||||||
* @return array<int,array<int,array{uid:int,title:string,teaser:string,link:string}>> category uid => products
|
* 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PRODUCT_TABLE);
|
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
|
||||||
$rows = $queryBuilder
|
$rows = $queryBuilder->select('pid', 'header', 'bodytext', 'vitec_primary_cta')->from('tt_content')
|
||||||
->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')
|
|
||||||
)
|
|
||||||
->where(
|
->where(
|
||||||
$queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($categoryUids, ArrayParameterType::INTEGER)),
|
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($pageUids, ArrayParameterType::INTEGER)),
|
||||||
$queryBuilder->expr()->eq('p.legacy', 0),
|
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
|
||||||
$queryBuilder->expr()->eq('p.supportproduct', 0),
|
$queryBuilder->expr()->like('vitec_primary_cta', $queryBuilder->createNamedParameter('/product/%'))
|
||||||
$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))
|
|
||||||
)
|
)
|
||||||
->orderBy('p.uid')
|
->orderBy('uid')
|
||||||
->executeQuery()->fetchAllAssociative();
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$byPage = [];
|
||||||
$byCategory = [];
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$byCategory[(int)$row['category']][] = [
|
$byPage[(int)$row['pid']][] = [
|
||||||
'uid' => (int)$row['uid'],
|
'title' => (string)$row['header'],
|
||||||
'title' => (string)$row['title'],
|
'teaser' => trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$row['bodytext']), ENT_QUOTES | ENT_HTML5, 'UTF-8'))),
|
||||||
'teaser' => $this->oneLiner((string)$row['teaser']),
|
'link' => (string)$row['vitec_primary_cta'],
|
||||||
'link' => '/product/' . ltrim((string)$row['slug'], '/'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $byCategory;
|
return $byPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plain one-line teaser: tags and entities resolved, whitespace collapsed, cut at a word. */
|
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
|
||||||
private function oneLiner(string $value): string
|
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')));
|
try {
|
||||||
if (mb_strlen($text) <= self::TEASER_LENGTH) {
|
$references = $this->fileRepository->findByRelation('pages', 'media', $pageUid);
|
||||||
return $text;
|
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",
|
* { "query": "mgw d",
|
||||||
* "suggestions": [ "MGW Diamond", "MGW Diamond-H", ... ],
|
* "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
|
* Runs through Solr (decision Olli: synonyms, ranking and the indexed
|
||||||
* product set stay identical to the site search), restricted to product
|
* 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
|
// e.g. a typed hyphen or a complete word the prefix form misses
|
||||||
$documents = $this->search($connection, $query, false);
|
$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) {
|
} catch (\Throwable) {
|
||||||
return $this->json($query, []);
|
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 */
|
/** @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
|
private function search(\ApacheSolrForTypo3\Solr\System\Solr\SolrConnection $connection, string $input, bool $prefixLastTerm): array
|
||||||
{
|
{
|
||||||
@@ -154,12 +261,16 @@ final class ProductMenuSearchJsonRenderer
|
|||||||
return $teasers;
|
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([
|
return (string)json_encode([
|
||||||
'query' => $query,
|
'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,
|
'products' => $products,
|
||||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
], 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.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.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.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,
|
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
|
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):
|
(`ProductMenuSearchJsonRenderer`, USER_INT):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "query": "mgw d",
|
{ "query": "vision",
|
||||||
"suggestions": [ "MGW Diamond+ OG", "MGW Diamond" ],
|
"suggestions": [ "Vision Video Capture Cards", "VisionSD8" ],
|
||||||
"products": [ { "title": "MGW Diamond+ OG", "teaser": "Broadcast-grade 4K HEVC …",
|
"overviews": [ { "title": "Vision Video Capture Cards", "link": "/products/vision-video-capture-cards",
|
||||||
"image": "/fileadmin/_processed_/…", "url": "/product/…" } ] }
|
"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
|
It runs through Solr restricted to product documents; the last term is
|
||||||
completed as a prefix, so cards appear while typing (inputs below two
|
completed as a prefix, so cards appear while typing (inputs below two
|
||||||
characters yield the empty shape). At most six products, one card per
|
characters yield the empty shape). At most six products, one card per
|
||||||
product; `suggestions` are their titles for the autocomplete list. `teaser`
|
product. Since v1.28 `overviews` lists the product overview pages whose
|
||||||
is the product's plain-text one-liner from the record (≤ 120 characters),
|
title (or menu path) contains every typed term, and each product carries
|
||||||
`image` the indexed 640 px tile or `null`. The endpoint and its parameters
|
`overview` - the overview page that presents it, with its menu path - or
|
||||||
travel with the megamenu catalog payload (`catalog.search`); the catalog
|
`null` for a product outside the menu tree. `suggestions` are the overview
|
||||||
itself (`layout: "catalog"`, categories → groups → products) is produced by
|
titles followed by the product titles. `teaser` is the product's plain-text
|
||||||
the `ProvideProductCatalog` listener of EXT:evo_megamenu_json’s
|
one-liner from the record (≤ 120 characters), `image` the indexed 640 px
|
||||||
`BuildCatalogEvent` - see that extension’s README.
|
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
|
**Operations.** Indexing runs automatically: record and page saves enter the
|
||||||
index queue, a scheduler task (Index Queue Worker, every 5 minutes) pushes them
|
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.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `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.*
|
||||||
Reference in new issue
Block a user