Products megamenu as a data-driven catalog with a Solr product type-ahead

- evo_megamenu_json stays project-neutral: new panel layout "catalog";
  for such entries the MenuBuilder dispatches the new PSR-14 event
  BuildCatalogEvent and emits whatever a listener sets as `catalog`
  (null without a listener). The entry's teaser cards serve as the
  featured tiles; the teaser inline field now also shows for "catalog",
  and the entry form reloads when the layout changes. README section
  "Layout catalog"
- VITEC listener ProvideProductCatalog fills it, modelled on the Ross
  Video products menu: main categories below the "Product" root, their
  subcategories as groups, products with a plain-text one-line teaser;
  same visibility rules as the product list, Product Finder links
  (/products?cat=&subcat=), empty categories and groups left out, three
  queries for the whole catalog
- Product type-ahead GET /_json/?type=7385&q= (USER_INT page type,
  ProductMenuSearchJsonRenderer): Solr restricted to products, last term
  completed as a prefix, up to six product cards plus their titles as
  autocomplete suggestions; endpoint announced in catalog.search
- Spec v1.23 (clause 7.16: product type-ahead)
This commit is contained in:
orasche committed 2026-10-09 13:40:23 +02:00
1 parent b5fcdb2b5a
commit 887caed2aa
8 files changed
+502 -6

