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>
278 lines
12 KiB
PHP
278 lines
12 KiB
PHP
<?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", ... ],
|
|
* "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
|
|
* 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);
|
|
}
|
|
// 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
|
|
{
|
|
$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
|
|
* @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_merge(array_column($overviews, 'title'), array_column($products, 'title')))),
|
|
'overviews' => $overviews,
|
|
'products' => $products,
|
|
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
}
|
|
}
|