Products menu: single-product overviews link to the product (spec v1.33)

Decision Harry 2026-11-12 for the third level of the products panel: an overview page with exactly one product is presented as that product. items[].link becomes the product detail page and items[].image the product picture (productimage, else heroimage, else the page media as before); new additive fields items[].linkType (product | overview) and items[].overviewLink (always the overview page). The product uid is resolved from the card link via the product slug; one extra query per build. Applies today to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway and ActiveSQX and switches back automatically once an editor adds a second card. Removing the sub-product line and the shortcut pill/subline is front end work (ticket 0121-BE).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5.1 committed 2026-11-12 13:11:56 +01:00
1 parent 8031247acf
commit 4f47382679
2 files changed
+80 -7

No files matched your search

@@ -29,6 +29,14 @@ use TYPO3\CMS\Extbase\Service\ImageService;
* pages the visitor reaches from the overview page; the menu does not show
* them, the count and the list serve the "N sub-products" line and search.
*
* An overview page with exactly ONE product is shown as that product
* (decision Harry 2026-11-12, e.g. Avedia, EZ TV, X-Player, ChannelLink,
* ActiveSQX): `link` is the product detail page and `image` the product's
* own image (productimage, else heroimage, else the page media). `linkType`
* says which case it is (`product` | `overview`), `overviewLink` always
* keeps the overview page. The page itself stays reachable (breadcrumb,
* search, sitemap).
*
* A and B were sysfolders (menu labels only) until 2026-11-05; Olli: every
* category and sub-category is a clickable page. Both levels now carry
* `link` (additive; '' while a level is still a folder). Folders are still
@@ -119,16 +127,21 @@ final class ProvideProductCatalog
$uid = (int)$itemRow['uid'];
$pagePos[$uid] = [$c, $g, $p];
$products = $productsByPage[$uid] ?? [];
// exactly one product: the tile is that product (link + image), see class comment
$single = count($products) === 1 ? $products[0] : null;
$item = [
'type' => 'page',
'uid' => $uid,
'title' => (string)$itemRow['title'],
'link' => (string)$itemRow['slug'],
'link' => $single !== null ? $single['link'] : (string)$itemRow['slug'],
'linkType' => $single !== null ? 'product' : 'overview',
'overviewLink' => (string)$itemRow['slug'],
'productCount' => count($products),
'products' => $products,
'products' => array_map(static fn(array $p): array => ['title' => $p['title'], 'link' => $p['link']], $products),
];
if ($stage >= 2) {
$item['image'] = $this->pageImage($uid);
$item['image'] = ($single !== null && $single['uid'] > 0 ? $this->productImage($single['uid']) : null)
?? $this->pageImage($uid);
}
$items[] = $item;
}
@@ -228,7 +241,7 @@ final class ProvideProductCatalog
* product detail page (container children included), creation order.
*
* @param int[] $pageUids
* @return array<int,array<int,array{title:string,link:string}>> page uid => products
* @return array<int,array<int,array{title:string,link:string,uid:int}>> page uid => products
*/
private function productsOnPages(array $pageUids): array
{
@@ -244,16 +257,49 @@ final class ProvideProductCatalog
)
->orderBy('uid')
->executeQuery()->fetchAllAssociative();
$uidBySlug = $rows === [] ? [] : $this->productUidsBySlug();
$byPage = [];
foreach ($rows as $row) {
$link = (string)$row['vitec_primary_cta'];
$slug = trim((string)parse_url($link, PHP_URL_PATH), '/');
$slug = substr($slug, strlen('product/'));
$byPage[(int)$row['pid']][] = [
'title' => (string)$row['header'],
'link' => (string)$row['vitec_primary_cta'],
'link' => $link,
'uid' => $uidBySlug[$slug] ?? 0,
];
}
return $byPage;
}
/** @return array<string,int> product slug (without slashes) => uid, live products only */
private function productUidsBySlug(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_vitec_domain_model_product');
$rows = $queryBuilder->select('uid', 'slug')->from('tx_vitec_domain_model_product')
->executeQuery()->fetchAllAssociative();
$map = [];
foreach ($rows as $row) {
$map[trim((string)$row['slug'], '/')] = (int)$row['uid'];
}
return $map;
}
/** The product's own picture (productimage, else heroimage) as 640px tile - or null. */
private function productImage(int $productUid): ?array
{
try {
foreach (['productimage', 'heroimage'] as $field) {
$references = $this->fileRepository->findByRelation('tx_vitec_domain_model_product', $field, $productUid);
if ($references !== []) {
return $this->tile($references[0]);
}
}
} catch (\Throwable) {
}
return null;
}
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
private function pageImage(int $pageUid): ?array
{
@@ -262,6 +308,17 @@ final class ProvideProductCatalog
if ($references === []) {
return null;
}
return $this->tile($references[0]);
} catch (\Throwable) {
return null;
}
}
/** @return array{url:string,alternative:string,width:int,height:int}|null */
private function tile(\TYPO3\CMS\Core\Resource\FileReference $reference): ?array
{
try {
$references = [$reference];
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
$url = $imageService->getImageUri($processed);