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,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user