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:
1 parent
b5fcdb2b5a
commit
887caed2aa
8 files changed
+502
-6
No files matched your search
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\EvoMegamenuJson\Event;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched for every entry with the panel layout `catalog`.
|
||||||
|
*
|
||||||
|
* The extension stays project-neutral: it knows that a catalog panel exists,
|
||||||
|
* not where its data comes from. A site package listens to this event and
|
||||||
|
* supplies the data (categories, their groups and items, a search endpoint,
|
||||||
|
* ...) as a plain array; it is emitted verbatim as `catalog` on the entry.
|
||||||
|
* Without a listener the entry carries `catalog: null`.
|
||||||
|
*/
|
||||||
|
final class BuildCatalogEvent
|
||||||
|
{
|
||||||
|
/** @var array<string,mixed>|null */
|
||||||
|
private ?array $catalog = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $entry the raw tx_evomegamenujson_entry row
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $entry,
|
||||||
|
private readonly int $stage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
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<string,mixed>|null */
|
||||||
|
public function getCatalog(): ?array
|
||||||
|
{
|
||||||
|
return $this->catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $catalog */
|
||||||
|
public function setCatalog(array $catalog): void
|
||||||
|
{
|
||||||
|
$this->catalog = $catalog;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace Evomedien\EvoMegamenuJson\Service;
|
namespace Evomedien\EvoMegamenuJson\Service;
|
||||||
|
|
||||||
use Doctrine\DBAL\ParameterType;
|
use Doctrine\DBAL\ParameterType;
|
||||||
|
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
|
||||||
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
|
||||||
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
|
||||||
@@ -52,6 +54,7 @@ final class MenuBuilder
|
|||||||
private readonly FlexFormService $flexFormService,
|
private readonly FlexFormService $flexFormService,
|
||||||
private readonly PageRepository $pageRepository,
|
private readonly PageRepository $pageRepository,
|
||||||
private readonly FileRepository $fileRepository,
|
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
|
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 = [];
|
$columns = [];
|
||||||
foreach ($this->children(self::TABLE_COLUMN, (int)$entry['uid']) as $column) {
|
foreach ($this->children(self::TABLE_COLUMN, (int)$entry['uid']) as $column) {
|
||||||
@@ -104,7 +110,15 @@ final class MenuBuilder
|
|||||||
'columns' => $columns,
|
'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 = [];
|
$teasers = [];
|
||||||
foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) {
|
foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) {
|
||||||
$card = [
|
$card = [
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ A plugin called **Megamenu**. Inside it:
|
|||||||
| **Teaser card** | kicker, headline, text, link, image — for editorial panels |
|
| **Teaser card** | kicker, headline, text, link, image — for editorial panels |
|
||||||
| **Featured** | one highlighted block per entry, edited on the entry itself |
|
| **Featured** | one highlighted block per entry, edited on the entry itself |
|
||||||
|
|
||||||
Two panel layouts per entry: `columns` for a structure menu (products,
|
Three panel layouts per entry: `columns` for a structure menu (products,
|
||||||
solutions, markets) and `teasers` for a small link column next to cards
|
solutions, markets), `teasers` for a small link column next to cards
|
||||||
(news, stories).
|
(news, stories), and `catalog` for a data-driven panel whose content comes
|
||||||
|
from the site package (see below).
|
||||||
|
|
||||||
## Installation
|
## 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
|
column heading instead), `mute` (show, not clickable) or `hide`. This keeps
|
||||||
a menu shippable while its deeper pages are still being built.
|
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
|
## Notes
|
||||||
|
|
||||||
* Every link is a resolved URL — page, record, external or file, run through
|
* Every link is a resolved URL — page, record, external or file, run through
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<trans-unit id="tx_evomegamenujson_entry.layout.description"><source>Columns for a structure menu, teaser cards for editorial sections such as news or stories.</source></trans-unit>
|
<trans-unit id="tx_evomegamenujson_entry.layout.description"><source>Columns for a structure menu, teaser cards for editorial sections such as news or stories.</source></trans-unit>
|
||||||
<trans-unit id="tx_evomegamenujson_entry.layout.columns"><source>Columns</source></trans-unit>
|
<trans-unit id="tx_evomegamenujson_entry.layout.columns"><source>Columns</source></trans-unit>
|
||||||
<trans-unit id="tx_evomegamenujson_entry.layout.teasers"><source>Link column plus teaser cards</source></trans-unit>
|
<trans-unit id="tx_evomegamenujson_entry.layout.teasers"><source>Link column plus teaser cards</source></trans-unit>
|
||||||
|
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
|
||||||
<trans-unit id="tx_evomegamenujson_entry.menu_columns"><source>Columns</source></trans-unit>
|
<trans-unit id="tx_evomegamenujson_entry.menu_columns"><source>Columns</source></trans-unit>
|
||||||
<trans-unit id="tx_evomegamenujson_entry.menu_columns.description"><source>One column per group. In the teaser layout only the first column is rendered, next to the cards.</source></trans-unit>
|
<trans-unit id="tx_evomegamenujson_entry.menu_columns.description"><source>One column per group. In the teaser layout only the first column is rendered, next to the cards.</source></trans-unit>
|
||||||
<trans-unit id="tx_evomegamenujson_entry.menu_teasers"><source>Teaser cards</source></trans-unit>
|
<trans-unit id="tx_evomegamenujson_entry.menu_teasers"><source>Teaser cards</source></trans-unit>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -974,6 +974,24 @@ tx_solr_suggest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Product type-ahead of the megamenu catalog panel (2026-10-09):
|
||||||
|
# GET /_json/?type=7385&q=<input> -> { 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 = 1
|
||||||
plugin.tx_solr.suggest {
|
plugin.tx_solr.suggest {
|
||||||
numberOfSuggestions = 8
|
numberOfSuggestions = 8
|
||||||
|
|||||||
@@ -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.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.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.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,
|
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
|
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
|
(`showTopResults = 0`): their shape is hardcoded upstream and carries the raw
|
||||||
document type and the full indexed content.
|
document type and the full indexed content.
|
||||||
|
|
||||||
|
**Product type-ahead (megamenu, since v1.23).**
|
||||||
|
`GET /_json/?type=7385&q=<input>` 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
|
**Operations.** Indexing runs automatically: record and page saves enter the
|
||||||
index queue, a scheduler task (Index Queue Worker, every 5 minutes) pushes them
|
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`)
|
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.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `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.*
|
||||||
Reference in new issue
Block a user