From 887caed2aa48396c05fcbc748185f90895f581f4 Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Fri, 9 Oct 2026 13:40:23 +0200 Subject: [PATCH] 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) --- .../Classes/Event/BuildCatalogEvent.php | 52 +++++ .../Classes/Service/MenuBuilder.php | 18 +- packages/evo_megamenu_json/README.md | 32 ++- .../Private/Language/locallang_db.xlf | 1 + .../EventListener/ProvideProductCatalog.php | 196 ++++++++++++++++++ .../ProductMenuSearchJsonRenderer.php | 166 +++++++++++++++ .../Sets/Vitecset/setup.typoscript | 18 ++ .../Headless-JSON-Architecture.md | 25 ++- 8 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 packages/evo_megamenu_json/Classes/Event/BuildCatalogEvent.php create mode 100644 packages/vitec/Classes/EventListener/ProvideProductCatalog.php create mode 100644 packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php diff --git a/packages/evo_megamenu_json/Classes/Event/BuildCatalogEvent.php b/packages/evo_megamenu_json/Classes/Event/BuildCatalogEvent.php new file mode 100644 index 0000000..4aa32a5 --- /dev/null +++ b/packages/evo_megamenu_json/Classes/Event/BuildCatalogEvent.php @@ -0,0 +1,52 @@ +|null */ + private ?array $catalog = null; + + /** + * @param array $entry the raw tx_evomegamenujson_entry row + */ + public function __construct( + private readonly array $entry, + private readonly int $stage, + ) {} + + /** @return array */ + public function getEntry(): array + { + return $this->entry; + } + + /** Content stage of the menu (1 structure, 2 + images/teasers, 3 + featured). */ + public function getStage(): int + { + return $this->stage; + } + + /** @return array|null */ + public function getCatalog(): ?array + { + return $this->catalog; + } + + /** @param array $catalog */ + public function setCatalog(array $catalog): void + { + $this->catalog = $catalog; + } +} diff --git a/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php index 02fa6d0..e6c7a70 100644 --- a/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php +++ b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Evomedien\EvoMegamenuJson\Service; use Doctrine\DBAL\ParameterType; +use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent; +use Psr\EventDispatcher\EventDispatcherInterface; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\QueryBuilder; use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer; @@ -52,6 +54,7 @@ final class MenuBuilder private readonly FlexFormService $flexFormService, private readonly PageRepository $pageRepository, private readonly FileRepository $fileRepository, + private readonly EventDispatcherInterface $eventDispatcher, ) {} /** @@ -82,7 +85,10 @@ final class MenuBuilder */ private function buildEntry(array $entry, int $stage, ContentObjectRenderer $cObj): array { - $layout = (string)($entry['layout'] ?? 'columns') === 'teasers' ? 'teasers' : 'columns'; + $layout = (string)($entry['layout'] ?? 'columns'); + if (!in_array($layout, ['columns', 'teasers', 'catalog'], true)) { + $layout = 'columns'; + } $columns = []; foreach ($this->children(self::TABLE_COLUMN, (int)$entry['uid']) as $column) { @@ -104,7 +110,15 @@ final class MenuBuilder 'columns' => $columns, ]; - if ($layout === 'teasers') { + // Catalog panels: the teaser cards serve as the featured tiles (left + // column), the catalog data itself comes from a listener of + // BuildCatalogEvent - this extension does not know the data source. + if ($layout === 'catalog') { + $event = $this->eventDispatcher->dispatch(new BuildCatalogEvent($entry, $stage)); + $built['catalog'] = $event->getCatalog(); + } + + if ($layout === 'teasers' || $layout === 'catalog') { $teasers = []; foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) { $card = [ diff --git a/packages/evo_megamenu_json/README.md b/packages/evo_megamenu_json/README.md index 5af77c2..37ad8a0 100644 --- a/packages/evo_megamenu_json/README.md +++ b/packages/evo_megamenu_json/README.md @@ -17,9 +17,10 @@ A plugin called **Megamenu**. Inside it: | **Teaser card** | kicker, headline, text, link, image — for editorial panels | | **Featured** | one highlighted block per entry, edited on the entry itself | -Two panel layouts per entry: `columns` for a structure menu (products, -solutions, markets) and `teasers` for a small link column next to cards -(news, stories). +Three panel layouts per entry: `columns` for a structure menu (products, +solutions, markets), `teasers` for a small link column next to cards +(news, stories), and `catalog` for a data-driven panel whose content comes +from the site package (see below). ## Installation @@ -89,6 +90,31 @@ An item whose target page does not exist yet carries `"pending": true`. column heading instead), `mute` (show, not clickable) or `hide`. This keeps a menu shippable while its deeper pages are still being built. +### Layout `catalog` + +For panels built from data rather than by hand — a product catalog with +categories, groups and items, a search bar, whatever the site needs. The +extension stays project-neutral: it dispatches +`Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent` for every catalog entry +and emits whatever a listener sets via `setCatalog()` verbatim as +`catalog` on the entry (`null` without a listener). The entry's teaser cards +are emitted as `teasers` as usual and serve as the panel's featured tiles; +its columns are ignored. + +```php +#[AsEventListener(identifier: 'site/megamenu-catalog')] +final class ProvideCatalog +{ + public function __invoke(BuildCatalogEvent $event): void + { + $event->setCatalog(['categories' => [/* … */]]); + } +} +``` + +`getEntry()` hands the listener the raw entry row (to tell several catalog +entries apart), `getStage()` the content stage. + ## Notes * Every link is a resolved URL — page, record, external or file, run through diff --git a/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf index e0a51bf..421dc9e 100644 --- a/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf +++ b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf @@ -24,6 +24,7 @@ Columns for a structure menu, teaser cards for editorial sections such as news or stories. Columns Link column plus teaser cards + Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles) Columns One column per group. In the teaser layout only the first column is rendered, next to the cards. Teaser cards diff --git a/packages/vitec/Classes/EventListener/ProvideProductCatalog.php b/packages/vitec/Classes/EventListener/ProvideProductCatalog.php new file mode 100644 index 0000000..a651b6d --- /dev/null +++ b/packages/vitec/Classes/EventListener/ProvideProductCatalog.php @@ -0,0 +1,196 @@ + 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=
&subcat=. + * + * 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> */ + 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 + */ + 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> 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, ' ,;:-') . ' …'; + } +} diff --git a/packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php b/packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php new file mode 100644 index 0000000..caf6e70 --- /dev/null +++ b/packages/vitec/Classes/UserFunc/ProductMenuSearchJsonRenderer.php @@ -0,0 +1,166 @@ + + * + * { "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> 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> $documents + * @return array + */ + 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 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> $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); + } +} diff --git a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript index 13303ec..7a63b7e 100755 --- a/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript +++ b/packages/vitec/Configuration/Sets/Vitecset/setup.typoscript @@ -974,6 +974,24 @@ tx_solr_suggest { } } +# ============================================================================= +# Product type-ahead of the megamenu catalog panel (2026-10-09): +# GET /_json/?type=7385&q= -> { query, suggestions[], products[] } +# (ProductMenuSearchJsonRenderer). USER_INT for the same reason as 7384: +# every keystroke is a new query. `q` is excluded from cHash (ext_localconf). +# ============================================================================= +vitec_product_menu_search = PAGE +vitec_product_menu_search { + typeNum = 7385 + config { + disableAllHeaderCode = 1 + additionalHeaders.10.header = Content-Type: application/json; charset=utf-8 + } + + 10 = USER_INT + 10.userFunc = Evomedien\Vitec\UserFunc\ProductMenuSearchJsonRenderer->render +} + plugin.tx_solr.suggest = 1 plugin.tx_solr.suggest { numberOfSuggestions = 8 diff --git a/packages/vitec/Documentation/Headless-JSON-Architecture.md b/packages/vitec/Documentation/Headless-JSON-Architecture.md index eb8d511..e18cf38 100755 --- a/packages/vitec/Documentation/Headless-JSON-Architecture.md +++ b/packages/vitec/Documentation/Headless-JSON-Architecture.md @@ -35,6 +35,7 @@ | 1.20 | 2026‑10‑01 | **Interface change (additive).** Sub-market page template: frontend layout 21 with one colPos group per design section (`colPos110`..`colPos160`, clause 7.17); new Content Blocks `vitec_markethero`, `vitec_marketintro`, `vitec_marketinsights`; new plugin `vitec_productfeature` (key `productfeature`, one product in the 7.15.2 item shape); story cards gained `quote`/`quotePerson`/`quoteRole`; `vitec_usecaselist` gained the layout value `testimonials`; `vitec_productlist` gained hand-picked product selection (editor order, category filter ignored). Field semantics: Template_Sub-Market_Spezifikation_2026-10-01.md. | | 1.21 | 2026‑10‑07 | **Interface change (additive) and corpus change.** Search results carry `image` (processed 640 px tile image, root-relative, `null` for types without one). Corpus: story documents now include their body (the inline `tt_content` children), page and story content include the text columns of the market/solution Content Blocks and the `vitec_items`/`vitec_faq_items` collection children; solutions are indexed. News rank by publication date (`datetime`; `crdate` is the import date) with an age-decay boost. EXT:solr upgraded from 14.0.0-RC1 to 14.0.2 final (TYPO3 and the Solr image). Editorial: 7.16 now states the `/_json` prefix the URL swap introduced, the `type` vocabulary lists `solution` (delivered since 1.14), and the footer had been stuck at v1.19. | | 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). | +| 1.23 | 2026‑10‑09 | **New interface (additive).** Product type-ahead for the megamenu catalog panel: lean page type `?type=7385&q=` (7.16) returning `suggestions` and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout `catalog`, filled by the VITEC listener `ProvideProductCatalog` (main categories → subcategory groups → products with one-line teasers). | 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 @@ -1172,6 +1173,28 @@ regular `/_json/search?q=` request. Top-result documents are deliberately disabl (`showTopResults = 0`): their shape is hardcoded upstream and carries the raw document type and the full indexed content. +**Product type-ahead (megamenu, since v1.23).** +`GET /_json/?type=7385&q=` answers as a lean JSON page type +(`ProductMenuSearchJsonRenderer`, USER_INT): + +```json +{ "query": "mgw d", + "suggestions": [ "MGW Diamond+ OG", "MGW Diamond" ], + "products": [ { "title": "MGW Diamond+ OG", "teaser": "Broadcast-grade 4K HEVC …", + "image": "/fileadmin/_processed_/…", "url": "/product/…" } ] } +``` + +It runs through Solr restricted to product documents; the last term is +completed as a prefix, so cards appear while typing (inputs below two +characters yield the empty shape). At most six products, one card per +product; `suggestions` are their titles for the autocomplete list. `teaser` +is the product's plain-text one-liner from the record (≤ 120 characters), +`image` the indexed 640 px tile or `null`. The endpoint and its parameters +travel with the megamenu catalog payload (`catalog.search`); the catalog +itself (`layout: "catalog"`, categories → groups → products) is produced by +the `ProvideProductCatalog` listener of EXT:evo_megamenu_json’s +`BuildCatalogEvent` - see that extension’s README. + **Operations.** Indexing runs automatically: record and page saves enter the index queue, a scheduler task (Index Queue Worker, every 5 minutes) pushes them to Solr; the CLI command `vitec:solr-index` (with `--initialize` and `--debug`) @@ -1589,4 +1612,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.22.* +*End of document EVO‑VITEC‑HL‑001 v1.23.*