* * { "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 > category > * sub-category > page, see ProvideProductCatalog) with their menu path. * Category and sub-category are pages since 2026-11-05 (folders before - * both are read). * * @return array 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'); $rows = $queryBuilder->select('uid', 'pid', 'title', 'nav_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(); // menu label = nav_title when set (same rule as ProvideProductCatalog) foreach ($rows as &$row) { if (trim((string)($row['nav_title'] ?? '')) !== '') { $row['title'] = $row['nav_title']; } } return $rows; }; $categories = array_column($level([$root], [1, 254]), null, 'uid'); $groups = array_column($level(array_keys($categories), [1, 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> $products * @param array> $overviews * @return array> */ 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> $overviews * @return array */ 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> 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 * @param array> $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); } }