diff --git a/packages/vitec/Classes/Command/BuildProductOverviewsCommand.php b/packages/vitec/Classes/Command/BuildProductOverviewsCommand.php index f6a3af4..f9d721a 100644 --- a/packages/vitec/Classes/Command/BuildProductOverviewsCommand.php +++ b/packages/vitec/Classes/Command/BuildProductOverviewsCommand.php @@ -21,21 +21,38 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; * below /products: * * /products - * [folder] category (A, menu label) e.g. "Platforms and End-Points" - * [folder] sub-category (B, menu label) e.g. "End-Points" + * category page (A, /products/) e.g. "Platforms and End-Points" + * sub-category page (B, /products/) e.g. "End-Points" * overview page (C, /products/) 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, placeholder + * All pages get flat slugs (decision Olli 2026-11-02). Overview pages, when + * new or still empty, get generated content: hero (page title, placeholder * background - 0026-FE), one intro paragraph per grouping title (or * "Product range"), a product card per sub-product (record teaser, link * /product/), contact pre-footer. Existing pages whose hero carries * no image get the placeholder backfilled; any existing reference wins. - * Afterwards run vitec:wrap-cards-grid per page and vitec:attach-card-images. + * Afterwards run vitec:wrap-cards-grid per page. + * + * A and B were sysfolders (menu labels) until 2026-11-05; Olli: every + * category and sub-category must be a clickable page. Decisions 2026-11-05: + * - ADOPT: six sub-categories ARE a 2026-10 category page (Joe's category + * workbooks; their "Why VITEC" cards match the sub-category icon sets). + * The page moves into the folder's place, keeps content, slug and SEO, + * gets the sitemap name as nav_title; the folder's children move below + * it and the emptied folder is deleted. + * - MERGE: "Capture & Graphics" gets the main column of BOTH matching + * pages (Graphics & Multi-Display, Video Capture) copied below each other. + * - the rest (3 categories, End-Points, RF Gateway) are converted folders + * with generated content: hero, "Product range", one card per child page + * (title, child list, the child's page media, link), contact pre-footer. + * - the 2026-10 pages without a place (Graphics & Multi-Display, Video + * Capture, Infrastructure & Accessories) leave the menus (nav_hide) but + * stay reachable. + * Folders become pages and pages move by direct UPDATE - not through the + * DataHandler, whose slug hook (EXT:redirects) would rewrite the flat slugs. * * Decisions without customer confirmation (Olli: proceed, nothing deleted): * - the sitemap's Vision names (UHD3, SD15, HD5, ...) map onto the @@ -152,6 +169,43 @@ final class BuildProductOverviewsCommand extends Command ]], ]; + /** category (A) and sub-category (B) pages built here: flat slugs like the overview pages */ + private const SECTION_SLUGS = [ + 'Platforms and End-Points' => 'platforms-and-end-points', + 'End-Points' => 'end-points', + 'IP Video Streaming, Distribution & KVM' => 'ip-video-streaming-distribution-and-kvm', + 'RF Gateway' => 'rf-gateway', + 'Video Wall Controllers & Management' => 'video-wall-controllers-and-management', + 'Capture & Graphics' => 'capture-and-graphics', + ]; + + /** + * Sub-categories that ARE a 2026-10 category page (Joe's category + * workbooks, imported 2026-10-27 - their "Why VITEC" cards match the + * agency's sub-category icon sets 1:1). Decision Olli 2026-11-05: the + * page moves into the tree in place of the folder and keeps content, + * slug and SEO; the sitemap name becomes its nav_title (menu label). + */ + private const ADOPT = [ + 'IPTV Distribution & Signage Platform' => '/products/iptv-and-digital-signage', + 'Control Room Platform' => '/products/control-room-platforms', + 'Encoders & Decoders' => '/products/video-encoding-and-streaming', + 'IP Gateway & Transcoder' => '/products/gateways-and-transcoding', + 'AVoIP & KVM' => '/products/av-over-ip-and-kvm', + 'Video Walls' => '/products/video-wall-and-display-processing', + ]; + + /** sub-category built from the content of two category pages (main column of both, in order) */ + private const MERGE = [ + 'Capture & Graphics' => ['/products/graphics-and-multi-display', '/products/video-capture'], + ]; + + /** 2026-10 category pages without a place in the tree: stay reachable, leave the menus */ + private const LEGACY_CATEGORY_SLUGS = [ + '/products/graphics-and-multi-display', '/products/video-capture', + '/products/infrastructure-and-accessories', + ]; + /** * Sitemap names that do not exist as records - mapped onto the existing * record by slug. The Vision "+1" names are almost certainly an Excel @@ -179,6 +233,24 @@ final class BuildProductOverviewsCommand extends Command private array $report = []; private int $newCounter = 0; private bool $apply = false; + /** @var array section page id => title */ + private array $sectionTitles = []; + /** @var array>> section page id => child specs (card order) */ + private array $sectionChildren = []; + /** @var string[] section page ids that get generated content (new or empty) */ + private array $sectionsToFill = []; + /** @var array> folder uid => page fields, written by direct UPDATE */ + private array $conversions = []; + /** @var int[] legacy category pages that leave the menus */ + private array $legacyToHide = []; + /** @var array adopted 2026-10 pages, applied by direct UPDATE */ + private array $adoptions = []; + /** @var array sub-category id => id of the node its children currently hang on (the old folder) */ + private array $lookupParent = []; + /** @var array merge target id => source page uids */ + private array $merges = []; + /** @var array overview slug => product names (card text on the sub-category page) */ + private array $overviewNames = []; /** @var array|null */ private ?array $products = null; @@ -205,23 +277,42 @@ final class BuildProductOverviewsCommand extends Command $shortcuts = []; // [id, spec] foreach (self::STRUCTURE as [$categoryTitle, $groups]) { - $categoryId = $this->folder($rootId, $categoryTitle, 'category'); + $categoryId = $this->section($rootId, $categoryTitle, 'category'); + $categoryChildren = []; foreach ($groups as [$groupTitle, $entries]) { - $groupId = $this->folder($categoryId, $groupTitle, 'sub-category'); + $groupId = $this->section($categoryId, $groupTitle, 'sub-category'); $groupFolderIds[$groupTitle] = $groupId; + $groupChildren = []; foreach ($entries as $entry) { if (isset($entry['shortcut'])) { $shortcuts[] = [$this->shortcutPage($groupId, $entry['shortcut']), $entry]; + $groupChildren[] = ['shortcut' => $entry]; continue; } $overviewBySlug[$entry['slug']] = $this->overviewPage($groupId, $entry); + $groupChildren[] = [ + 'id' => $overviewBySlug[$entry['slug']], + 'title' => (string)$entry['page'], + 'text' => $this->overviewNames[$entry['slug']] ?? '', + ]; } + $this->sectionChildren[$groupId] = $groupChildren; + $categoryChildren[] = [ + 'id' => $groupId, + 'title' => $groupTitle, + 'text' => implode(', ', array_column(array_filter($groupChildren, static fn(array $c): bool => isset($c['id'])), 'title')), + ]; } + $this->sectionChildren[$categoryId] = $categoryChildren; } - // shortcut targets (a group folder or an overview page) + // shortcut targets (a sub-category page or an overview page); on the + // sub-category page the shortcut becomes a card linking its target + $targetOf = function (array $spec) use ($groupFolderIds, $overviewBySlug): ?string { + return isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null); + }; foreach ($shortcuts as [$shortcutId, $spec]) { - $target = isset($spec['toGroup']) ? ($groupFolderIds[$spec['toGroup']] ?? null) : ($overviewBySlug[$spec['toPage']] ?? null); + $target = $targetOf($spec); if ($target === null) { $this->report[] = ' ! shortcut "' . $spec['shortcut'] . '": target not found'; continue; @@ -229,6 +320,25 @@ final class BuildProductOverviewsCommand extends Command $this->datamap['pages'][$shortcutId]['shortcut'] = ctype_digit($target) ? (int)$target : $target; $this->datamap['pages'][$shortcutId]['shortcut_mode'] = 0; } + foreach ($this->sectionChildren as $sectionId => $children) { + foreach ($children as $i => $child) { + if (isset($child['shortcut'])) { + $target = $targetOf($child['shortcut']); + $this->sectionChildren[$sectionId][$i] = $target === null ? null : [ + 'id' => $target, + 'title' => (string)$child['shortcut']['shortcut'], + 'text' => (string)($child['shortcut']['toGroup'] ?? ''), + ]; + } + } + $this->sectionChildren[$sectionId] = array_values(array_filter($this->sectionChildren[$sectionId])); + } + + $this->sectionMeta(); + foreach ($this->sectionsToFill as $sectionId) { + $this->sectionContent($sectionId); + } + $this->legacyCategoryPages(); foreach ($this->report as $line) { $output->writeln($line); @@ -238,6 +348,24 @@ final class BuildProductOverviewsCommand extends Command return Command::SUCCESS; } + // folders -> pages and legacy nav_hide by direct UPDATE (see class + // comment: the DataHandler slug hook would rewrite child slugs) + $pageConnection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + foreach ($this->conversions as $uid => $fields) { + $pageConnection->update('pages', $fields, ['uid' => $uid]); + } + foreach ($this->adoptions as $adoption) { + $pageConnection->update('pages', ['pid' => $adoption['parent'], 'nav_hide' => 0, 'hidden' => 0, 'nav_title' => $adoption['navTitle']], ['uid' => $adoption['page']]); + $pageConnection->update('pages', ['pid' => $adoption['parent']], ['l10n_parent' => $adoption['page']]); + if ($adoption['folder'] > 0) { + $pageConnection->update('pages', ['pid' => $adoption['page']], ['pid' => $adoption['folder'], 'deleted' => 0]); + $pageConnection->update('pages', ['deleted' => 1], ['uid' => $adoption['folder']]); + } + } + foreach ($this->legacyToHide as $uid) { + $pageConnection->update('pages', ['nav_hide' => 1], ['uid' => $uid]); + } + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($this->datamap, []); $dataHandler->process_datamap(); @@ -246,35 +374,291 @@ final class BuildProductOverviewsCommand extends Command return Command::FAILURE; } $this->applyOrder($dataHandler->substNEWwithIDs); + $this->copyMergedContent($dataHandler->substNEWwithIDs, $output); - $output->writeln("\nWritten. Next, per overview page:"); - foreach ($overviewBySlug as $slug => $id) { - $output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=/products/' . $slug . ' --apply'); + $output->writeln("\nWritten. Next, per page with generated cards:"); + foreach ($this->sectionsToFill as $id) { + $slug = $this->conversions[(int)$id]['slug'] ?? ('/products/' . self::SECTION_SLUGS[$this->sectionTitles[$id]]); + $output->writeln(' vendor/bin/typo3 vitec:wrap-cards-grid --page=' . $slug . ' --apply'); } - $output->writeln('then: vendor/bin/typo3 vitec:attach-card-images --apply && vendor/bin/typo3 cache:flush'); + $output->writeln('(overview pages as needed: --page=/products/), then vitec:build-why-vitec-bands and cache:flush'); return Command::SUCCESS; } // ---- structure ------------------------------------------------------------------- - private function folder(string $parentId, string $title, string $kind): string + /** + * A category (A) or sub-category (B) page, found by title below its + * parent. A sysfolder from the 2026-11-02 build is queued for conversion + * into a page; an empty page is queued for generated content. + */ + private function section(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; + $existing = ctype_digit($parentId) + ? $this->childByTitle((int)$parentId, $title, [self::DOKTYPE_FOLDER, self::DOKTYPE_PAGE]) : null; + if (isset(self::ADOPT[$title])) { + return $this->adopt($parentId, $title, $existing); } - $id = $this->newId(); - $this->datamap['pages'][$id] = ['pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_FOLDER, 'title' => $title, 'hidden' => 0]; + $slug = '/products/' . self::SECTION_SLUGS[$title]; + if ($existing === null) { + $id = $this->newId(); + $this->datamap['pages'][$id] = [ + 'pid' => $this->pid($parentId), 'doktype' => self::DOKTYPE_PAGE, 'hidden' => 0, + 'title' => $title, 'slug' => $slug, 'layout' => self::LAYOUT, + ]; + $this->pageOrder[$parentId][] = $id; + $this->sectionTitles[$id] = $title; + $this->sectionsToFill[] = $id; + $this->report[] = sprintf('%s page "%s" %s CREATE', $kind, $title, $slug); + return $id; + } + + $uid = $existing['uid']; + $id = (string)$uid; $this->pageOrder[$parentId][] = $id; - $this->report[] = sprintf('%s folder "%s" CREATE', $kind, $title); + $this->sectionTitles[$id] = $title; + if ($existing['doktype'] === self::DOKTYPE_FOLDER) { + $taken = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne( + 'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND uid <> ? AND sys_language_uid IN (0,-1)', + [$slug, $uid] + ); + if ($taken !== false) { + $this->report[] = sprintf('%s folder "%s" #%d: slug %s taken by #%d - NOT converted', $kind, $title, $uid, $slug, (int)$taken); + return $id; + } + $this->conversions[$uid] = [ + 'doktype' => self::DOKTYPE_PAGE, 'slug' => $slug, 'layout' => self::LAYOUT, + 'nav_hide' => 0, 'hidden' => 0, + ]; + $this->report[] = sprintf('%s folder "%s" #%d -> page %s', $kind, $title, $uid, $slug); + // a redirect on the slug wins over the page (2026-11-05: the 2026-10 + // stub redirect /products/platforms-and-end-points hid the new page) + $redirect = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_redirect')->fetchAssociative( + 'SELECT uid, target FROM sys_redirect WHERE deleted = 0 AND disabled = 0 AND source_path IN (?, ?)', + [$slug, $slug . '/'] + ); + if ($redirect) { + $this->report[] = sprintf(' ! redirect #%d %s -> %s overrides this page - disable it (Redirects module)', (int)$redirect['uid'], $slug, $redirect['target']); + } + } else { + $this->report[] = sprintf('%s page "%s" #%d (exists)', $kind, $title, $uid); + } + $contentCount = (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content') + ->fetchOne('SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ?', [$uid]); + if ($contentCount === 0) { + $this->sectionsToFill[] = $id; + } else { + $this->report[] = sprintf(' (%d elements - content left untouched)', $contentCount); + } + if (isset(self::MERGE[$title])) { + $this->merges[$id] = []; + foreach (self::MERGE[$title] as $sourceSlug) { + $source = $this->pageBySlug($sourceSlug); + if ($source === null) { + $this->report[] = ' ! merge source ' . $sourceSlug . ' not found'; + continue; + } + $this->merges[$id][] = $source; + } + } return $id; } + /** + * A 2026-10 category page takes the sub-category's place (ADOPT): it + * moves below the category at the folder's position, the folder's + * children move below it, the emptied folder is deleted. Content, slug + * and SEO of the page stay; nav_title = sitemap name. + * + * @param array{uid:int,doktype:int}|null $folder + */ + private function adopt(string $parentId, string $title, ?array $folder): string + { + $page = $this->pageBySlug(self::ADOPT[$title]); + if ($page === null) { + $this->report[] = sprintf('sub-category "%s": page %s not found - NOT adopted', $title, self::ADOPT[$title]); + return $folder !== null ? (string)$folder['uid'] : $this->newId(); + } + $id = (string)$page; + $this->pageOrder[$parentId][] = $id; + $this->lookupParent[$id] = $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? (string)$folder['uid'] : $id; + if (!ctype_digit($parentId)) { + $this->report[] = sprintf('sub-category "%s": category not built yet - run again', $title); + return $id; + } + $this->adoptions[] = [ + 'page' => $page, + 'folder' => $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? $folder['uid'] : 0, + 'parent' => (int)$parentId, + 'navTitle' => $title, + ]; + $this->report[] = sprintf( + 'sub-category "%s" = %s #%d (content, slug, SEO stay; menu title "%s")%s', + $title, self::ADOPT[$title], $page, $title, + $folder !== null && $folder['doktype'] === self::DOKTYPE_FOLDER ? '; folder #' . $folder['uid'] . ' -> children move, folder deleted' : '' + ); + return $id; + } + + private function pageBySlug(string $slug): ?int + { + $uid = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchOne( + 'SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1) ORDER BY uid', [$slug] + ); + return $uid !== false ? (int)$uid : null; + } + + /** SEO/OG title, description and abstract of the section pages, from their children. */ + private function sectionMeta(): void + { + foreach ($this->sectionTitles as $id => $title) { + $names = array_column($this->sectionChildren[$id] ?? [], 'title'); + $description = 'VITEC ' . $title . ($names !== [] ? ': ' . implode(', ', $names) : '') . '.'; + $meta = [ + 'seo_title' => $title . ' | VITEC', 'description' => $description, + 'og_title' => $title . ' | VITEC', 'og_description' => $description, + 'abstract' => implode(', ', $names), + ]; + if (isset($this->conversions[(int)$id])) { + $this->conversions[(int)$id] += $meta; + } elseif (isset($this->datamap['pages'][$id])) { + $this->datamap['pages'][$id] += $meta; + } + } + } + + /** + * Generated content of a new or empty category / sub-category page: + * hero, "Product range", one card per child page (title, child list as + * text, the child's first page media as image, link), contact pre-footer. + */ + private function sectionContent(string $pageId): void + { + $title = $this->sectionTitles[$pageId]; + $this->report[] = sprintf('content for "%s" (%s):', $title, ctype_digit($pageId) ? '#' . $pageId : 'new page'); + $heroId = $this->element($pageId, self::COLPOS_HERO, 'vitec_herosection', ['header' => $title, 'header_layout' => 1, 'bodytext' => '']); + $placeholder = $this->heroPlaceholder($pageId); + if ($placeholder !== null) { + $this->report[] = ' hero: ' . ($this->apply ? 'attach ' : 'would attach ') . $placeholder; + if ($this->apply) { + $refId = $this->newId(); + $this->datamap['sys_file_reference'][$refId] = [ + 'pid' => $this->pid($pageId), + 'table_local' => 'sys_file', + 'uid_local' => GeneralUtility::makeInstance(StorageRepository::class) + ->getDefaultStorage()->getFile($placeholder)->getUid(), + ]; + $this->datamap['tt_content'][$heroId]['vitec_hero_bgimage'] = $refId; + } + } + if (isset($this->merges[$pageId])) { + // MERGE target: the main column is copied from the source pages after the run + $this->report[] = ' main column: copied from ' . count($this->merges[$pageId]) . ' page(s) #' . implode(', #', $this->merges[$pageId]) . ' (after the write)'; + } else { + $this->element($pageId, self::COLPOS_MAIN, 'vitec_introparagraph', ['header' => 'Product range', 'bodytext' => '']); + } + foreach (isset($this->merges[$pageId]) ? [] : ($this->sectionChildren[$pageId] ?? []) as $child) { + $fields = [ + 'vitec_layout_variant' => 'text_only', + 'header' => (string)$child['title'], + 'bodytext' => $this->p((string)$child['text']), + 'vitec_primary_cta_label' => 'Learn more', + 'vitec_primary_cta' => ctype_digit((string)$child['id']) ? 't3://page?uid=' . $child['id'] : '', + ]; + $fileUid = ctype_digit((string)$child['id']) ? $this->pageMediaFile((int)$child['id']) : 0; + $cardId = $this->element($pageId, self::COLPOS_MAIN, 'vitec_card', $fields); + if ($fileUid > 0) { + $this->datamap['tt_content'][$cardId]['vitec_layout_variant'] = 'vertical'; + if ($this->apply) { + $refId = $this->newId(); + $this->datamap['sys_file_reference'][$refId] = ['pid' => $this->pid($pageId), 'table_local' => 'sys_file', 'uid_local' => $fileUid]; + $this->datamap['tt_content'][$cardId]['vitec_image'] = $refId; + } + } + $this->report[] = sprintf(' card "%s"%s%s', $child['title'], $fields['vitec_primary_cta'] !== '' ? ' -> ' . $fields['vitec_primary_cta'] : ' (link after the first run)', $fileUid > 0 ? ' + page image' : ''); + } + $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', + ]); + } + + /** The 2026-10 category pages below /products leave the menus (nav_hide), stay reachable. */ + private function legacyCategoryPages(): void + { + $pages = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages'); + foreach (self::LEGACY_CATEGORY_SLUGS as $slug) { + $row = $pages->fetchAssociative('SELECT uid, nav_hide FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]); + if (!$row) { + $this->report[] = 'legacy ' . $slug . ': not found'; + } elseif ((int)$row['nav_hide'] === 0) { + $this->legacyToHide[] = (int)$row['uid']; + $this->report[] = sprintf('legacy %s #%d: hide in menus (stays reachable)', $slug, (int)$row['uid']); + } + } + } + + /** + * MERGE: copies the visible top-level main-column elements of the source + * pages, in order, below each other onto the (new, previously empty) + * target page. One DataHandler copy per element, each placed after the + * previous copy; EXT:container copies a grid's children along. Re-runs + * do nothing - the target is only filled while it is empty. + * + * @param array $subst + */ + private function copyMergedContent(array $subst, OutputInterface $output): void + { + $content = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); + foreach ($this->merges as $targetId => $sources) { + $targetId = (string)$targetId; + if (!in_array($targetId, $this->sectionsToFill, true)) { + continue; + } + $targetUid = ctype_digit($targetId) ? (int)$targetId : (int)($subst[$targetId] ?? 0); + if ($targetUid === 0) { + continue; + } + $after = 0; + $copied = 0; + foreach ($sources as $sourceUid) { + $elements = $content->fetchFirstColumn( + 'SELECT uid FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND colPos = ? + AND (tx_container_parent IS NULL OR tx_container_parent = 0) ORDER BY sorting, uid', + [$sourceUid, self::COLPOS_MAIN] + ); + foreach ($elements as $elementUid) { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start([], ['tt_content' => [(int)$elementUid => ['copy' => $after > 0 ? -$after : $targetUid]]]); + $dataHandler->process_cmdmap(); + $newUid = (int)($dataHandler->copyMappingArray_merged['tt_content'][(int)$elementUid] ?? 0); + if ($newUid > 0) { + $after = $newUid; + $copied++; + } + } + } + $output->writeln(sprintf('merged %d element(s) onto #%d', $copied, $targetUid)); + } + } + + /** sys_file uid of a page's first media reference, 0 when none */ + private function pageMediaFile(int $pageUid): int + { + return (int)GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference')->fetchOne( + "SELECT uid_local FROM sys_file_reference WHERE deleted = 0 AND hidden = 0 AND tablenames = 'pages' AND fieldname = 'media' AND uid_foreign = ? ORDER BY sorting_foreign LIMIT 1", + [$pageUid] + ); + } + private function shortcutPage(string $groupId, string $title): string { - $existing = ctype_digit($groupId) ? $this->childByTitle((int)$groupId, $title, self::DOKTYPE_SHORTCUT) : null; + $lookup = $this->lookupParent[$groupId] ?? $groupId; // an adopted page's shortcuts still hang on the old folder + $existing = ctype_digit($lookup) ? ($this->childByTitle((int)$lookup, $title, [self::DOKTYPE_SHORTCUT])['uid'] ?? null) : null; if ($existing !== null) { $this->report[] = sprintf(' shortcut "%s" #%d (exists, target refreshed)', $title, $existing); $this->pageOrder[$groupId][] = (string)$existing; @@ -296,13 +680,14 @@ final class BuildProductOverviewsCommand extends Command 'SELECT uid, pid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug] ); [$cards, $names] = $this->resolveItems($entry['items']); + $this->overviewNames[$entry['slug']] = implode(', ', $names); 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 (!in_array((string)(int)$existing['pid'], [$groupId, $this->lookupParent[$groupId] ?? $groupId], true)) { + $updates['pid'] = $this->pid($groupId); // moved into its sub-category } if ($updates !== []) { $this->datamap['pages'][$pageId] = $updates; @@ -509,13 +894,18 @@ final class BuildProductOverviewsCommand extends Command // ---- lookups ----------------------------------------------------------------------- - private function childByTitle(int $parentUid, string $title, int $doktype): ?int + /** + * @param int[] $doktypes + * @return array{uid:int,doktype:int}|null + */ + private function childByTitle(int $parentUid, string $title, array $doktypes): ?array { - $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] + $row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')->fetchAssociative( + 'SELECT uid, doktype FROM pages WHERE deleted = 0 AND pid = ? AND title = ? AND doktype IN (' . implode(',', array_map('intval', $doktypes)) . ') + AND sys_language_uid IN (0,-1) ORDER BY uid', + [$parentUid, $title] ); - return $uid !== false ? (int)$uid : null; + return $row ? ['uid' => (int)$row['uid'], 'doktype' => (int)$row['doktype']] : null; } /** @return array */ diff --git a/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php b/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php index 57ed017..1970b70 100644 --- a/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php +++ b/packages/vitec/Classes/Command/BuildWhyVitecBandsCommand.php @@ -25,9 +25,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; * -> "Scalable architecture"); bodytext stays empty until the agency * delivers copy (0036: a card without text is fine). * - * The band lands directly AFTER the first element of the main column (the - * intro). Idempotent: a page already carrying a "Why VITEC?" introparagraph - * is skipped - editors own the band from then on. The Vision sub-group icon + * The band lands at the TOP of the main column, before the "Product range" + * intro and its cards (0042-FE: placed after the intro it split heading and + * cards; fix_why_band_position.php corrected the first seven pages). + * Idempotent: a page already carrying a "Why VITEC?" introparagraph gets + * no second band; unused icons of its context go onto existing cards whose + * title matches the icon theme (2026-11-05: the adopted category pages' + * workbook cards) - nothing else is touched. The Vision sub-group icon * sets (vision, visionav) stay unassigned: ONE page, TWO sets - editorial * decision, reported only. * @@ -44,7 +48,7 @@ final class BuildWhyVitecBandsCommand extends Command private const HEADER = 'Why VITEC?'; private const COLPOS = 0; - /** staged icon context => overview page slug */ + /** staged icon context => overview (or, since 2026-11-05, sub-category) page slug */ private const BANDS = [ 'activesqx' => '/products/activesqx', 'aetria' => '/products/aetria', @@ -53,8 +57,18 @@ final class BuildWhyVitecBandsCommand extends Command 'image-graphics-cards' => '/products/image-graphics-cards', 'operator-workstations' => '/products/operator-workstations', 'prism-transcoder' => '/products/prism-transcoder', + // sub-category pages = the adopted 2026-10 category pages (2026-11-05); + // they already carry a "Why VITEC?" section from Joe's workbooks whose + // cards ARE these icon themes -> icons go onto the existing cards + 'avoip-and-kvm' => '/products/av-over-ip-and-kvm', + 'control-room-platform' => '/products/control-room-platforms', + 'ip-gateway-and-transcoder' => '/products/gateways-and-transcoding', + 'iptv-distribution-and-signange-platform' => '/products/iptv-and-digital-signage', ]; + /** words ignored when an icon theme is matched against a card title */ + private const STOPWORDS = ['and', 'for', 'the', 'of', 'to', 'a', 'an', 'in', 'on', 'with']; + /** acronyms kept upper-case when the filename theme becomes a card title */ private const ACRONYMS = ['KVM', 'IPTV', 'AV', 'HD', 'SDI', 'IP', 'SQX', 'RF', 'VSN', 'HDMI', 'DVI']; @@ -85,15 +99,6 @@ final class BuildWhyVitecBandsCommand extends Command } $pageUid = (int)$page['uid']; - $existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne( - "SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?", - [$pageUid, self::HEADER] - ); - if ($existing > 0) { - $output->writeln($slug . ': band exists - untouched'); - continue; - } - $folder = self::ICON_BASE . $context; $icons = []; if ($storage->hasFolder($folder)) { @@ -108,6 +113,18 @@ final class BuildWhyVitecBandsCommand extends Command continue; } + $existing = (int)$pool->getConnectionForTable('tt_content')->fetchOne( + "SELECT COUNT(*) FROM tt_content WHERE deleted = 0 AND pid = ? AND CType = 'vitec_introparagraph' AND header = ?", + [$pageUid, self::HEADER] + ); + if ($existing > 0) { + // the page has its "Why VITEC?" section already (own band or + // a workbook import): put the icons onto matching cards + // without one; everything else stays untouched + $built += $this->iconsOntoExistingCards($pageUid, $slug, $icons, $apply, $output); + continue; + } + $output->writeln(sprintf("\n%s (#%d): band with %d icon card(s)", $slug, $pageUid, count($icons))); foreach ($icons as $icon) { $output->writeln(' "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')'); @@ -160,11 +177,10 @@ final class BuildWhyVitecBandsCommand extends Command return Command::FAILURE; } - // final order: first element (intro), then the band, then the rest + // final order: the band first, then the existing main column $order = array_merge( - array_slice($existingUids, 0, 1), array_map(static fn(string $id) => (string)($dataHandler->substNEWwithIDs[$id] ?? '0'), $newIds), - array_slice($existingUids, 1) + $existingUids ); $sorting = 0; foreach ($order as $uid) { @@ -182,6 +198,97 @@ final class BuildWhyVitecBandsCommand extends Command return Command::SUCCESS; } + /** + * Icons onto the existing cards of a page whose title matches an icon + * theme ("Centralized management" ~ CentralManagement: every word of + * the theme is a prefix of a card-title word or vice versa, same word + * count). Only cards without an icon; they become icon_card like the + * generated bands. Returns 1 when anything was (or would be) attached. + * + * @param array $icons + */ + private function iconsOntoExistingCards(int $pageUid, string $slug, array $icons, bool $apply, OutputInterface $output): int + { + $pool = GeneralUtility::makeInstance(ConnectionPool::class); + $cards = $pool->getConnectionForTable('tt_content')->fetchAllAssociative( + "SELECT c.uid, c.header FROM tt_content c + WHERE c.deleted = 0 AND c.pid = ? AND c.CType = 'vitec_card' + AND NOT EXISTS (SELECT 1 FROM sys_file_reference r WHERE r.deleted = 0 AND r.tablenames = 'tt_content' + AND r.fieldname = 'vitec_icon' AND r.uid_foreign = c.uid) + ORDER BY c.uid", + [$pageUid] + ); + $storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); + // icons already on a card of this page (e.g. the generated bands) + $used = array_map('intval', $pool->getConnectionForTable('sys_file_reference')->fetchFirstColumn( + "SELECT r.uid_local FROM sys_file_reference r JOIN tt_content c ON c.uid = r.uid_foreign + WHERE r.deleted = 0 AND r.tablenames = 'tt_content' AND r.fieldname = 'vitec_icon' AND c.deleted = 0 AND c.pid = ?", + [$pageUid] + )); + $datamap = []; + $lines = []; + foreach ($icons as $icon) { + if (in_array($storage->getFile($icon['identifier'])->getUid(), $used, true)) { + continue; + } + foreach ($cards as $k => $card) { + if (!$this->sameTheme($icon['title'], (string)$card['header'])) { + continue; + } + $lines[] = sprintf(' card #%d "%s" <- %s', (int)$card['uid'], $card['header'], basename($icon['identifier'])); + $refId = $this->newId(); + $datamap['sys_file_reference'][$refId] = [ + 'pid' => $pageUid, + 'table_local' => 'sys_file', + 'uid_local' => $storage->getFile($icon['identifier'])->getUid(), + ]; + $datamap['tt_content'][(int)$card['uid']] = ['vitec_icon' => $refId, 'vitec_layout_variant' => 'icon_card']; + unset($cards[$k]); + continue 2; + } + $lines[] = ' no card for "' . $icon['title'] . '" (' . basename($icon['identifier']) . ')'; + } + if ($lines === []) { + $output->writeln($slug . ': band complete - untouched'); + return 0; + } + $output->writeln(sprintf("\n%s (#%d): \"Why VITEC?\" exists - icons onto its cards", $slug, $pageUid)); + foreach ($lines as $line) { + $output->writeln($line); + } + if ($datamap === []) { + return 0; + } + if ($apply) { + $dataHandler = GeneralUtility::makeInstance(DataHandler::class); + $dataHandler->start($datamap, []); + $dataHandler->process_datamap(); + if ($dataHandler->errorLog !== []) { + $output->writeln('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); + } + } + return 1; + } + + private function sameTheme(string $iconTitle, string $cardTitle): bool + { + $words = fn(string $text): array => array_values(array_diff( + preg_split('/[^a-z0-9]+/', mb_strtolower($text), -1, PREG_SPLIT_NO_EMPTY) ?: [], + self::STOPWORDS + )); + $a = $words($iconTitle); + $b = $words($cardTitle); + if ($a === [] || count($a) !== count($b)) { + return false; + } + foreach ($a as $i => $word) { + if (!str_starts_with($b[$i], $word) && !str_starts_with($word, $b[$i])) { + return false; + } + } + return true; + } + /** "ScalableArchitecture" -> "Scalable architecture", acronyms kept ("KVMCapability" -> "KVM capability") */ private function themeTitle(string $theme): string { diff --git a/packages/vitec/Classes/EventListener/ProvideProductCatalog.php b/packages/vitec/Classes/EventListener/ProvideProductCatalog.php index dc0d6db..3792450 100644 --- a/packages/vitec/Classes/EventListener/ProvideProductCatalog.php +++ b/packages/vitec/Classes/EventListener/ProvideProductCatalog.php @@ -16,8 +16,8 @@ use TYPO3\CMS\Extbase\Service\ImageService; * version 2 (2026-11-02, agency "Product Menu Prototype"): three levels, * the menu ends at the product OVERVIEW pages. * - * categories[] (A, menu label) = sysfolders directly below /products - * groups[] (B, menu label) = sysfolders below those + * categories[] (A) = category pages directly below /products + * groups[] (B) = sub-category pages below those * items[] type "page" = overview pages (/products/) * type "shortcut" = shortcut pages, resolved to a target * group or overview page of this tree @@ -29,6 +29,13 @@ use TYPO3\CMS\Extbase\Service\ImageService; * 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. * + * A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every + * category and sub-category is a clickable page. Both levels now carry + * `link` (additive; '' while a level is still a folder). Folders are still + * read, so the menu survives a half-converted tree; only groups with items + * and categories with such groups count (keeps other pages below /products, + * e.g. the 2026-10 category pages, out of the menu). + * * Next to the tree: viewAll (/products) and the type-ahead endpoint. The * boxes of the left column ("Product highlight", "Product news") are the * entry's editorial teaser cards, not data from here (decision Olli @@ -81,8 +88,8 @@ final class ProvideProductCatalog if ($root === 0) { return []; } - $categoryRows = $this->children([$root], [self::DOKTYPE_FOLDER]); - $groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_FOLDER]); + $categoryRows = $this->children([$root], [self::DOKTYPE_PAGE, self::DOKTYPE_FOLDER]); + $groupRows = $this->children(array_column($categoryRows, 'uid'), [self::DOKTYPE_PAGE, 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' @@ -92,6 +99,12 @@ final class ProvideProductCatalog // positions for shortcut resolution: folder uid => [c, g], page uid => [c, g, p] $groupPos = []; $pagePos = []; + // only the products tree: groups with items, categories with such groups + $filledGroups = array_map('intval', array_column($itemRows, 'pid')); + $groupRows = array_values(array_filter($groupRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledGroups, true))); + $filledCategories = array_map('intval', array_column($groupRows, 'pid')); + $categoryRows = array_values(array_filter($categoryRows, static fn(array $row): bool => in_array((int)$row['uid'], $filledCategories, true))); + $categories = []; foreach ($categoryRows as $c => $categoryRow) { $groups = []; @@ -119,9 +132,9 @@ final class ProvideProductCatalog } $items[] = $item; } - $groups[] = ['title' => (string)$groupRow['title'], 'items' => $items]; + $groups[] = ['title' => (string)$groupRow['title'], 'link' => $this->sectionLink($groupRow), 'items' => $items]; } - $categories[] = ['title' => (string)$categoryRow['title'], 'groups' => $groups]; + $categories[] = ['title' => (string)$categoryRow['title'], 'link' => $this->sectionLink($categoryRow), 'groups' => $groups]; } // shortcuts: target = a group of this tree (opens that group) or an @@ -159,6 +172,12 @@ final class ProvideProductCatalog return $categories; } + /** @param array $row category or sub-category page row; '' while it is a folder */ + private function sectionLink(array $row): string + { + return (int)$row['doktype'] === self::DOKTYPE_PAGE ? (string)$row['slug'] : ''; + } + private function productsRootUid(): int { $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); @@ -173,7 +192,9 @@ final class ProvideProductCatalog /** * Visible child pages (frontend restrictions: hidden/deleted/start/end), - * nav_hide respected, in page tree order. + * nav_hide respected, in page tree order. `title` is the menu label: + * nav_title when set (the adopted 2026-10 category pages carry the + * sitemap name there, their page title stays the SEO/H1 one). * * @param int[] $parents * @param int[] $doktypes @@ -185,7 +206,7 @@ final class ProvideProductCatalog return []; } $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); - return $queryBuilder->select('uid', 'pid', 'title', 'slug', 'doktype', 'shortcut')->from('pages') + $rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_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)), @@ -194,6 +215,12 @@ final class ProvideProductCatalog ) ->orderBy('pid')->addOrderBy('sorting') ->executeQuery()->fetchAllAssociative(); + foreach ($rows as &$row) { + if (trim((string)($row['nav_title'] ?? '')) !== '') { + $row['title'] = $row['nav_title']; + } + } + return $rows; } /** diff --git a/packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php index 0861975..9b96fb9 100644 --- a/packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php @@ -77,8 +77,10 @@ final class ProductMenuSearchJsonRenderer } /** - * The overview pages of the products menu tree (/products > folder > - * folder > page, see ProvideProductCatalog) with their menu path. + * The overview pages of the products menu tree (/products > category > + * sub-category > page, see ProvideProductCatalog) with their menu path. + * Category and sub-category are pages since 2026-11-05 (folders before - + * both are read). * * @return array page uid => overview */ @@ -96,7 +98,7 @@ final class ProductMenuSearchJsonRenderer return []; } $queryBuilder = $pool->getQueryBuilderForTable('pages'); - return $queryBuilder->select('uid', 'pid', 'title', 'slug')->from('pages') + $rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_title', 'slug')->from('pages') ->where( $queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)), $queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)), @@ -104,9 +106,16 @@ final class ProductMenuSearchJsonRenderer ) ->orderBy('sorting') ->executeQuery()->fetchAllAssociative(); + // menu label = nav_title when set (same rule as ProvideProductCatalog) + foreach ($rows as &$row) { + if (trim((string)($row['nav_title'] ?? '')) !== '') { + $row['title'] = $row['nav_title']; + } + } + return $rows; }; - $categories = array_column($level([$root], [254]), null, 'uid'); - $groups = array_column($level(array_keys($categories), [254]), null, 'uid'); + $categories = array_column($level([$root], [1, 254]), null, 'uid'); + $groups = array_column($level(array_keys($categories), [1, 254]), null, 'uid'); $overviews = []; foreach ($level(array_keys($groups), [1]) as $page) { $group = $groups[(int)$page['pid']]; diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 7a63b7e..9227dcc 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -331,6 +331,17 @@ tt_content { page.10.fields.jsonLd = USER page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render +# ============================================================================= +# No browser caching of the page JSON (2026-11-05). EXT:headless sets +# page.config.sendCacheHeaders = 1, and TYPO3 14 caches a page for ONE YEAR +# by default (CacheLifetimeCalculator::defaultCacheTimeout) - every /_json/ +# response went out with "Cache-Control: max-age=~31536000", so browsers +# kept an old menu/page for up to a year (found when the clickable product +# categories did not show up). The server-side page cache is unaffected; +# without the header, .htaccess sends application/json "access plus 0 seconds". +# ============================================================================= +page.config.sendCacheHeaders = 0 + ############################################################ # Frontend resolution for the record links from the link # browser (page.tsconfig TCEMAIN.linkHandler): diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md index cceba0c..678abc3 100755 --- a/packages/vitec/Documentation/Headless-JSON-Architecture.md +++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md @@ -3,7 +3,7 @@ | | | |---|---| | **Document identifier** | EVO‑VITEC‑HL‑001 | -| **Version** | 1.31 | +| **Version** | 1.32 | | **Status** | Released | | **Date** | 2026‑11‑05 | | **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) | @@ -44,6 +44,7 @@ | 1.29 | 2026‑11‑02 | **Interface change (removal within the unreleased catalog v2).** `catalog.news` dropped (decision: no automatic news in the products menu). Both promo boxes of the products panel, "Product highlight" and "Product news", are the entry's editorial `teasers` (`kicker` = box label). Megamenu caching: pages carrying the menu are now tagged with the menu tables, so saving menu records refreshes every page; data read from the page tree still needs a cache flush. | | 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). | | 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. | +| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. | 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 @@ -1211,20 +1212,21 @@ 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/`, built by +`/products`: the pages on the first level are the menu **categories**, the +pages below them the **groups** (sub-categories), and the pages below the +groups the **overview pages** (`/products/`, 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. +**shortcut** pages. Since v1.32 categories and groups are clickable pages +with `link` (they were sysfolders/labels before); product detail pages are +reached from the overview pages. ```json "catalog": { "version": 2, "search": { "endpoint": "/_json/?type=7385", "param": "q", "minChars": 2, "placeholder": "Search products" }, "viewAll": { "title": "View all products", "link": "/products" }, - "categories": [ { "title": "IP Video Streaming, Distribution & KVM", - "groups": [ { "title": "Encoders & Decoders", + "categories": [ { "title": "IP Video Streaming, Distribution & KVM", "link": "/products/ip-video-streaming-distribution-and-kvm", + "groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming", "items": [ { "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances", "productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ], @@ -1234,10 +1236,18 @@ pages are reached from there. } ``` +- `categories[].link` / `groups[].link` (v1.32, additive): the category / + sub-category page; `""` only while a level is still a folder. `title` is + the menu label = the page's *nav_title* when set: six sub-categories are + the 2026-10 category pages (Joe's workbooks - content, slug and SEO kept, + e.g. "Encoders & Decoders" → `/products/video-encoding-and-streaming`, + H1 "Video Encoding & Streaming"). Breadcrumbs run + Home › Products › category › sub-category › overview page. - `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 `""`). + without `page` it opens a whole group (`link` is `""`; the group's own + page is `groups[].link` of the target). - `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. @@ -1251,15 +1261,9 @@ pages are reached from there. - 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. +- The transitional v1 fields of v1.28 (`groups[].products`, `uid`s, + product `teaser`) were removed on 2026-11-03 after the front end switched + (0024-FE / 0029-BE). The `link` fields of v1.32 are new and permanent. **Markets and Solutions menus (megamenu `layout: "pagetree"`, `display` since v1.30).** Page-tree entries build their `columns` live from the @@ -1706,4 +1710,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.31.* +*End of document EVO‑VITEC‑HL‑001 v1.32.*