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:
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
|
* 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.
|
* 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
|
* 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
|
* category and sub-category is a clickable page. Both levels now carry
|
||||||
* `link` (additive; '' while a level is still a folder). Folders are still
|
* `link` (additive; '' while a level is still a folder). Folders are still
|
||||||
@@ -119,16 +127,21 @@ final class ProvideProductCatalog
|
|||||||
$uid = (int)$itemRow['uid'];
|
$uid = (int)$itemRow['uid'];
|
||||||
$pagePos[$uid] = [$c, $g, $p];
|
$pagePos[$uid] = [$c, $g, $p];
|
||||||
$products = $productsByPage[$uid] ?? [];
|
$products = $productsByPage[$uid] ?? [];
|
||||||
|
// exactly one product: the tile is that product (link + image), see class comment
|
||||||
|
$single = count($products) === 1 ? $products[0] : null;
|
||||||
$item = [
|
$item = [
|
||||||
'type' => 'page',
|
'type' => 'page',
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'title' => (string)$itemRow['title'],
|
'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),
|
'productCount' => count($products),
|
||||||
'products' => $products,
|
'products' => array_map(static fn(array $p): array => ['title' => $p['title'], 'link' => $p['link']], $products),
|
||||||
];
|
];
|
||||||
if ($stage >= 2) {
|
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;
|
$items[] = $item;
|
||||||
}
|
}
|
||||||
@@ -228,7 +241,7 @@ final class ProvideProductCatalog
|
|||||||
* product detail page (container children included), creation order.
|
* product detail page (container children included), creation order.
|
||||||
*
|
*
|
||||||
* @param int[] $pageUids
|
* @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
|
private function productsOnPages(array $pageUids): array
|
||||||
{
|
{
|
||||||
@@ -244,16 +257,49 @@ final class ProvideProductCatalog
|
|||||||
)
|
)
|
||||||
->orderBy('uid')
|
->orderBy('uid')
|
||||||
->executeQuery()->fetchAllAssociative();
|
->executeQuery()->fetchAllAssociative();
|
||||||
|
$uidBySlug = $rows === [] ? [] : $this->productUidsBySlug();
|
||||||
$byPage = [];
|
$byPage = [];
|
||||||
foreach ($rows as $row) {
|
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']][] = [
|
$byPage[(int)$row['pid']][] = [
|
||||||
'title' => (string)$row['header'],
|
'title' => (string)$row['header'],
|
||||||
'link' => (string)$row['vitec_primary_cta'],
|
'link' => $link,
|
||||||
|
'uid' => $uidBySlug[$slug] ?? 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $byPage;
|
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. */
|
/** First page media (page properties > resources), 640px tile, root-relative - or null. */
|
||||||
private function pageImage(int $pageUid): ?array
|
private function pageImage(int $pageUid): ?array
|
||||||
{
|
{
|
||||||
@@ -262,6 +308,17 @@ final class ProvideProductCatalog
|
|||||||
if ($references === []) {
|
if ($references === []) {
|
||||||
return null;
|
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);
|
$imageService = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(ImageService::class);
|
||||||
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
|
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640', 'fileExtension' => 'webp']);
|
||||||
$url = $imageService->getImageUri($processed);
|
$url = $imageService->getImageUri($processed);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
| 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). |
|
| 1.30 | 2026‑11‑02 | **Interface change (additive).** Megamenu `pagetree` entries carry `display` (`columns` \| `rail`) and, for `rail`, `cardSize` (`regular` \| `compact`): Markets and Solutions switch to a left list with the sub-pages as image cards on hover. Page-tree items gained `image` (page media) and `teaser` (abstract) from stage 2; the sub-solution pages received their hero background as page media (sub-markets had page media already). |
|
||||||
| 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. |
|
| 1.31 | 2026‑11‑05 | **No interface change (data source).** Megamenu Insights `spotlight.news` read only news folder 11; the newer posts live in 442 (ticket 0046-FE). Catalog and spotlight entries gained the editor fields *Data source folders* and *Data source categories*; the Insights listener reads news from them (empty = 11 + 442, no category filter), newest `datetime` first, three cards. Pages carrying the menu are tagged with the news and event tables, so a saved news or event record refreshes the menu. Contract unchanged. |
|
||||||
| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. |
|
| 1.32 | 2026‑11‑05 | **Interface change (additive).** Products menu: categories and sub-categories are clickable pages (were sysfolders/labels): `categories[].link` and `groups[].link`. Six sub-categories are the 2026-10 category pages (content/slug/SEO kept, sitemap name as nav_title = menu `title`); 3 categories, End-Points, RF Gateway and Capture & Graphics (content of Graphics & Multi-Display + Video Capture) are new pages; Graphics & Multi-Display, Video Capture and Infrastructure & Accessories left the menus (reachable). Breadcrumbs run through category and sub-category. Spec text on the removed v1.28 transitional fields corrected. **Caching:** `/_json/` responses no longer carry browser cache headers (`page.config.sendCacheHeaders = 0`) - EXT:headless enables them and TYPO3 14's default page lifetime of one year made browsers keep every JSON response up to a year; front ends should request with `cache: 'no-cache'` to get past copies cached before. |
|
||||||
|
| 1.33 | 2026‑11‑12 | **Interface change (additive).** Products menu: an overview page with exactly one product is presented as that product — `items[].link` is the product detail page and `items[].image` the product picture (productimage, else heroimage, else page media); new `items[].linkType` (`product` | `overview`) and `items[].overviewLink` (always the overview page). Applies today to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway, ActiveSQX (decision Harry 2026-11-12). Front end: no sub-product count for `linkType = product`. |
|
||||||
|
|
||||||
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
|
||||||
@@ -1229,8 +1230,13 @@ reached from the overview pages.
|
|||||||
"groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
|
"groups": [ { "title": "Encoders & Decoders", "link": "/products/video-encoding-and-streaming",
|
||||||
"items": [
|
"items": [
|
||||||
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
{ "type": "page", "uid": 587, "title": "Appliances", "link": "/products/appliances",
|
||||||
|
"linkType": "overview", "overviewLink": "/products/appliances",
|
||||||
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
|
"productCount": 5, "products": [ { "title": "MGW Diamond-H", "link": "/product/…" } ],
|
||||||
"image": null },
|
"image": null },
|
||||||
|
{ "type": "page", "uid": 575, "title": "Avedia", "link": "/product/avedia-platform-iptv-signage-cmsserver",
|
||||||
|
"linkType": "product", "overviewLink": "/products/avedia",
|
||||||
|
"productCount": 1, "products": [ { "title": "Avedia Platform", "link": "/product/avedia-platform-iptv-signage-cmsserver" } ],
|
||||||
|
"image": { "url": "/fileadmin/_processed_/…webp", "alternative": "", "width": 640, "height": 360 } },
|
||||||
{ "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo",
|
{ "type": "shortcut", "title": "Aligo KVM", "link": "/products/aligo",
|
||||||
"target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ]
|
"target": { "category": 1, "group": 3, "page": 0 } } ] } ] } ]
|
||||||
}
|
}
|
||||||
@@ -1251,8 +1257,18 @@ reached from the overview pages.
|
|||||||
- `products` are the product cards of the overview page that link a detail
|
- `products` are the product cards of the overview page that link a detail
|
||||||
page (container children included, creation order); `productCount` is
|
page (container children included, creation order); `productCount` is
|
||||||
their number - a card without a resolvable product is not counted.
|
their number - a card without a resolvable product is not counted.
|
||||||
|
- **Single-product overviews (v1.33, additive):** an overview page with
|
||||||
|
exactly one product is presented as that product - `link` is the product
|
||||||
|
detail page (`/product/<slug>`) and `image` the product's own picture
|
||||||
|
(*productimage*, else *heroimage*, else the page media). `linkType` tells
|
||||||
|
the two cases apart: `product` | `overview`; `overviewLink` always holds
|
||||||
|
the overview page (`/products/<slug>`) for breadcrumb-style uses. Today
|
||||||
|
this applies to Avedia, EZ TV, X-Player End-Points, ChannelLink IP Gateway
|
||||||
|
and ActiveSQX; it switches automatically when an editor adds a second
|
||||||
|
card. The front end should not render a sub-product count for these.
|
||||||
- `image` (from content stage 2) is the overview page's first page media as a
|
- `image` (from content stage 2) is the overview page's first page media as a
|
||||||
640 px WebP, `null` when none is set.
|
640 px WebP (`linkType = overview`) or the product picture (`linkType =
|
||||||
|
product`), `null` when none is set.
|
||||||
- The promo boxes of the left column ("Product highlight", "Product news")
|
- The promo boxes of the left column ("Product highlight", "Product news")
|
||||||
are the entry's editorial `teasers` next to `catalog` (`kicker` = box
|
are the entry's editorial `teasers` next to `catalog` (`kicker` = box
|
||||||
label, plus `title`, `text`, `link`, `image`; designed for two, an empty
|
label, plus `title`, `text`, `link`, `image`; designed for two, an empty
|
||||||
@@ -1710,4 +1726,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.32.*
|
*End of document EVO‑VITEC‑HL‑001 v1.33.*
|
||||||
Reference in new issue
Block a user