No files matched your search

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* Supplies the product catalog for megamenu entries with the layout
* `catalog` (2026-10-09, modelled on the Ross Video "Products" menu):
* main categories (middle column) -> subcategories as group headings ->
* products with a one-line teaser (right column), plus the endpoint of the
* product type-ahead in the panel's search bar.
*
* Same tree and visibility rules as the product list and the Product
* Finder: level 1 below the root category "Product" are the main
* categories, their direct children the subcategories; products need
* legacy/supportproduct/hideonwebsite/hideonproducts/subproduct = 0. Within
* a group the uid order is kept (records were created in sitemap order).
* Links follow the Product Finder convention /products?cat=<main>&subcat=<sub>.
*
* Three queries for the whole catalog (categories, assignments, products) -
* the menu travels with every page.
*/
#[AsEventListener(identifier: 'vitec/megamenu-product-catalog')]
final class ProvideProductCatalog
{
private const ROOT_TITLE = 'Product';
private const PRODUCT_TABLE = 'tx_vitec_domain_model_product';
private const PRODUCTS_PAGE = '/products';
private const SEARCH_ENDPOINT = '/_json/?type=7385';
private const TEASER_LENGTH = 120;
public function __construct(private readonly ConnectionPool $connectionPool) {}
public function __invoke(BuildCatalogEvent $event): void
{
try {
$event->setCatalog([
'search' => [
'endpoint' => self::SEARCH_ENDPOINT,
'param' => 'q',
'minChars' => 2,
'placeholder' => 'Search products',
],
'categories' => $this->categories(),
]);
} catch (\Throwable) {
// fail-soft: the menu renders without the catalog rather than not at all
}
}
/** @return array<int,array<string,mixed>> */
private function categories(): array
{
$root = $this->rootUid();
if ($root === 0) {
return [];
}
$mains = $this->childCategories([$root]);
$subs = $this->childCategories(array_column($mains, 'uid'));
$subsByParent = [];
foreach ($subs as $sub) {
$subsByParent[(int)$sub['parent']][] = $sub;
}
$categoryUids = array_merge(array_column($mains, 'uid'), array_column($subs, 'uid'));
$productsByCategory = $this->productsByCategory(array_map('intval', $categoryUids));
$catalog = [];
foreach ($mains as $main) {
$mainUid = (int)$main['uid'];
$groups = [];
// products filed directly on the main category: an untitled first group
if (!empty($productsByCategory[$mainUid])) {
$groups[] = [
'uid' => $mainUid,
'title' => '',
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
'products' => $productsByCategory[$mainUid],
];
}
foreach ($subsByParent[$mainUid] ?? [] as $sub) {
$subUid = (int)$sub['uid'];
if (empty($productsByCategory[$subUid])) {
continue; // no empty headings in the menu
}
$groups[] = [
'uid' => $subUid,
'title' => (string)$sub['title'],
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid . '&subcat=' . $subUid,
'products' => $productsByCategory[$subUid],
];
}
if ($groups === []) {
continue;
}
$catalog[] = [
'uid' => $mainUid,
'title' => (string)$main['title'],
'link' => self::PRODUCTS_PAGE . '?cat=' . $mainUid,
'groups' => $groups,
];
}
return $catalog;
}
private function rootUid(): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
return (int)$queryBuilder->select('uid')->from('sys_category')
->where(
$queryBuilder->expr()->eq('parent', 0),
$queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter(self::ROOT_TITLE))
)
->setMaxResults(1)
->executeQuery()->fetchOne();
}
/**
* @param int[] $parents
* @return array<int,array{uid:int,parent:int,title:string}>
*/
private function childCategories(array $parents): array
{
if ($parents === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_category');
return $queryBuilder->select('uid', 'parent', 'title')->from('sys_category')
->where($queryBuilder->expr()->in('parent', $queryBuilder->createNamedParameter($parents, ArrayParameterType::INTEGER)))
->orderBy('parent')->addOrderBy('sorting')
->executeQuery()->fetchAllAssociative();
}
/**
* @param int[] $categoryUids
* @return array<int,array<int,array{uid:int,title:string,teaser:string,link:string}>> category uid => products
*/
private function productsByCategory(array $categoryUids): array
{
if ($categoryUids === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::PRODUCT_TABLE);
$rows = $queryBuilder
->select('p.uid', 'p.title', 'p.teaser', 'p.slug', 'mm.uid_local AS category')
->from(self::PRODUCT_TABLE, 'p')
->join(
'p',
'sys_category_record_mm',
'mm',
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $queryBuilder->createNamedParameter(self::PRODUCT_TABLE)
. ' AND mm.fieldname = ' . $queryBuilder->createNamedParameter('categories')
)
->where(
$queryBuilder->expr()->in('mm.uid_local', $queryBuilder->createNamedParameter($categoryUids, ArrayParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.legacy', 0),
$queryBuilder->expr()->eq('p.supportproduct', 0),
$queryBuilder->expr()->eq('p.hideonwebsite', 0),
$queryBuilder->expr()->eq('p.hideonproducts', 0),
$queryBuilder->expr()->eq('p.subproduct', 0),
$queryBuilder->expr()->neq('p.slug', $queryBuilder->createNamedParameter('', ParameterType::STRING))
)
->orderBy('p.uid')
->executeQuery()->fetchAllAssociative();
$byCategory = [];
foreach ($rows as $row) {
$byCategory[(int)$row['category']][] = [
'uid' => (int)$row['uid'],
'title' => (string)$row['title'],
'teaser' => $this->oneLiner((string)$row['teaser']),
'link' => '/product/' . ltrim((string)$row['slug'], '/'),
];
}
return $byCategory;
}
/** Plain one-line teaser: tags and entities resolved, whitespace collapsed, cut at a word. */
private function oneLiner(string $value): string
{
$text = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($value), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) <= self::TEASER_LENGTH) {
return $text;
}
$cut = mb_substr($text, 0, self::TEASER_LENGTH);
$space = mb_strrpos($cut, ' ');
return rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …';
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use ApacheSolrForTypo3\Solr\ConnectionManager;
use ApacheSolrForTypo3\Solr\Domain\Search\Query\Query;
use Doctrine\DBAL\ArrayParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Product type-ahead for the search bar of the megamenu catalog panel
* (2026-10-09): GET /_json/?type=7385&q=<input>
*
* { "query": "mgw d",
* "suggestions": [ "MGW Diamond", "MGW Diamond-H", ... ],
* "products": [ { "title", "teaser", "image", "url" } ] }
*
* Runs through Solr (decision Olli: synonyms, ranking and the indexed
* product set stay identical to the site search), restricted to product
* documents. The LAST term is completed as a prefix ("mgw d" finds
* "MGW Diamond"), so cards appear while typing. One card per product, at
* most MAX_PRODUCTS; `suggestions` are the matched product titles for the
* autocomplete list. `teaser` is the product's one-liner from the record
* (the indexed content is the full page text - too long for a card),
* `image` the indexed 640px tile.
*
* Wired as USER_INT on a lean page type (like the EXT:solr suggest 7384):
* every keystroke is a new query, a cached USER would pin the first one.
* Fail-soft: any error yields the empty shape, never a broken menu.
*/
final class ProductMenuSearchJsonRenderer
{
private const MIN_CHARS = 2;
private const MAX_PRODUCTS = 6;
private const PRODUCT_TYPE = 'tx_vitec_domain_model_product';
private const TEASER_LENGTH = 120;
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$query = '';
try {
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
$query = trim((string)($request?->getQueryParams()['q'] ?? ''));
if ($request === null || mb_strlen($query) < self::MIN_CHARS) {
return $this->json($query, []);
}
$connection = GeneralUtility::makeInstance(ConnectionManager::class)->getConnectionByTypo3Site(
$request->getAttribute('site'),
(int)($request->getAttribute('language')?->getLanguageId() ?? 0)
);
$documents = $this->search($connection, $query, true);
if ($documents === []) {
// 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));
} catch (\Throwable) {
return $this->json($query, []);
}
}
/** @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
{
$solrQuery = new Query();
$helper = $solrQuery->getHelper();
$terms = preg_split('/\s+/u', $input, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$parts = [];
foreach ($terms as $index => $term) {
$escaped = $helper->escapeTerm($term);
$isLast = $index === count($terms) - 1;
$parts[] = ($prefixLastTerm && $isLast && preg_match('/^[\p{L}\p{N}]+$/u', $term)) ? $escaped . '*' : $escaped;
}
if ($parts === []) {
return [];
}
$solrQuery->setQuery(implode(' ', $parts));
$solrQuery->setQueryDefaultOperator('AND');
$solrQuery->getEDisMax()->setQueryFields('title^20 content');
$solrQuery->createFilterQuery('type')->setQuery('type:' . self::PRODUCT_TYPE);
$solrQuery->setRows(self::MAX_PRODUCTS);
$solrQuery->setFields(['uid', 'title', 'url', 'image']);
$response = $connection->getReadService()->search($solrQuery);
$documents = [];
foreach ((array)($response->response->docs ?? []) as $document) {
$documents[] = method_exists($document, 'getFields') ? (array)$document->getFields() : (array)$document;
}
return $documents;
}
/**
* @param array<int,array<string,mixed>> $documents
* @return array<int,array{title:string,teaser:string,image:?string,url:string}>
*/
private function cards(array $documents): array
{
$uids = array_values(array_filter(array_map(static fn(array $d): int => (int)($d['uid'] ?? 0), $documents)));
$teasers = $this->teasers($uids);
$cards = [];
$seen = [];
foreach ($documents as $document) {
$uid = (int)($document['uid'] ?? 0);
$url = trim((string)($document['url'] ?? ''));
if ($uid === 0 || $url === '' || isset($seen[$uid])) {
continue;
}
$seen[$uid] = true;
$image = trim((string)($document['image'] ?? ''));
if ($image !== '' && !str_starts_with($image, '/') && !preg_match('#^https?://#', $image)) {
$image = '/' . $image;
}
$cards[] = [
'title' => (string)($document['title'] ?? ''),
'teaser' => $teasers[$uid] ?? '',
'image' => $image !== '' ? $image : null,
'url' => $url,
];
}
return $cards;
}
/**
* @param int[] $uids
* @return array<int,string> uid => one-line teaser
*/
private function teasers(array $uids): array
{
if ($uids === []) {
return [];
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::PRODUCT_TYPE);
$rows = $queryBuilder->select('uid', 'teaser')->from(self::PRODUCT_TYPE)
->where($queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($uids, ArrayParameterType::INTEGER)))
->executeQuery()->fetchAllAssociative();
$teasers = [];
foreach ($rows as $row) {
$text = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string)$row['teaser']), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) > self::TEASER_LENGTH) {
$cut = mb_substr($text, 0, self::TEASER_LENGTH);
$space = mb_strrpos($cut, ' ');
$text = rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …';
}
$teasers[(int)$row['uid']] = $text;
}
return $teasers;
}
/** @param array<int,array<string,mixed>> $products */
private function json(string $query, array $products): string
{
return (string)json_encode([
'query' => $query,
'suggestions' => array_values(array_unique(array_column($products, 'title'))),
'products' => $products,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
}