diff --git a/packages/evo_megamenu_json/Classes/Event/BuildSpotlightEvent.php b/packages/evo_megamenu_json/Classes/Event/BuildSpotlightEvent.php new file mode 100644 index 0000000..73de4bf --- /dev/null +++ b/packages/evo_megamenu_json/Classes/Event/BuildSpotlightEvent.php @@ -0,0 +1,54 @@ +|null */ + private ?array $spotlight = null; + + /** + * @param array $entry the raw tx_evomegamenujson_entry row + */ + public function __construct( + private readonly array $entry, + private readonly int $stage, + ) {} + + /** @return array */ + public function getEntry(): array + { + return $this->entry; + } + + /** Content stage of the menu (1 structure, 2 + images/teasers, 3 + featured). */ + public function getStage(): int + { + return $this->stage; + } + + /** @return array|null */ + public function getSpotlight(): ?array + { + return $this->spotlight; + } + + /** @param array $spotlight */ + public function setSpotlight(array $spotlight): void + { + $this->spotlight = $spotlight; + } +} diff --git a/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php index d7c4d57..5708b35 100644 --- a/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php +++ b/packages/evo_megamenu_json/Classes/Service/MenuBuilder.php @@ -6,6 +6,7 @@ namespace Evomedien\EvoMegamenuJson\Service; use Doctrine\DBAL\ParameterType; use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent; +use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent; use Psr\EventDispatcher\EventDispatcherInterface; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\QueryBuilder; @@ -87,7 +88,7 @@ final class MenuBuilder private function buildEntry(array $entry, int $stage, ContentObjectRenderer $cObj): array { $layout = (string)($entry['layout'] ?? 'columns'); - if (!in_array($layout, ['columns', 'teasers', 'catalog', 'pagetree'], true)) { + if (!in_array($layout, ['columns', 'teasers', 'catalog', 'pagetree', 'spotlight'], true)) { $layout = 'columns'; } @@ -111,7 +112,18 @@ final class MenuBuilder // trigger). Curated columns are ignored on this layout; teasers and // the featured slot stay editorial. if ($layout === 'pagetree') { - $columns = $this->treeColumns((string)$entry['link'], $cObj); + $columns = $this->treeColumns((string)$entry['link'], $stage, $cObj); + } + + // Spotlight panels (2026-10-27, customer wish "visual menu like + // rossvideo.com"): a flat left-rail nav built live from the linked + // page's children (abstract + page media per item, so the front end + // can swap the visual area on hover) next to live content cards that + // a site package supplies via BuildSpotlightEvent - this extension + // does not know the data sources. Curated columns are ignored here + // like on pagetree; teasers and the featured slot stay editorial. + if ($layout === 'spotlight') { + $columns = []; } $built = [ @@ -129,6 +141,12 @@ final class MenuBuilder $built['catalog'] = $event->getCatalog(); } + if ($layout === 'spotlight') { + $built['nav'] = $this->navItems((string)$entry['link'], $stage, $cObj); + $spotlightEvent = $this->eventDispatcher->dispatch(new BuildSpotlightEvent($entry, $stage)); + $built['spotlight'] = $spotlightEvent->getSpotlight(); + } + if ($layout === 'teasers' || $layout === 'catalog') { $teasers = []; foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) { @@ -195,14 +213,9 @@ final class MenuBuilder * * @return array> */ - private function treeColumns(string $entryLink, ContentObjectRenderer $cObj): array + private function treeColumns(string $entryLink, int $stage, ContentObjectRenderer $cObj): array { - $rootUid = 0; - if (preg_match('/t3:\/\/page\?uid=(\d+)/', $entryLink, $matches)) { - $rootUid = (int)$matches[1]; - } elseif (ctype_digit(trim($entryLink))) { - $rootUid = (int)trim($entryLink); - } + $rootUid = $this->linkedPageUid($entryLink); if ($rootUid <= 0) { return []; } @@ -215,15 +228,69 @@ final class MenuBuilder 'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj), ]; } - $columns[] = [ + $column = [ 'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']), 'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj), 'items' => $items, ]; + // visual menus (2026-10-27): a page with media gets its first + // image as the column visual - attach an image in the page + // properties and the panel turns into image cards by itself + if ($stage >= 2) { + $image = $this->image('pages', 'media', (int)$child['uid']); + if ($image !== null) { + $column['image'] = $image; + } + } + $columns[] = $column; } return $columns; } + /** + * Flat left-rail nav for the spotlight layout: the linked page's children + * with abstract and page media, so the front end can render the rail and + * swap the visual area on hover. Same visibility rules as treeColumns + * (getMenu applies hidden, time, nav_hide and fe_group). + * + * @return array> + */ + private function navItems(string $entryLink, int $stage, ContentObjectRenderer $cObj): array + { + $rootUid = $this->linkedPageUid($entryLink); + if ($rootUid <= 0) { + return []; + } + $items = []; + foreach ($this->pageRepository->getMenu($rootUid) as $child) { + $item = [ + 'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']), + 'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj), + ]; + if ($stage >= 2) { + $teaser = trim((string)($child['abstract'] ?? '')); + if ($teaser !== '') { + $item['teaser'] = $teaser; + } + $item['image'] = $this->image('pages', 'media', (int)$child['uid']); + } + $items[] = $item; + } + return $items; + } + + /** Page uid a menu entry links to; 0 when the link is no page link. */ + private function linkedPageUid(string $entryLink): int + { + if (preg_match('/t3:\/\/page\?uid=(\d+)/', $entryLink, $matches)) { + return (int)$matches[1]; + } + if (ctype_digit(trim($entryLink))) { + return (int)trim($entryLink); + } + return 0; + } + // ------------------------------------------------------------ internals /** @return array|null */ diff --git a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php index 1399cdf..f41d582 100644 --- a/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php +++ b/packages/evo_megamenu_json/Configuration/TCA/tx_evomegamenujson_entry.php @@ -106,6 +106,7 @@ return [ ['label' => $ll . 'layout.teasers', 'value' => 'teasers'], ['label' => $ll . 'layout.catalog', 'value' => 'catalog'], ['label' => $ll . 'layout.pagetree', 'value' => 'pagetree'], + ['label' => $ll . 'layout.spotlight', 'value' => 'spotlight'], ], ], ], diff --git a/packages/evo_megamenu_json/README.md b/packages/evo_megamenu_json/README.md index 37ad8a0..ace79f2 100644 --- a/packages/evo_megamenu_json/README.md +++ b/packages/evo_megamenu_json/README.md @@ -17,10 +17,12 @@ A plugin called **Megamenu**. Inside it: | **Teaser card** | kicker, headline, text, link, image — for editorial panels | | **Featured** | one highlighted block per entry, edited on the entry itself | -Three panel layouts per entry: `columns` for a structure menu (products, -solutions, markets), `teasers` for a small link column next to cards -(news, stories), and `catalog` for a data-driven panel whose content comes -from the site package (see below). +Five panel layouts per entry: `columns` for a curated structure menu, +`teasers` for a small link column next to cards (news, stories), +`pagetree` for a structure menu built live from the page tree, +`catalog` for a data-driven panel whose content comes from the site +package, and `spotlight` for a visual panel with a left-rail nav and live +content cards (see below). ## Installation @@ -115,6 +117,44 @@ final class ProvideCatalog `getEntry()` hands the listener the raw entry row (to tell several catalog entries apart), `getStage()` the content stage. +### Layout `pagetree` + +A structure panel that can never age against the site: the columns mirror +the live subtree of the page the entry links to - one column per child +page, its children as the items. Hidden or nav-hidden pages leave the menu +with the next cache flush. Curated columns are ignored; teasers and the +featured slot stay editorial. From stage 2 a child page with media gets its +first image as `image` on the column - attach images in the page +properties and the panel turns visual by itself. + +### Layout `spotlight` + +A visual panel ("rossvideo style"): `nav` is a flat left rail built live +from the linked page's children - each item with `title`, `link` and, from +stage 2, the page `abstract` as `teaser` plus the first page media as +`image`, so the front end can swap the visual area on hover. Next to it the +extension emits `spotlight`, filled by the site package: it dispatches +`Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent` per spotlight entry +and emits whatever a listener sets via `setSpotlight()` verbatim +(`null` without a listener). Curated columns are ignored. + +The VITEC site package ships `ProvideInsightsSpotlight`, which supplies + +```json +"spotlight": { + "news": [ {"title", "date", "link", "teaser?", "image?"} ], + "events": [ {"title", "start", "end", "venue", "booth", "city", + "country", "attendanceMode", "link", "teaser?", "image?"} ] +} +``` + +- `news`: the three latest news records (storage pid 11, type 0 - the Solr + convention), `link` = `/news/article/` per the NewsDetail + route enhancer, image from `fal_media` as processed 640px webp. +- `events`: the next two events (`hideonwebsite` = 0, upcoming by + eventstart/eventend), `link` = the record's `eventurl` or the events + page, image processed the same way. + ## Notes * Every link is a resolved URL — page, record, external or file, run through diff --git a/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf index 6ec3e7f..6f9cbe5 100644 --- a/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf +++ b/packages/evo_megamenu_json/Resources/Private/Language/locallang_db.xlf @@ -26,6 +26,7 @@ Link column plus teaser cards Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles) Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored) + Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored) Columns One column per group. In the teaser layout only the first column is rendered, next to the cards. Teaser cards diff --git a/packages/vitec/Classes/EventListener/ProvideInsightsSpotlight.php b/packages/vitec/Classes/EventListener/ProvideInsightsSpotlight.php new file mode 100644 index 0000000..9596d58 --- /dev/null +++ b/packages/vitec/Classes/EventListener/ProvideInsightsSpotlight.php @@ -0,0 +1,185 @@ +). + * Events come from the vitec event table; an event links to its own + * eventurl when set, otherwise to the events page. + */ +#[AsEventListener(identifier: 'vitec/megamenu-insights-spotlight')] +final class ProvideInsightsSpotlight +{ + private const NEWS_TABLE = 'tx_news_domain_model_news'; + private const NEWS_STORAGE_PID = 11; + private const NEWS_DETAIL_PATH = '/news/article/'; + private const EVENT_TABLE = 'tx_vitec_domain_model_event'; + private const EVENTS_PAGE = '/insights/events'; + private const NEWS_COUNT = 3; + private const EVENT_COUNT = 2; + + public function __construct( + private readonly ConnectionPool $connectionPool, + private readonly FileRepository $fileRepository, + ) {} + + public function __invoke(BuildSpotlightEvent $event): void + { + $stage = $event->getStage(); + $event->setSpotlight([ + 'news' => $this->latestNews($stage), + 'events' => $this->upcomingEvents($stage), + ]); + } + + /** @return array> */ + private function latestNews(int $stage): array + { + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE); + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + try { + $rows = $queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment') + ->from(self::NEWS_TABLE) + ->where( + $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(self::NEWS_STORAGE_PID, ParameterType::INTEGER)), + $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0')) + ) + ->orderBy('datetime', 'DESC') + ->setMaxResults(self::NEWS_COUNT) + ->executeQuery()->fetchAllAssociative(); + } catch (\Throwable) { + return []; + } + $cards = []; + foreach ($rows as $row) { + $card = [ + 'title' => (string)$row['title'], + 'date' => ((int)$row['datetime']) > 0 ? date('Y-m-d', (int)$row['datetime']) : '', + 'link' => (string)$row['path_segment'] !== '' ? self::NEWS_DETAIL_PATH . $row['path_segment'] : '', + ]; + if ($stage >= 2) { + $teaser = trim((string)($row['teaser'] ?? '')); + if ($teaser !== '') { + $card['teaser'] = $teaser; + } + $card['image'] = $this->image(self::NEWS_TABLE, 'fal_media', (int)$row['uid']); + } + $cards[] = $card; + } + return $cards; + } + + /** @return array> */ + private function upcomingEvents(int $stage): array + { + $now = time(); + $queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::EVENT_TABLE); + $queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class)); + try { + $rows = $queryBuilder->select('*') + ->from(self::EVENT_TABLE) + ->where( + $queryBuilder->expr()->eq('hideonwebsite', $queryBuilder->createNamedParameter(0, ParameterType::INTEGER)), + $queryBuilder->expr()->or( + $queryBuilder->expr()->gte('eventend', $queryBuilder->createNamedParameter($now, ParameterType::INTEGER)), + $queryBuilder->expr()->and( + $queryBuilder->expr()->eq('eventend', $queryBuilder->createNamedParameter(0, ParameterType::INTEGER)), + $queryBuilder->expr()->gte('eventstart', $queryBuilder->createNamedParameter($now - 86400, ParameterType::INTEGER)) + ) + ) + ) + ->orderBy('eventstart', 'ASC') + ->setMaxResults(self::EVENT_COUNT) + ->executeQuery()->fetchAllAssociative(); + } catch (\Throwable) { + return []; + } + $cards = []; + foreach ($rows as $row) { + $card = [ + 'title' => (string)$row['title'], + 'start' => ((int)$row['eventstart']) > 0 ? date('Y-m-d', (int)$row['eventstart']) : '', + 'end' => ((int)$row['eventend']) > 0 ? date('Y-m-d', (int)$row['eventend']) : '', + 'venue' => (string)$row['venue'], + 'booth' => (string)$row['booth'], + 'city' => (string)$row['city'], + 'country' => (string)$row['country'], + 'attendanceMode' => (string)$row['attendancemode'], + 'link' => (string)$row['eventurl'] !== '' ? (string)$row['eventurl'] : self::EVENTS_PAGE, + ]; + if ($stage >= 2) { + $teaser = trim((string)($row['teaser'] ?? '')); + if ($teaser !== '') { + $card['teaser'] = $teaser; + } + $card['image'] = $this->image(self::EVENT_TABLE, 'image', (int)$row['uid']); + } + $cards[] = $card; + } + return $cards; + } + + /** + * First file reference as a processed 640px webp card image, original URL + * as the fallback - same contract as MenuBuilder::image() so every menu + * image behaves the same on the wire. + * + * @return array|null + */ + private function image(string $table, string $field, int $uid): ?array + { + try { + $references = $this->fileRepository->findByRelation($table, $field, $uid); + } catch (\Throwable) { + return null; + } + $reference = $references[0] ?? null; + if ($reference === null) { + return null; + } + try { + try { + $imageService = GeneralUtility::makeInstance(ImageService::class); + $processed = $imageService->applyProcessingInstructions( + $reference, + ['maxWidth' => 640, 'fileExtension' => 'webp'] + ); + return [ + 'url' => $imageService->getImageUri($processed), + 'alternative' => (string)$reference->getProperty('alternative'), + 'title' => (string)$reference->getProperty('title'), + 'width' => (int)$processed->getProperty('width'), + 'height' => (int)$processed->getProperty('height'), + ]; + } catch (\Throwable) { + return [ + 'url' => $reference->getPublicUrl(), + 'alternative' => (string)$reference->getProperty('alternative'), + 'title' => (string)$reference->getProperty('title'), + 'width' => (int)$reference->getProperty('width'), + 'height' => (int)$reference->getProperty('height'), + ]; + } + } catch (\Throwable) { + return null; + } + } +}