Products: 20 overview pages and megamenu catalog v2

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

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

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

No files matched your search

@@ -17,7 +17,13 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
*
* { "query": "mgw d",
* "suggestions": [ "MGW Diamond", "MGW Diamond-H", ... ],
* "products": [ { "title", "teaser", "image", "url" } ] }
* "overviews": [ { "title", "link", "path": [category, group] } ],
* "products": [ { "title", "teaser", "image", "url",
* "overview": { "title", "link", "path" } | null } ] }
*
* Since menu v2 (2026-11-02) overview pages of the products tree match by
* title (every typed term contained) and lead the suggestions; each product
* names the overview page linking it.
*
* Runs through Solr (decision Olli: synonyms, ranking and the indexed
* product set stay identical to the site search), restricted to product
@@ -60,12 +66,113 @@ final class ProductMenuSearchJsonRenderer
// e.g. a typed hyphen or a complete word the prefix form misses
$documents = $this->search($connection, $query, false);
}
return $this->json($query, $this->cards($documents));
// menu v2 (2026-11-02): overview pages are hits of their own, and
// every product names the overview page it belongs to (path)
$overviews = $this->overviewPages();
$products = $this->withOverview($this->cards($documents), $overviews);
return $this->json($query, $products, $this->matchingOverviews($overviews, $query));
} catch (\Throwable) {
return $this->json($query, []);
}
}
/**
* The overview pages of the products menu tree (/products > folder >
* folder > page, see ProvideProductCatalog) with their menu path.
*
* @return array<int,array{uid:int,title:string,link:string,path:string[]}> page uid => overview
*/
private function overviewPages(): array
{
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$root = (int)$pool->getConnectionForTable('pages')->fetchOne(
"SELECT uid FROM pages WHERE deleted = 0 AND hidden = 0 AND slug = '/products' AND sys_language_uid IN (0,-1)"
);
if ($root === 0) {
return [];
}
$level = function (array $parents, array $doktypes) use ($pool): array {
if ($parents === []) {
return [];
}
$queryBuilder = $pool->getQueryBuilderForTable('pages');
return $queryBuilder->select('uid', 'pid', 'title', 'slug')->from('pages')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->in('doktype', $queryBuilder->createNamedParameter($doktypes, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('nav_hide', 0)
)
->orderBy('sorting')
->executeQuery()->fetchAllAssociative();
};
$categories = array_column($level([$root], [254]), null, 'uid');
$groups = array_column($level(array_keys($categories), [254]), null, 'uid');
$overviews = [];
foreach ($level(array_keys($groups), [1]) as $page) {
$group = $groups[(int)$page['pid']];
$overviews[(int)$page['uid']] = [
'uid' => (int)$page['uid'],
'title' => (string)$page['title'],
'link' => (string)$page['slug'],
'path' => [(string)$categories[(int)$group['pid']]['title'], (string)$group['title']],
];
}
return $overviews;
}
/**
* Adds `overview` {title, link, path} to every product card whose detail
* page is linked from an overview page's cards (first one wins).
*
* @param array<int,array<string,mixed>> $products
* @param array<int,array<string,mixed>> $overviews
* @return array<int,array<string,mixed>>
*/
private function withOverview(array $products, array $overviews): array
{
if ($products === [] || $overviews === []) {
return $products;
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder->select('pid', 'vitec_primary_cta')->from('tt_content')
->where(
$queryBuilder->expr()->in('pid', $queryBuilder->createNamedParameter(array_keys($overviews), ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('CType', $queryBuilder->createNamedParameter('vitec_card')),
$queryBuilder->expr()->in('vitec_primary_cta', $queryBuilder->createNamedParameter(array_column($products, 'url'), ArrayParameterType::STRING))
)
->orderBy('uid')
->executeQuery()->fetchAllAssociative();
$overviewByUrl = [];
foreach ($rows as $row) {
$overviewByUrl[(string)$row['vitec_primary_cta']] ??= $overviews[(int)$row['pid']];
}
foreach ($products as &$product) {
$overview = $overviewByUrl[$product['url']] ?? null;
$product['overview'] = $overview === null ? null
: ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
}
return $products;
}
/**
* Overview pages whose title contains every typed term (case-insensitive).
*
* @param array<int,array<string,mixed>> $overviews
* @return array<int,array{title:string,link:string,path:string[]}>
*/
private function matchingOverviews(array $overviews, string $query): array
{
$terms = preg_split('/\s+/u', mb_strtolower($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
$hits = [];
foreach ($overviews as $overview) {
$haystack = mb_strtolower($overview['title'] . ' ' . implode(' ', $overview['path']));
if ($terms !== [] && array_reduce($terms, fn(bool $all, string $t): bool => $all && str_contains($haystack, $t), true)) {
$hits[] = ['title' => $overview['title'], 'link' => $overview['link'], 'path' => $overview['path']];
}
}
return $hits;
}
/** @return array<int,array<string,mixed>> Solr document fields, best first */
private function search(\ApacheSolrForTypo3\Solr\System\Solr\SolrConnection $connection, string $input, bool $prefixLastTerm): array
{
@@ -154,12 +261,16 @@ final class ProductMenuSearchJsonRenderer
return $teasers;
}
/** @param array<int,array<string,mixed>> $products */
private function json(string $query, array $products): string
/**
* @param array<int,array<string,mixed>> $products
* @param array<int,array<string,mixed>> $overviews
*/
private function json(string $query, array $products, array $overviews = []): string
{
return (string)json_encode([
'query' => $query,
'suggestions' => array_values(array_unique(array_column($products, 'title'))),
'suggestions' => array_values(array_unique(array_merge(array_column($overviews, 'title'), array_column($products, 'title')))),
'overviews' => $overviews,
'products' => $products,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}