Megamenu: rail display for page trees, cache tags, editorial promo boxes

- Page-tree entries get a "Page tree display" field (columns | rail |
  rail-compact), emitted as display + cardSize: Markets and Solutions
  show their main pages as a list and the sub-pages as image cards on
  hover. Page-tree items carry image (page media) and teaser (abstract)
  from stage 2. Additive; front ends ignoring display render columns.
- vitec:attach-page-media gains --depth=2 and falls back to the page's
  hero background before the grey placeholder; the 39 sub-solution
  pages got their hero image as page media.
- MegamenuJsonRenderer tags every page carrying the menu with the menu
  tables and tt_content_<uid>, so saving menu records refreshes all
  pages instead of waiting for a full flush.
- Products catalog: drop catalog.news; both promo boxes ("Product
  highlight", "Product news") are the entry's editorial teaser cards.
  ProvideInsightsSpotlight back to its previous state.
- Spec v1.29 and v1.30 (header version corrected), README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Opus 5.5 committed 2026-11-02 17:35:23 +01:00
1 parent 9fb7f0ca77
commit 9211c8a395
10 files changed
+171 -37

No files matched your search

@@ -24,12 +24,17 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
* Image per page: the numbered placeholder
* `fileadmin/placeholders/pages/<pid>-08.png` when it exists (slot 08 =
* page media / menu tile; the number in the picture doubles as the image
* wish list towards the agency), otherwise the generic wide grey
* placeholder. A page that already has a media reference is NEVER touched -
* editors swap the placeholder for the real image in the page properties.
* wish list towards the agency), else the page's own hero background
* (`vitec_bgimage`, same file referenced - 2026-11-02, for the sub-market /
* sub-solution cards of the megamenu `rail` display), otherwise the generic
* wide grey placeholder. A page that already has a media reference is NEVER
* touched - editors swap the image in the page properties.
*
* --depth=2 targets the grandchildren (e.g. the sub-markets below
* /markets/<market>) instead of the children.
*
* vendor/bin/typo3 vitec:attach-page-media --root=/support
* vendor/bin/typo3 vitec:attach-page-media --root=/support --apply
* vendor/bin/typo3 vitec:attach-page-media --root=/markets --depth=2 --apply
*/
#[AsCommand(
name: 'vitec:attach-page-media',
@@ -40,6 +45,7 @@ final class AttachPageMediaCommand extends Command
protected function configure(): void
{
$this->addOption('root', null, InputOption::VALUE_REQUIRED, 'Slug of the root page, e.g. /support');
$this->addOption('depth', null, InputOption::VALUE_REQUIRED, '1 = children of the root, 2 = their children', '1');
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
@@ -56,11 +62,15 @@ final class AttachPageMediaCommand extends Command
$output->writeln('<error>Root page ' . $rootSlug . ' not found.</error>');
return Command::FAILURE;
}
$children = $pages->fetchAllAssociative(
'SELECT uid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting',
[(int)$root['uid']]
);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · %s #%d · %d child page(s)', $rootSlug, (int)$root['uid'], count($children)));
$depth = max(1, min(2, (int)$input->getOption('depth')));
$parents = [(int)$root['uid']];
for ($level = 1; $level <= $depth; $level++) {
$children = $pages->fetchAllAssociative(
'SELECT uid, pid, title FROM pages WHERE deleted = 0 AND hidden = 0 AND doktype = 1 AND pid IN (' . implode(',', $parents ?: [0]) . ') AND sys_language_uid IN (0,-1) ORDER BY pid, sorting'
);
$parents = array_map('intval', array_column($children, 'uid'));
}
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · %s #%d · depth %d · %d page(s)', $rootSlug, (int)$root['uid'], $depth, count($children)));
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
$refs = $pool->getConnectionForTable('sys_file_reference');
@@ -76,10 +86,19 @@ final class AttachPageMediaCommand extends Command
$output->writeln(sprintf(' = #%d "%s": has media (%d) - untouched', $uid, (string)$child['title'], $existing));
continue;
}
$fileUid = 0;
$identifier = 'placeholders/pages/' . $uid . '-08.png';
if (!$storage->hasFile($identifier)) {
$identifier = 'placeholders/placeholder-grey-wide.png';
if (!$storage->hasFile($identifier)) {
// the page's own hero background, first hero on the page
$fileUid = (int)$refs->fetchOne(
"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_bgimage'
AND c.deleted = 0 AND c.hidden = 0 AND c.pid = ?
ORDER BY c.colPos, c.sorting, r.sorting_foreign LIMIT 1",
[$uid]
);
$identifier = $fileUid > 0 ? 'hero image (sys_file #' . $fileUid . ')' : 'placeholders/placeholder-grey-wide.png';
if ($fileUid === 0 && !$storage->hasFile($identifier)) {
$output->writeln(sprintf(' <comment>! #%d "%s": no placeholder file - skipped</comment>', $uid, (string)$child['title']));
continue;
}
@@ -88,11 +107,13 @@ final class AttachPageMediaCommand extends Command
if (!$apply) {
continue;
}
$file = $storage->getFile($identifier);
if ($fileUid === 0) {
$fileUid = $storage->getFile($identifier)->getUid();
}
$datamap['sys_file_reference']['NEWPM' . (++$newIndex)] = [
'pid' => $uid,
'table_local' => 'sys_file',
'uid_local' => $file->getUid(),
'uid_local' => $fileUid,
'tablenames' => 'pages',
'uid_foreign' => $uid,
'fieldname' => 'media',
@@ -50,13 +50,8 @@ final class ProvideInsightsSpotlight
]);
}
/**
* 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
/** @return array<int,array<string,mixed>> */
private function latestNews(int $stage): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
@@ -68,7 +63,7 @@ final class ProvideInsightsSpotlight
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
)
->orderBy('datetime', 'DESC')
->setMaxResults($count)
->setMaxResults(self::NEWS_COUNT)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return [];
@@ -29,9 +29,10 @@ 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.
*
* 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".
* 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
* 2026-11-02: no automatic news).
*
* 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
@@ -45,12 +46,10 @@ final class ProvideProductCatalog
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,
private readonly FileRepository $fileRepository,
private readonly ProvideInsightsSpotlight $spotlight,
) {}
public function __invoke(BuildCatalogEvent $event): void
@@ -66,7 +65,6 @@ final class ProvideProductCatalog
'placeholder' => 'Search products',
],
'viewAll' => ['title' => 'View all products', 'link' => self::PRODUCTS_SLUG],
'news' => $this->spotlight->latestNews($stage, self::NEWS_COUNT),
'categories' => $this->categories($stage),
]);
} catch (\Throwable) {