Success-story migration + new plugins + JSON/UX polish

- Success Stories: full migration from old site (48/48, audited),
  markets n:n, quotation content block, columns content block,
  pretty SEO detail URLs via SuccessStoryPathRewrite middleware,
  list/detail split (list page 4 / story page), detailUrl/backUrl
- New plugins: VITEC Locations (grid/list/map + RTE map text),
  VITEC Customer Logos (color/bw logic, only-show-selected),
  VITEC Card (one plugin for product/story/market/solution
  with reloading FlexForm + custom backend preview renderer)
- Eventlist: layout dropdown (list/grid/teaserbar) in settings
- Hero section CB: Images/Video tabs, background video + overlay
- Product JSON: full category rootline (parents), fixed missing
  ConnectionPool import (all-products crash), category tree map
- Backend preview CSS: container-query responsive (narrow columns)
- Docs: ISO architecture spec, root + extension READMEs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
khaccountandClaude Fable 5 committed 2026-07-24 09:53:33 +02:00
1 parent aafcb5dcb6
commit eae40f4286
110 files changed
+8502 -279

No files matched your search

@@ -0,0 +1,708 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* One-time (idempotent) migration of the old vitec.com Success Stories into
* the new tx_vitec_domain_model_usecase model.
*
* Source: migrations JSON produced on the OLD site by
* migrations/export_success_stories.php, plus the staged original files under
* fileadmin/success_stories_import/ (same relative identifiers as on the old
* site).
*
* Mapping (see analysis in the session / architecture spec):
* pages(title,slug,SEO) -> usecase base + seo_*
* mask_web__markets_card -> subtitle, card_image, customer_logo
* mask_web__success_story__promo -> hero (bgimage, overlay color, theme)
* use_case element (APP) -> industries (sys_category) -> markets MM
* page content (flux tree) -> inline content_elements:
* text -> text CE (blockquote quotes -> vitec_quotation)
* image/textmedia -> image/textmedia CE + FAL
* html -> html CE
* 2-column flux rows -> vitec_columns content block
*
* Usage:
* vendor/bin/typo3 vitec:import-success-stories --dry-run
* vendor/bin/typo3 vitec:import-success-stories --only=meydan-racecourse
* vendor/bin/typo3 vitec:import-success-stories --force (reimport existing)
*/
#[AsCommand(
name: 'vitec:import-success-stories',
description: 'Import success stories from the old-website export JSON into the usecase model'
)]
final class ImportSuccessStoriesCommand extends Command
{
private const TABLE = 'tx_vitec_domain_model_usecase';
private const FILES_BASE = '/success_stories_import';
/** Old market2 token -> canonical industry title. */
private const INDUSTRY_TOKENS = [
'sports' => 'Sports', 'venues' => 'Venues', 'government' => 'Government',
'military' => 'Military', 'corporate' => 'Corporate', 'broadcast' => 'Broadcast',
'education' => 'Education', 'healthcare' => 'Healthcare',
'hospitality' => 'Hospitality & Leisure', 'leisure' => 'Hospitality & Leisure',
'accomodation' => 'Accommodation', 'accommodation' => 'Accommodation',
];
/** CTypes that never become content elements. */
private const SKIP_CTYPES = [
'shortcut', 'div', 'fluxbs5templates_buttonlink', 'mask_web__jumpmenu',
'mask_web__markets_card', 'mask_web__success_story__full_width_promo',
'mask_web__success_top_story', 'mask_use_case', 'header',
];
private const CONTAINER_CTYPES = ['fluxbs5templates_fluidrow', 'fluxbs5templates_container'];
private array $export = [];
private array $pagesByUid = [];
private array $contentByUid = [];
private array $childrenByParent = []; // parentCeUid => list of child CEs
private array $rootsByPid = []; // pageUid => list of colPos-0/5 CEs
private array $cardByPageUid = [];
private array $useCaseByKey = [];
private array $fileRefs = []; // "table:uid:field" => refs[]
private array $catTitleByUid = [];
private array $catsByCeUid = [];
private array $marketUidByTitle = [];
private array $fileUidCache = [];
private string $collectionTable = '';
protected function configure(): void
{
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Path to success_stories_export.json', 'var/transient/success_stories_export.json');
$this->addOption('pid', null, InputOption::VALUE_REQUIRED, 'Storage pid for new usecase records (default: pid of existing records)');
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Import only the story whose slug tail matches');
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Analyse and report only, write nothing');
$this->addOption('force', null, InputOption::VALUE_NONE, 'Delete + recreate stories that already exist (matched by slug)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$file = (string)$input->getOption('file');
if (!is_file($file)) {
$output->writeln("<error>Export file not found: $file</error>");
return Command::FAILURE;
}
$this->export = json_decode((string)file_get_contents($file), true) ?? [];
if (!isset($this->export['pages'])) {
$output->writeln('<error>Export JSON malformed (no pages key)</error>');
return Command::FAILURE;
}
$dryRun = (bool)$input->getOption('dry-run');
$force = (bool)$input->getOption('force');
$only = (string)($input->getOption('only') ?? '');
$this->buildIndexes();
$storagePid = $this->resolveStoragePid($input->getOption('pid'));
if ($storagePid <= 0) {
$output->writeln('<error>No storage pid — pass --pid or create one usecase record manually first.</error>');
return Command::FAILURE;
}
$this->collectionTable = (string)($GLOBALS['TCA']['tt_content']['columns']['vitec_items']['config']['foreign_table'] ?? '');
$rootUids = array_map(static fn(array $r): int => (int)$r['uid'], $this->export['storyRoots']);
$stories = array_values(array_filter(
$this->export['pages'],
static fn(array $p): bool => (int)($p['sys_language_uid'] ?? 0) === 0 && in_array((int)$p['pid'], $rootUids, true)
));
$output->writeln(sprintf(
'<info>%d stories in export · storage pid %d · %s</info>',
count($stories),
$storagePid,
$dryRun ? 'DRY-RUN' : 'LIVE'
));
if (!$dryRun) {
$this->ensureMarkets($output, $storagePid);
}
$summary = [];
foreach ($stories as $page) {
$slugTail = basename((string)$page['slug']);
if ($only !== '' && $slugTail !== $only) {
continue;
}
try {
$summary[] = $this->importStory($page, $storagePid, $dryRun, $force, $output);
} catch (\Throwable $e) {
$summary[] = [$slugTail, 'ERROR: ' . $e->getMessage(), 0, 0, 0];
$output->writeln("<error> $slugTail: {$e->getMessage()}</error>");
}
}
$output->writeln('');
$output->writeln(str_pad('story', 55) . str_pad('action', 12) . str_pad('CEs', 5) . str_pad('imgs', 6) . 'markets');
foreach ($summary as [$slug, $action, $ces, $imgs, $mk]) {
$output->writeln(str_pad(substr($slug, 0, 53), 55) . str_pad($action, 12) . str_pad((string)$ces, 5) . str_pad((string)$imgs, 6) . $mk);
}
return Command::SUCCESS;
}
// =========================================================== index building
private function buildIndexes(): void
{
foreach ($this->export['pages'] as $p) {
$this->pagesByUid[(int)$p['uid']] = $p;
}
$allCes = array_merge($this->export['content'], $this->export['storyElementsElsewhere']);
foreach ($allCes as $c) {
$this->contentByUid[(int)$c['uid']] = $c;
}
// flux tree: colPos >= 100 encodes parentUid*100 + column
foreach ($this->export['content'] as $c) {
$colPos = (int)($c['colPos'] ?? 0);
if ($colPos >= 100 && isset($this->contentByUid[intdiv($colPos, 100)])) {
$this->childrenByParent[intdiv($colPos, 100)][] = $c;
} else {
$this->rootsByPid[(int)$c['pid']][] = $c;
}
}
foreach ($this->childrenByParent as &$list) {
usort($list, static fn($a, $b) => [($a['colPos'] % 100), $a['sorting']] <=> [($b['colPos'] % 100), $b['sorting']]);
}
unset($list);
foreach ($this->rootsByPid as &$list) {
usort($list, static fn($a, $b) => $a['sorting'] <=> $b['sorting']);
}
unset($list);
// story card by target page uid (tx_mask_web__markets_card_link = "427" | "t3://page?uid=427")
foreach ($allCes as $c) {
if (($c['CType'] ?? '') !== 'mask_web__markets_card') {
continue;
}
$link = (string)($c['tx_mask_web__markets_card_link'] ?? '');
if (preg_match('/uid=(\d+)/', $link, $m)) {
$target = (int)$m[1];
} elseif (ctype_digit(trim($link))) {
$target = (int)trim($link);
} else {
continue;
}
// prefer the newest non-hidden card per target
$known = $this->cardByPageUid[$target] ?? null;
if ($known === null || ((int)$c['tstamp'] > (int)$known['tstamp'] && empty($c['hidden']))) {
$this->cardByPageUid[$target] = $c;
}
}
// use_case (APP) elements by normalised title
foreach ($allCes as $c) {
if (($c['CType'] ?? '') !== 'mask_use_case') {
continue;
}
foreach ([(string)($c['header'] ?? ''), (string)($c['tx_mask_tile_customer'] ?? '')] as $t) {
$k = $this->normTitle($t);
if ($k !== '' && !isset($this->useCaseByKey[$k])) {
$this->useCaseByKey[$k] = $c;
}
}
}
foreach ($this->export['fileReferences'] as $r) {
if (!empty($r['hidden'])) {
continue;
}
$this->fileRefs[$r['tablenames'] . ':' . $r['uid_foreign'] . ':' . $r['fieldname']][] = $r;
}
foreach ($this->export['categories'] as $c) {
$this->catTitleByUid[(int)$c['uid']] = (string)$c['title'];
}
foreach ($this->export['categoryAssignments'] as $a) {
if ($a['tablenames'] === 'tt_content') {
$this->catsByCeUid[(int)$a['uid_foreign']][] = $this->catTitleByUid[(int)$a['category_uid']] ?? '';
}
}
}
/** Resolve a content-blocks column name: prefixed variant if it exists in TCA. */
private function cbCol(string $table, string $name): string
{
return isset($GLOBALS['TCA'][$table]['columns']['vitec_' . $name]) ? 'vitec_' . $name : $name;
}
private function normTitle(string $t): string
{
return preg_replace('/[^a-z0-9]/', '', strtolower($t)) ?? '';
}
private function refs(string $table, int $uid, string $field): array
{
return $this->fileRefs[$table . ':' . $uid . ':' . $field] ?? [];
}
// ============================================================== market setup
private function resolveStoragePid(mixed $option): int
{
if ($option !== null && (int)$option > 0) {
return (int)$option;
}
$row = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable(self::TABLE)
->select(['pid'], self::TABLE, ['deleted' => 0], [], [], 1)
->fetchAssociative();
return (int)($row['pid'] ?? 0);
}
/** Ensure one market record per canonical industry; fill title->uid map. */
private function ensureMarkets(OutputInterface $output, int $fallbackPid): void
{
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_market');
$rows = $conn->select(['uid', 'title', 'pid'], 'tx_vitec_domain_model_market', ['deleted' => 0])->fetchAllAssociative();
$marketPid = $fallbackPid;
foreach ($rows as $r) {
$this->marketUidByTitle[$this->normTitle((string)$r['title'])] = (int)$r['uid'];
$marketPid = (int)$r['pid'];
}
$canonical = array_unique(array_values(self::INDUSTRY_TOKENS));
$datamap = [];
foreach ($canonical as $i => $title) {
if (!isset($this->marketUidByTitle[$this->normTitle($title)])) {
$datamap['tx_vitec_domain_model_market']['NEWMARKET' . $i] = ['pid' => $marketPid, 'title' => $title];
}
}
if ($datamap !== []) {
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($datamap, []);
$dh->process_datamap();
foreach ($datamap['tx_vitec_domain_model_market'] as $key => $fields) {
$uid = (int)($dh->substNEWwithIDs[$key] ?? 0);
if ($uid > 0) {
$this->marketUidByTitle[$this->normTitle($fields['title'])] = $uid;
$output->writeln(" <info>created market: {$fields['title']} ($uid)</info>");
}
}
}
}
/** @return string[] canonical industry titles for one story */
private function industriesForStory(array $page, ?array $card, ?array $useCase): array
{
$out = [];
if ($useCase !== null) {
foreach ($this->catsByCeUid[(int)$useCase['uid']] ?? [] as $t) {
if ($t !== '') {
$out[] = $t;
}
}
}
if ($out === [] && $card !== null) {
$tokens = preg_split('/\s+/', strtolower((string)($card['tx_mask_markets_card_market2'] ?? ''))) ?: [];
foreach ($tokens as $tok) {
if (isset(self::INDUSTRY_TOKENS[$tok])) {
$out[] = self::INDUSTRY_TOKENS[$tok];
}
}
}
return array_values(array_unique($out));
}
// =============================================================== story import
/** @return array{0:string,1:string,2:int,3:int,4:string} */
private function importStory(array $page, int $storagePid, bool $dryRun, bool $force, OutputInterface $output): array
{
$pageUid = (int)$page['uid'];
$slugTail = basename((string)$page['slug']);
$slug = '/' . $slugTail;
$card = $this->cardByPageUid[$pageUid] ?? null;
$useCase = $this->useCaseByKey[$this->normTitle((string)$page['title'])] ?? null;
$promo = null;
foreach ($this->rootsByPid[$pageUid] ?? [] as $ce) {
if ($ce['CType'] === 'mask_web__success_story__full_width_promo') {
$promo = $ce;
break;
}
}
$industries = $this->industriesForStory($page, $card, $useCase);
$ceSpecs = $this->transformContent($pageUid);
// ---- existing?
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable(self::TABLE);
$existing = $conn->select(['uid'], self::TABLE, ['slug' => $slug, 'deleted' => 0])->fetchAssociative()
?: $conn->select(['uid'], self::TABLE, ['slug' => $slugTail, 'deleted' => 0])->fetchAssociative();
$imgCount = 0;
foreach ($ceSpecs as $s) {
$imgCount += count($s['files'] ?? []);
foreach ($s['items'] ?? [] as $it) {
$imgCount += count($it['files'] ?? []);
}
}
if ($dryRun) {
$output->writeln(sprintf(
' <comment>%s</comment> ces=%d quotes=%d cols=%d imgs=%d markets=[%s] card=%s uc=%s hero=%s%s',
$slugTail,
count($ceSpecs),
count(array_filter($ceSpecs, fn($s) => $s['CType'] === 'vitec_quotation')),
count(array_filter($ceSpecs, fn($s) => $s['CType'] === 'vitec_columns')),
$imgCount,
implode(',', $industries),
$card ? 'y' : 'MISSING',
$useCase ? 'y' : '-',
$promo ? 'y' : 'MISSING',
$existing ? ' EXISTS(uid=' . $existing['uid'] . ')' : ''
));
return [$slugTail, $existing ? 'dry(exists)' : 'dry', count($ceSpecs), $imgCount, implode(',', $industries)];
}
if ($existing) {
if (!$force) {
return [$slugTail, 'skipped', 0, 0, ''];
}
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start([], [self::TABLE => [(int)$existing['uid'] => ['delete' => 1]]]);
$dh->process_cmdmap();
}
// ---- assemble datamap
$datamap = [];
$storyKey = 'NEWSTORY1';
$refCounter = 0;
$newRef = function (string $identifier, string $table, string $key, string $field, array $meta = []) use (&$datamap, &$refCounter, $storagePid): ?string {
$fileUid = $this->fileUidFor($identifier);
if ($fileUid === null) {
return null;
}
$refKey = 'NEWREF' . (++$refCounter);
$datamap['sys_file_reference'][$refKey] = array_filter([
'pid' => $storagePid,
'uid_local' => 'sys_file_' . $fileUid,
'tablenames' => $table,
'uid_foreign' => $key,
'fieldname' => $field,
'title' => $meta['title'] ?? null,
'alternative' => $meta['alternative'] ?? null,
'description' => $meta['description'] ?? null,
'crop' => $meta['crop'] ?? null,
], static fn($v) => $v !== null && $v !== '');
return $refKey;
};
$attach = function (array $refs, string $table, string $key, string $newField) use ($newRef): string {
$ids = [];
foreach ($refs as $r) {
$id = $newRef((string)$r['identifier'], $table, $key, $newField, $r);
if ($id !== null) {
$ids[] = $id;
}
}
return implode(',', $ids);
};
// story base fields
$subtitle = trim(strip_tags((string)($card['tx_mask_web__markets_card_frontttext'] ?? '')));
if ($subtitle === '' && $useCase !== null) {
$subtitle = (string)($useCase['tx_mask_tile_teaser'] ?? '');
}
$textColor = strtolower((string)($promo['tx_mask_web__success_story__full_width_promo_textcolor'] ?? ''));
$overlay = (string)($promo['tx_mask_web__success_story__full_width_promo_color'] ?? '');
$story = [
'pid' => $storagePid,
'title' => (string)$page['title'],
'slug' => $slug,
'subtitle' => $subtitle,
'teaser' => (string)($page['abstract'] ?? '') !== '' ? (string)$page['abstract'] : (string)($page['description'] ?? ''),
'seo_title' => (string)($page['seo_title'] ?? ''),
'seo_description' => (string)($page['description'] ?? ''),
'og_title' => (string)($page['og_title'] ?? ''),
'og_description' => (string)($page['og_description'] ?? ''),
'twitter_title' => (string)($page['twitter_title'] ?? ''),
'twitter_description' => (string)($page['twitter_description'] ?? ''),
'no_index' => (int)($page['no_index'] ?? 0),
'no_follow' => (int)($page['no_follow'] ?? 0),
'hero_overlay_color' => $overlay,
'hero_overlay_opacity' => $overlay !== '' ? 0.4 : 0,
'text_theme' => in_array($textColor, ['#fff', '#ffffff', 'white'], true) ? 'light' : 'dark',
'markets' => implode(',', array_filter(array_map(
fn(string $t): int => $this->marketUidByTitle[$this->normTitle($t)] ?? 0,
$industries
))),
];
// FAL: card image, logo, hero, og/twitter
if ($card !== null) {
$story['card_image'] = $attach(
array_slice($this->refs('tt_content', (int)$card['uid'], 'tx_mask_web__markets_card_frontimage'), 0, 1),
self::TABLE, $storyKey, 'card_image'
);
$story['customer_logo'] = $attach(
array_slice($this->refs('tt_content', (int)$card['uid'], 'tx_mask_web__markets_card_customerlogo'), 0, 1),
self::TABLE, $storyKey, 'customer_logo'
);
}
$heroRefs = $promo !== null
? $this->refs('tt_content', (int)$promo['uid'], 'tx_mask_web__success_story__full_width_promo_image')
: [];
if ($heroRefs === []) {
$heroRefs = $this->refs('pages', $pageUid, 'heroimage_big');
}
$story['hero_bgimage'] = $attach(array_slice($heroRefs, 0, 1), self::TABLE, $storyKey, 'hero_bgimage');
$story['og_image'] = $attach(array_slice($this->refs('pages', $pageUid, 'og_image'), 0, 1), self::TABLE, $storyKey, 'og_image');
$story['twitter_image'] = $attach(array_slice($this->refs('pages', $pageUid, 'twitter_image'), 0, 1), self::TABLE, $storyKey, 'twitter_image');
// content elements
$ceKeys = [];
$ceCounter = 0;
$itemCounter = 0;
foreach ($ceSpecs as $spec) {
$ceKey = 'NEWCE' . (++$ceCounter);
$fields = $spec['fields'];
$fields['pid'] = $storagePid;
$fields['CType'] = $spec['CType'];
$fields['colPos'] = 999;
foreach ($spec['files'] ?? [] as $f) {
$fields[$f['field']] = $attach($f['refs'], 'tt_content', $ceKey, $f['field']);
}
if (($spec['CType'] === 'vitec_columns') && $this->collectionTable !== '' && !empty($spec['items'])) {
$itemKeys = [];
foreach ($spec['items'] as $item) {
$itemKey = 'NEWITEM' . (++$itemCounter);
$itemFields = $item['fields'];
$itemFields['pid'] = $storagePid;
foreach ($item['files'] ?? [] as $f) {
$itemFields[$f['field']] = $attach($f['refs'], $this->collectionTable, $itemKey, $f['field']);
}
$datamap[$this->collectionTable][$itemKey] = $itemFields;
$itemKeys[] = $itemKey;
}
$fields[$this->cbCol('tt_content', 'items')] = implode(',', $itemKeys);
}
$datamap['tt_content'][$ceKey] = $fields;
$ceKeys[] = $ceKey;
}
$story['content_elements'] = implode(',', $ceKeys);
$datamap[self::TABLE][$storyKey] = $story;
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start($datamap, []);
$dh->process_datamap();
if ($dh->errorLog !== []) {
throw new \RuntimeException('DataHandler: ' . implode(' | ', array_slice($dh->errorLog, 0, 3)));
}
return [$slugTail, $existing ? 'reimported' : 'created', count($ceSpecs), $imgCount, implode(',', $industries)];
}
// ========================================================== content transform
/** @return array<int,array<string,mixed>> ordered CE specs */
private function transformContent(int $pageUid): array
{
$specs = [];
$roots = $this->rootsByPid[$pageUid] ?? [];
// intro (colPos 5): hero subline — first element, CTA buttons stripped
foreach ($roots as $ce) {
if ((int)$ce['colPos'] === 5 && $ce['CType'] === 'text' && !empty($ce['bodytext'])) {
$body = preg_replace('/<p[^>]*>(?:(?!<\/p>).)*btn(?:(?!<\/p>).)*<\/p>/is', '', (string)$ce['bodytext']) ?? '';
if (trim(strip_tags($body)) !== '') {
$specs[] = ['CType' => 'text', 'fields' => ['header' => '', 'bodytext' => trim($body)], 'files' => []];
}
}
}
foreach ($roots as $ce) {
if ((int)$ce['colPos'] === 0) {
$this->transformCe($ce, $specs);
}
}
return $specs;
}
private function transformCe(array $ce, array &$specs): void
{
$ctype = (string)$ce['CType'];
if (in_array($ctype, self::SKIP_CTYPES, true)) {
return;
}
if (in_array($ctype, self::CONTAINER_CTYPES, true)) {
$this->transformContainer($ce, $specs);
return;
}
switch ($ctype) {
case 'text':
$specs[] = $this->textOrQuoteSpec($ce);
break;
case 'image':
case 'textmedia':
$field = $ctype === 'image' ? 'image' : 'assets';
$spec = [
'CType' => $ctype,
'fields' => array_filter([
'header' => (string)($ce['header'] ?? ''),
'header_layout' => (int)($ce['header_layout'] ?? 0),
'bodytext' => (string)($ce['bodytext'] ?? ''),
], static fn($v) => $v !== '' && $v !== 0),
'files' => [],
];
$refs = $this->refs('tt_content', (int)$ce['uid'], $field);
if ($refs !== []) {
$spec['files'][] = ['field' => $field, 'refs' => $refs];
}
if ($spec['files'] !== [] || ($spec['fields']['bodytext'] ?? '') !== '') {
$specs[] = $spec;
}
break;
case 'html':
if (!empty($ce['bodytext'])) {
$specs[] = ['CType' => 'html', 'fields' => ['bodytext' => (string)$ce['bodytext']], 'files' => []];
}
break;
default:
// unknown → keep text-ish content if any
if (!empty($ce['bodytext']) || !empty($ce['header'])) {
$specs[] = ['CType' => 'text', 'fields' => [
'header' => (string)($ce['header'] ?? ''),
'bodytext' => (string)($ce['bodytext'] ?? ''),
], 'files' => []];
}
}
}
/** Two clean columns -> vitec_columns; everything else flattens in order. */
private function transformContainer(array $ce, array &$specs): void
{
$children = $this->childrenByParent[(int)$ce['uid']] ?? [];
if ($children === []) {
return;
}
$byCol = [];
foreach ($children as $child) {
$byCol[(int)$child['colPos'] % 100][] = $child;
}
ksort($byCol);
$isSimple = static fn(array $c): bool => in_array($c['CType'], ['text', 'image'], true)
&& stripos((string)($c['bodytext'] ?? ''), '<blockquote') === false;
$cols = array_keys($byCol);
$allSimple = count($cols) === 2 && count($children) <= 4
&& array_reduce($children, static fn($ok, $c) => $ok && $isSimple($c), true);
if ($allSimple) {
$items = [];
foreach ($byCol as $colIdx => $colCes) {
$side = $colIdx === $cols[0] ? 'left' : 'right';
foreach ($colCes as $c) {
$t = $this->collectionTable;
if ($c['CType'] === 'text') {
$items[] = ['fields' => [
$this->cbCol($t, 'column') => $side, $this->cbCol($t, 'item_type') => 'text',
$this->cbCol($t, 'headline') => (string)($c['header'] ?? ''),
$this->cbCol($t, 'text') => (string)($c['bodytext'] ?? ''),
], 'files' => []];
} else {
$refs = $this->refs('tt_content', (int)$c['uid'], 'image');
$items[] = ['fields' => [
$this->cbCol($t, 'column') => $side, $this->cbCol($t, 'item_type') => 'image',
$this->cbCol($t, 'headline') => (string)($c['header'] ?? ''),
], 'files' => $refs !== [] ? [['field' => $this->cbCol($t, 'image'), 'refs' => array_slice($refs, 0, 1)]] : []];
}
}
}
$specs[] = [
'CType' => 'vitec_columns',
'fields' => ['header' => (string)($ce['header'] ?? ''), $this->cbCol('tt_content', 'layout') => 'cols_50_50'],
'files' => [],
'items' => $items,
];
return;
}
// flatten: children first; the container header only becomes a heading
// when the container actually contributed content (skips e.g. the old
// "Related Success Stories" section whose children are all shortcuts).
$childSpecs = [];
foreach ($byCol as $colCes) {
foreach ($colCes as $child) {
$this->transformCe($child, $childSpecs);
}
}
$header = trim((string)($ce['header'] ?? ''));
if ($childSpecs !== [] && $header !== '' && !str_starts_with($header, '///')) {
$specs[] = ['CType' => 'text', 'fields' => ['header' => $header], 'files' => []];
}
foreach ($childSpecs as $cs) {
$specs[] = $cs;
}
}
/** blockquote text CEs become vitec_quotation; the rest stay text. */
private function textOrQuoteSpec(array $ce): array
{
$body = (string)($ce['bodytext'] ?? '');
$header = (string)($ce['header'] ?? '');
if (stripos($body, '<blockquote') !== false) {
$quote = $body;
$name = '';
$position = '';
if (preg_match('/<blockquote[^>]*>(.*)<\/blockquote>/is', $body, $m)) {
$inner = $m[1];
// attribution paragraph: <p><strong>— Name</strong><br/>Position</p>
if (preg_match('/<p>\s*<strong>\s*(?:—|&mdash;|-)?\s*(.*?)<\/strong>\s*(?:<br\s*\/?>)?\s*(.*?)<\/p>\s*$/is', $inner, $a)) {
$name = trim(strip_tags($a[1]));
$position = trim(strip_tags($a[2]));
$inner = (string)preg_replace('/<p>\s*<strong>\s*(?:—|&mdash;|-)?.*?<\/p>\s*$/is', '', $inner);
}
$quote = trim(strip_tags($inner));
$quote = trim($quote, "“”\"' \u{201C}\u{201D}");
}
return ['CType' => 'vitec_quotation', 'fields' => [
$this->cbCol('tt_content', 'quote') => $quote,
$this->cbCol('tt_content', 'quote_name') => $name,
$this->cbCol('tt_content', 'quote_position') => $position,
], 'files' => []];
}
return ['CType' => 'text', 'fields' => array_filter([
'header' => strcasecmp($header, 'Quote') === 0 ? '' : $header,
'header_layout' => (int)($ce['header_layout'] ?? 0),
'bodytext' => $body,
], static fn($v) => $v !== '' && $v !== 0), 'files' => []];
}
// ==================================================================== files
/** Old-site identifier -> new-site sys_file uid (staged under FILES_BASE). */
private function fileUidFor(string $identifier): ?int
{
if (isset($this->fileUidCache[$identifier])) {
return $this->fileUidCache[$identifier] ?: null;
}
try {
$file = GeneralUtility::makeInstance(ResourceFactory::class)
->getFileObjectFromCombinedIdentifier('1:' . self::FILES_BASE . $identifier);
$uid = $file !== null ? (int)$file->getUid() : 0;
} catch (\Throwable $e) {
$uid = 0;
}
$this->fileUidCache[$identifier] = $uid;
return $uid ?: null;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
/**
* CustomerController — Extbase registration target for the Customerlogos
* plugin. In headless mode the JSON output is produced by
* CustomerlogosJsonRenderer, not by this controller.
*/
class CustomerController extends ActionController
{
public function listAction(): ResponseInterface
{
return $this->htmlResponse();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
/**
* LocationController — Extbase registration target for the Locationlist
* plugin. In headless mode the JSON output is produced by
* LocationsJsonRenderer, not by this controller.
*/
class LocationController extends ActionController
{
public function listAction(): ResponseInterface
{
return $this->htmlResponse();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
/**
* ModelcardController — Extbase registration target for the VITEC Card
* plugin. In headless mode the JSON output is produced by
* ModelcardJsonRenderer, not by this controller.
*/
class ModelcardController extends ActionController
{
public function listAction(): ResponseInterface
{
return $this->htmlResponse();
}
}
@@ -13,6 +13,9 @@ use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardcollectionJsonRenderer;
use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\LocationsJsonRenderer;
use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -111,6 +114,9 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],
'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'],
'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'],
'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'],
'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'],
'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'],
'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Pretty detail URLs for Success Stories.
*
* Public URL: /success-stories/<slug> (old-site SEO URLs)
* Real page: /success-stories/story/<slug> (subpage holding ONLY the
* usecaseshow plugin)
*
* TYPO3's page router always resolves /success-stories/<slug> to the LIST
* page (longest page-slug prefix), so the detail subpage could never answer
* that URL. This middleware rewrites the request path internally — before
* page resolution — whenever <slug> matches an existing (visible) success
* story. Real subpages of /success-stories are unaffected: no story match,
* no rewrite. The browser URL never changes.
*
* Exception-safe: any failure leaves the request untouched.
*/
final class SuccessStoryPathRewrite implements MiddlewareInterface
{
private const LIST_PATH = '/success-stories';
private const DETAIL_SEGMENT = 'story';
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$debug = 'no-match';
try {
$path = $request->getUri()->getPath();
if (preg_match('#^' . self::LIST_PATH . '/([a-zA-Z0-9\-]+)/?$#', $path, $m) === 1
&& $m[1] !== self::DETAIL_SEGMENT
) {
if ($this->isStorySlug($m[1])) {
$uri = $request->getUri()->withPath(
self::LIST_PATH . '/' . self::DETAIL_SEGMENT . '/' . $m[1]
);
$request = $request->withUri($uri);
$debug = 'rewritten';
} else {
$debug = 'slug-not-found:' . $m[1];
}
}
} catch (\Throwable $e) {
$debug = 'exception:' . substr($e->getMessage(), 0, 60);
}
// TEMP-DIAG header (remove after verification)
return $handler->handle($request)->withHeader('X-Vitec-Story-Rewrite', $debug);
}
private function isStorySlug(string $slug): bool
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$row = $qb
->select('uid')
->from('tx_vitec_domain_model_usecase')
->where(
$qb->expr()->eq('slug', $qb->createNamedParameter($slug)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
return $row !== false;
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Preview;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Backend\Preview\StandardContentPreviewRenderer;
use TYPO3\CMS\Backend\View\BackendLayout\Grid\GridColumnItem;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Fluid\View\StandaloneView;
/**
* Page-module preview for the VITEC Card plugin (vitec_modelcard):
* type badge, selected record title, thumbnail and layout badge.
* Registered via TCA types.vitec_modelcard.previewRenderer.
*/
class ModelcardPreviewRenderer extends StandardContentPreviewRenderer
{
private const MODEL_TABLES = [
'product' => 'tx_vitec_domain_model_product',
'story' => 'tx_vitec_domain_model_usecase',
'market' => 'tx_vitec_domain_model_market',
'solution' => 'tx_vitec_domain_model_solution',
];
private const TYPE_LABELS = [
'product' => 'Product',
'story' => 'Success Story',
'market' => 'Market',
'solution' => 'Solution',
];
/** Card-image FAL field per model type (first hit wins). */
private const IMAGE_FIELDS = [
'product' => ['image', 'productimage'],
'story' => ['card_image', 'hero_bgimage'],
'market' => ['image'],
'solution' => ['image'],
];
public function renderPageModulePreviewContent(GridColumnItem $item): string
{
try {
$record = $item->getRecord();
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($record['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$modelType = (string)($settings['modelType'] ?? 'product');
$layout = (string)($settings['layout'] ?? 'vertical');
$recordUid = (int)($settings[$modelType === 'story' ? 'story' : $modelType] ?? 0);
$title = '';
$subtitle = '';
$imageUrl = '';
if ($recordUid > 0 && isset(self::MODEL_TABLES[$modelType])) {
$table = self::MODEL_TABLES[$modelType];
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$row = $qb
->select('*')
->from($table)
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0)
)
->executeQuery()
->fetchAssociative();
if ($row) {
$title = (string)($row['title'] ?? '');
$subtitle = (string)($row['subtitle'] ?? ($row['teaser'] ?? ''));
$imageUrl = $this->firstImageUrl($table, $recordUid, self::IMAGE_FIELDS[$modelType]);
}
}
$view = GeneralUtility::makeInstance(StandaloneView::class);
$view->setTemplatePathAndFilename('EXT:vitec/Resources/Private/Templates/Preview/Modelcard.html');
$view->assignMultiple([
'typeLabel' => self::TYPE_LABELS[$modelType] ?? $modelType,
'layout' => $layout,
'recordUid' => $recordUid,
'title' => $title,
'subtitle' => $subtitle,
'imageUrl' => $imageUrl,
]);
return $view->render();
} catch (\Throwable $e) {
return parent::renderPageModulePreviewContent($item);
}
}
/** @param string[] $fieldNames */
private function firstImageUrl(string $table, int $uid, array $fieldNames): string
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
foreach ($fieldNames as $fieldName) {
$ref = $qb
->select('uid')
->from('sys_file_reference')
->where(
$qb->expr()->eq('tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
$qb->expr()->eq('uid_foreign', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if ($ref) {
$file = GeneralUtility::makeInstance(ResourceFactory::class)
->getFileReferenceObject((int)$ref['uid']);
return (string)$file->getPublicUrl();
}
}
} catch (\Throwable $e) {
// no thumb
}
return '';
}
}
@@ -15,6 +15,9 @@ use Evomedien\Vitec\UserFunc\DownloadcardJsonRenderer;
use Evomedien\Vitec\UserFunc\DownloadcardcollectionJsonRenderer;
use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\LocationsJsonRenderer;
use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -76,6 +79,9 @@ final class ContentElementResolver
'vitec_downloadcardcollection' => [DownloadcardcollectionJsonRenderer::class, 'downloadcardcollection'],
'vitec_datasheets' => [DatasheetsJsonRenderer::class, 'datasheets'],
'vitec_eventlist' => [EventlistJsonRenderer::class, 'eventlist'],
'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'],
'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'],
'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'],
'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],
@@ -45,7 +45,7 @@ final class UsecaseSerializer
'teaser' => (string)($u['teaser'] ?? ''),
'cardImage' => $this->image($uid, 'card_image'),
'customerLogo' => $this->image($uid, 'customer_logo'),
'market' => $this->market((int)($u['market'] ?? 0)),
'markets' => $this->relationMulti('tx_vitec_usecase_market_mm', 'tx_vitec_domain_model_market', $uid),
'categories' => $this->categories($uid),
'featured' => (bool)($u['featured'] ?? false),
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
@@ -75,7 +75,7 @@ final class UsecaseSerializer
'contentElements' => $this->contentElements($uid),
'related' => [
'show' => (bool)($u['show_related'] ?? true),
'market' => $base['market'],
'markets' => $base['markets'],
'solutions' => $this->relationMulti('tx_vitec_usecase_solution_mm', 'tx_vitec_domain_model_solution', $uid),
'products' => $this->relationMulti('tx_vitec_usecase_product_mm', 'tx_vitec_domain_model_product', $uid),
'categories' => $base['categories'],
@@ -129,6 +129,20 @@ final class UsecaseSerializer
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
if (str_contains((string)$fileReference->getMimeType(), 'svg')) {
// SVG: no processing/srcset — deliver the file as-is.
return [
'uid' => (int)$ref['uid'],
'url' => (string)$fileReference->getPublicUrl(),
'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''),
'description' => (string)($ref['description'] ?? ''),
'srcset' => [],
'properties' => ['mimeType' => $fileReference->getMimeType()],
];
}
$srcset = [];
foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions(
@@ -165,6 +179,114 @@ final class UsecaseSerializer
}
}
/**
* All FAL images of one field (multi-reference), same shape as image().
*
* @return array<int,array<string,mixed>>
*/
public function images(int $recordUid, string $fieldName, ?string $table = null): array
{
try {
$table = $table ?? self::TABLE;
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$refs = $qb
->select('sfr.uid')
->from('sys_file_reference', 'sfr')
->where(
$qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
$qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
$qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
$qb->expr()->eq('sfr.deleted', 0),
$qb->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->executeQuery()
->fetchAllAssociative();
$out = [];
foreach ($refs as $i => $r) {
// reuse the single-image resolver per reference position
$img = $this->imageByReferenceUid((int)$r['uid']);
if ($img !== null) {
$out[] = $img;
}
}
return $out;
} catch (\Throwable $e) {
return [];
}
}
/**
* Resolve one sys_file_reference uid to the standard image shape.
*
* @return array<string,mixed>|null
*/
private function imageByReferenceUid(int $refUid): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$ref = $qb
->select('uid', 'title', 'description', 'alternative', 'crop')
->from('sys_file_reference')
->where($qb->expr()->eq('uid', $qb->createNamedParameter($refUid, ParameterType::INTEGER)))
->executeQuery()
->fetchAssociative();
if (!$ref) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
if (str_contains((string)$fileReference->getMimeType(), 'svg')) {
// SVG: no processing/srcset — deliver the file as-is.
return [
'uid' => (int)$ref['uid'],
'url' => (string)$fileReference->getPublicUrl(),
'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''),
'description' => (string)($ref['description'] ?? ''),
'srcset' => [],
'properties' => ['mimeType' => $fileReference->getMimeType()],
];
}
$srcset = [];
foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $ref['crop'] ?? null]
);
$srcset[] = ['url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w'];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $ref['crop'] ?? null]
);
return [
'uid' => (int)$ref['uid'],
'url' => $imageService->getImageUri($default),
'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''),
'description' => (string)($ref['description'] ?? ''),
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Throwable $e) {
return null;
}
}
/**
* FAL video (public URL only). $table as in image().
*
@@ -367,10 +489,39 @@ final class UsecaseSerializer
*/
private function resolveInlineElement(array $row): ?array
{
if ((string)($row['CType'] ?? '') === self::COLUMNS_CTYPE) {
$ctype = (string)($row['CType'] ?? '');
if ($ctype === self::COLUMNS_CTYPE) {
return $this->resolveColumnsElement($row);
}
return ContentElementResolver::normaliseRecord($row);
if ($ctype === 'vitec_quotation') {
$uid = (int)$row['uid'];
$logoField = array_key_exists('vitec_quote_logo', $row) ? 'vitec_quote_logo' : 'quote_logo';
return [
'id' => $uid,
'type' => 'vitec_quotation',
'colPos' => (int)($row['colPos'] ?? 0),
'sorting' => (int)($row['sorting'] ?? 0),
'header' => (string)($row['header'] ?? ''),
'quote' => (string)($row['vitec_quote'] ?? $row['quote'] ?? ''),
'name' => (string)($row['vitec_quote_name'] ?? $row['quote_name'] ?? ''),
'position' => (string)($row['vitec_quote_position'] ?? $row['quote_position'] ?? ''),
'logo' => $this->image($uid, $logoField, 'tt_content'),
];
}
$el = ContentElementResolver::normaliseRecord($row);
if (is_array($el)) {
$uid = (int)$row['uid'];
$media = [];
foreach (['image', 'assets', 'media'] as $f) {
foreach ($this->images($uid, $f, 'tt_content') as $img) {
$media[] = $img;
}
}
if ($media !== []) {
$el['images'] = $media;
}
}
return $el;
}
/**
@@ -445,15 +596,12 @@ final class UsecaseSerializer
$uid = (int)$it['uid'];
$get = static fn (string $k): string => (string)($it['vitec_' . $k] ?? $it[$k] ?? '');
$imageField = array_key_exists('vitec_image', $it) ? 'vitec_image' : 'image';
$videoField = array_key_exists('vitec_video', $it) ? 'vitec_video' : 'video';
return [
'type' => $get('item_type') !== '' ? $get('item_type') : 'text',
'headline' => $get('headline'),
'text' => $get('text'),
'image' => $this->image($uid, $imageField, $table),
'video' => $this->video($uid, $videoField, $table),
'image' => $this->image($uid, 'vitec_image', $table) ?? $this->image($uid, 'image', $table),
'video' => $this->video($uid, 'vitec_video', $table) ?? $this->video($uid, 'video', $table),
'link' => $get('link'),
'linkLabel' => $get('link_label'),
];
@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* UserFunc: render the VITEC customer logos as JSON (headless).
*
* Output under content.customerlogos:
* { "layout": "list|grid|carousel|marquee", "logos": [ … ] }
*
* Selection logic:
* - no customers selected -> ALL logos, each with color:false (b/w)
* - customers selected -> the selected ones FIRST (selection order,
* color:true), followed by all remaining logos (color:false)
*
* `render()` = top-level plugin / page discovery. `renderForRecord()` = one
* specific tt_content row (reused by ContainerChildrenProcessor for nested
* plugins). Exception-safe.
*/
class CustomerlogosJsonRenderer
{
private const TABLE = 'tx_vitec_domain_model_customer';
private const IMAGE_WIDTHS = [200, 400];
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === 'vitec_customerlogos') {
return $this->renderForRecord($row);
}
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_customerlogos', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($ces)) {
return '';
}
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$layout = (string)($settings['layout'] ?? 'grid');
$debugMode = (bool)($settings['debug'] ?? false);
$selectedUids = GeneralUtility::intExplode(',', (string)($settings['customers'] ?? ''), true);
$onlySelected = (bool)($settings['onlySelected'] ?? false);
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb
->select('*')
->from(self::TABLE)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$byUid = [];
foreach ($rows as $r) {
$byUid[(int)$r['uid']] = $r;
}
// Selected customers first (selection order, in color), then the rest (b/w).
$logos = [];
foreach ($selectedUids as $uid) {
if (isset($byUid[$uid])) {
$logos[] = $this->serializeCustomer($byUid[$uid], true);
unset($byUid[$uid]);
}
}
if (!($onlySelected && $selectedUids !== [])) {
foreach ($byUid as $r) {
$logos[] = $this->serializeCustomer($r, false);
}
}
$response = [
'layout' => $layout,
'logos' => $logos,
];
if ($debugMode) {
$response['debug'] = [
'count' => count($logos),
'selected' => $selectedUids,
'settings' => $settings,
];
}
return (string)json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $r
* @return array<string,mixed>
*/
private function serializeCustomer(array $r, bool $color): array
{
return [
'id' => (int)$r['uid'],
'name' => (string)($r['title'] ?? ''),
'emphasized' => (bool)($r['emphasize_logo'] ?? false),
'color' => $color,
'logo' => $this->logo((int)$r['uid']),
];
}
/**
* FAL logo with small srcset; SVGs are delivered unprocessed.
*
* @return array<string,mixed>|null
*/
private function logo(int $customerUid): ?array
{
try {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$ref = $qb
->select('uid', 'title', 'alternative', 'crop')
->from('sys_file_reference')
->where(
$qb->expr()->eq('tablenames', $qb->createNamedParameter(self::TABLE, ParameterType::STRING)),
$qb->expr()->eq('fieldname', $qb->createNamedParameter('logo', ParameterType::STRING)),
$qb->expr()->eq('uid_foreign', $qb->createNamedParameter($customerUid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$ref) {
return null;
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
if (str_contains((string)$fileReference->getMimeType(), 'svg')) {
// SVG: no processing/srcset — deliver the file as-is.
return [
'uid' => (int)$ref['uid'],
'url' => (string)$fileReference->getPublicUrl(),
'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''),
'srcset' => [],
'properties' => ['mimeType' => $fileReference->getMimeType()],
];
}
$srcset = [];
foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $ref['crop'] ?? null]
);
$srcset[] = ['url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w'];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 400, 'crop' => $ref['crop'] ?? null]
);
return [
'uid' => (int)$ref['uid'],
'url' => $imageService->getImageUri($default),
'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''),
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Throwable $e) {
return null;
}
}
}
@@ -86,12 +86,14 @@ class EventlistJsonRenderer
$daysInAdvance = (int)($settings['daysinadvance'] ?? 0);
$limit = (int)($settings['limit'] ?? 0);
$debugMode = (bool)($settings['debug'] ?? false);
$layout = (string)($settings['layout'] ?? 'list');
$upcoming = $this->fetchEvents(true, $limit, $daysInAdvance);
$response = [
'events' => array_map(fn($e) => $this->serializeEvent($e), $upcoming),
'settings' => [
'layout' => $layout,
'showpast' => $showPast,
'daysinadvance' => $daysInAdvance,
'limit' => $limit,
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
/**
* UserFunc: render the VITEC locations as JSON (headless).
*
* Output under content.locations:
* { "variant": "grid|list|map", "mapText": "<p>…</p>"|null, "locations": [ … ] }
*
* Each location follows the frontend contract (id, slug, name, countryCode,
* coordinates, address, contact, links, marker, sorting, active).
*
* `render()` = top-level plugin / page discovery. `renderForRecord()` = one
* specific tt_content row (reused by ContainerChildrenProcessor for nested
* plugins). Exception-safe.
*/
class LocationsJsonRenderer
{
private const TABLE = 'tx_vitec_domain_model_location';
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === 'vitec_locationlist') {
return $this->renderForRecord($row);
}
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_locationlist', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($ces)) {
return '';
}
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$variant = (string)($settings['variant'] ?? 'grid');
$mapText = trim((string)($settings['maptext'] ?? ''));
$debugMode = (bool)($settings['debug'] ?? false);
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
$rows = $qb
->select('*')
->from(self::TABLE)
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$locations = array_map(fn(array $r): array => $this->serializeLocation($r), $rows);
$response = [
'variant' => $variant,
'mapText' => $variant === 'map' && $mapText !== '' ? $mapText : null,
'locations' => $locations,
];
if ($debugMode) {
$response['debug'] = ['count' => count($locations), 'settings' => $settings];
}
return (string)json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $r
* @return array<string,mixed>
*/
private function serializeLocation(array $r): array
{
$name = (string)($r['title'] ?? '');
return [
'id' => (int)$r['uid'],
'slug' => (string)($r['slug'] ?? ''),
'name' => $name,
'countryCode' => (string)($r['country_code'] ?? ''),
'coordinates' => [
'latitude' => (float)($r['latitude'] ?? 0),
'longitude' => (float)($r['longitude'] ?? 0),
],
'address' => [
'company' => $this->nullIfEmpty($r['address_company'] ?? ''),
'street' => $this->nullIfEmpty($r['street'] ?? ''),
'additional' => $this->nullIfEmpty($r['address_additional'] ?? ''),
'postalCode' => $this->nullIfEmpty($r['postal_code'] ?? ''),
'city' => $this->nullIfEmpty($r['city'] ?? ''),
'region' => $this->nullIfEmpty($r['region'] ?? ''),
'country' => $this->nullIfEmpty($r['country'] ?? ''),
],
'contact' => [
'phone' => $this->nullIfEmpty($r['phone'] ?? ''),
'fax' => $this->nullIfEmpty($r['fax'] ?? ''),
'email' => $this->nullIfEmpty($r['email'] ?? ''),
],
'links' => [
'contact' => $this->resolveLink((string)($r['contact_link'] ?? '')),
'legal' => $this->resolveLinkLines((string)($r['legal_links'] ?? '')),
],
'marker' => [
'label' => (string)($r['marker_label'] ?? '') !== '' ? (string)$r['marker_label'] : $name,
'color' => (string)($r['marker_color'] ?? '#ff6633'),
'size' => (float)($r['marker_size'] ?? 0.5),
],
'sorting' => (int)($r['sorting'] ?? 0),
'active' => true,
];
}
private function nullIfEmpty(mixed $value): ?string
{
$value = trim((string)$value);
return $value === '' ? null : $value;
}
/** Resolve a typolink parameter (t3://…, page uid, URL) to a URL/path. */
private function resolveLink(string $parameter): ?string
{
$parameter = trim($parameter);
if ($parameter === '') {
return null;
}
try {
$cObj = GeneralUtility::makeInstance(ContentObjectRenderer::class);
$url = $cObj->typoLink_URL(['parameter' => $parameter]);
return $url !== '' ? $url : $parameter;
} catch (\Throwable $e) {
return $parameter;
}
}
/** @return array<int,string> */
private function resolveLinkLines(string $lines): array
{
$out = [];
foreach (preg_split('/\r\n|\r|\n/', $lines) ?: [] as $line) {
$resolved = $this->resolveLink($line);
if ($resolved !== null) {
$out[] = $resolved;
}
}
return $out;
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Service\UsecaseSerializer;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* UserFunc: render the VITEC Card plugin as JSON (headless).
*
* ONE plugin for four card types — the FlexForm picks the model type
* (product | story | market | solution) plus one record; ALL card data
* comes from the model. Output under content.card:
*
* { "modelType": "product", "layout": "vertical", "item": { … } }
*
* Image resolution is delegated to UsecaseSerializer::image() (generic,
* srcset + SVG-safe). `render()` = top-level plugin / page discovery,
* `renderForRecord()` = one tt_content row (container-nestable).
* Exception-safe.
*/
class ModelcardJsonRenderer
{
private const MODEL_TABLES = [
'product' => 'tx_vitec_domain_model_product',
'story' => 'tx_vitec_domain_model_usecase',
'market' => 'tx_vitec_domain_model_market',
'solution' => 'tx_vitec_domain_model_solution',
];
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && (string)($row['CType'] ?? '') === 'vitec_modelcard') {
return $this->renderForRecord($row);
}
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->eq('CType', $qb->createNamedParameter('vitec_modelcard', ParameterType::STRING)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($ces)) {
return '';
}
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$modelType = (string)($settings['modelType'] ?? 'product');
$layout = (string)($settings['layout'] ?? 'vertical');
$debugMode = (bool)($settings['debug'] ?? false);
$recordUid = (int)($settings[$modelType === 'story' ? 'story' : $modelType] ?? 0);
$item = null;
if ($recordUid > 0 && isset(self::MODEL_TABLES[$modelType])) {
$row = $this->fetchRecord(self::MODEL_TABLES[$modelType], $recordUid);
if ($row !== null) {
$item = $this->serializeItem($modelType, $row);
}
}
$response = [
'modelType' => $modelType,
'layout' => $layout,
'item' => $item,
];
if ($debugMode) {
$response['debug'] = ['recordUid' => $recordUid, 'settings' => $settings];
}
return (string)json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @return array<string,mixed>|null
*/
private function fetchRecord(string $table, int $uid): ?array
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$row = $qb
->select('*')
->from($table)
->where(
$qb->expr()->eq('uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
return $row ?: null;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function serializeItem(string $modelType, array $row): array
{
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
$uid = (int)$row['uid'];
if ($modelType === 'story') {
// Success stories already have the canonical card serialisation.
return $serializer->serializeListItem($row);
}
if ($modelType === 'product') {
return [
'uid' => $uid,
'title' => (string)($row['title'] ?? ''),
'slug' => (string)($row['slug'] ?? ''),
'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''),
'image' => $serializer->image($uid, 'image', 'tx_vitec_domain_model_product')
?? $serializer->image($uid, 'productimage', 'tx_vitec_domain_model_product'),
];
}
// market | solution — identical field set.
return [
'uid' => $uid,
'title' => (string)($row['title'] ?? ''),
'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''),
'description' => (string)($row['description'] ?? ''),
'image' => $serializer->image($uid, 'image', self::MODEL_TABLES[$modelType]),
];
}
}
@@ -11,6 +11,7 @@ use Evomedien\Vitec\Domain\Repository\ProductRepository;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Resource\ResourceFactory;
@@ -406,15 +407,61 @@ class ProductListJsonRenderer
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return array_map(function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
'parents' => $this->getCategoryParents((int)$cat['uid']),
];
}, $categories);
}
/** @var array<int,array{parent:int,title:string}>|null Lazy uid => [parent,title] map of all categories. */
private ?array $categoryTreeMap = null;
/**
* All ancestor categories of one category, ROOT FIRST (excluding itself).
* Backed by a once-per-request map of sys_category — cheap for any number
* of products. Cycle-safe.
*
* @return array<int,array{uid:int,title:string}>
*/
private function getCategoryParents(int $categoryUid): array
{
if ($this->categoryTreeMap === null) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $qb
->select('uid', 'parent', 'title')
->from('sys_category')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
$this->categoryTreeMap = [];
foreach ($rows as $row) {
$this->categoryTreeMap[(int)$row['uid']] = [
'parent' => (int)$row['parent'],
'title' => (string)$row['title'],
];
}
}
$parents = [];
$seen = [$categoryUid => true];
$current = $this->categoryTreeMap[$categoryUid]['parent'] ?? 0;
while ($current > 0 && isset($this->categoryTreeMap[$current]) && !isset($seen[$current])) {
$seen[$current] = true;
array_unshift($parents, ['uid' => $current, 'title' => $this->categoryTreeMap[$current]['title']]);
$current = $this->categoryTreeMap[$current]['parent'];
}
return $parents;
}
private function getDownloadFileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -493,10 +493,56 @@ class ProductShowJsonRenderer
'uid' => (int)$cat['uid'],
'title' => $cat['title'],
'description' => $cat['description'] ?? '',
'parents' => $this->getCategoryParents((int)$cat['uid']),
];
}, $categories);
}
/** @var array<int,array{parent:int,title:string}>|null Lazy uid => [parent,title] map of all categories. */
private ?array $categoryTreeMap = null;
/**
* All ancestor categories of one category, ROOT FIRST (excluding itself).
* Backed by a once-per-request map of sys_category — cheap for any number
* of products. Cycle-safe.
*
* @return array<int,array{uid:int,title:string}>
*/
private function getCategoryParents(int $categoryUid): array
{
if ($this->categoryTreeMap === null) {
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$rows = $qb
->select('uid', 'parent', 'title')
->from('sys_category')
->where(
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
$this->categoryTreeMap = [];
foreach ($rows as $row) {
$this->categoryTreeMap[(int)$row['uid']] = [
'parent' => (int)$row['parent'],
'title' => (string)$row['title'],
];
}
}
$parents = [];
$seen = [$categoryUid => true];
$current = $this->categoryTreeMap[$categoryUid]['parent'] ?? 0;
while ($current > 0 && isset($this->categoryTreeMap[$current]) && !isset($seen[$current])) {
$seen[$current] = true;
array_unshift($parents, ['uid' => $current, 'title' => $this->categoryTreeMap[$current]['title']]);
$current = $this->categoryTreeMap[$current]['parent'];
}
return $parents;
}
private function getDownloadFileType(int $downloadUid): string
{
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -72,6 +72,22 @@ class UsecaseListJsonRenderer
public function renderForRecord(array $contentElement): string
{
try {
// Detail context: when the request carries a story argument the
// list stays silent — the usecaseshow plugin on the same page
// renders the story. Keeps old SEO URLs on one page.
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
$routing = $request?->getAttribute('routing');
$detailParam = null;
if ($routing instanceof \TYPO3\CMS\Core\Routing\PageArguments) {
$detailParam = $routing->getRouteArguments()['tx_vitec_usecaseshow']['usecase'] ?? null;
}
if ($detailParam === null || $detailParam === '') {
$detailParam = ($request?->getQueryParams() ?? [])['tx_vitec_usecaseshow']['usecase'] ?? null;
}
if ($detailParam !== null && $detailParam !== '') {
return '';
}
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
@@ -92,8 +108,28 @@ class UsecaseListJsonRenderer
->fetchAllAssociative();
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
// Detail links: the PUBLIC URL is the PARENT path of the
// FlexForm-selected "Single PID" page plus the story slug —
// /success-stories/<slug>, not /success-stories/story/<slug>.
// The SuccessStoryPathRewrite middleware maps it back to the
// detail subpage at request time.
$singlePid = (int)($settings['singlePid'] ?? 0);
$detailBase = '';
if ($singlePid > 0) {
$detailPath = rtrim($this->resolvePageUrl($singlePid), '/');
$parent = str_contains($detailPath, '/') ? substr($detailPath, 0, (int)strrpos($detailPath, '/')) : '';
$detailBase = $parent !== '' ? $parent : $detailPath;
}
$usecases = array_map(
static fn (array $u): array => $serializer->serializeListItem($u),
static function (array $u) use ($serializer, $detailBase): array {
$item = $serializer->serializeListItem($u);
$item['detailUrl'] = ($detailBase !== '' && ($item['slug'] ?? '') !== '')
? $detailBase . '/' . ltrim((string)$item['slug'], '/')
: null;
return $item;
},
$rows
);
@@ -109,4 +145,15 @@ class UsecaseListJsonRenderer
return '';
}
}
/** Resolve a page uid to its frontend path (route enhancer aware). */
private function resolvePageUrl(int $pageUid): string
{
try {
$cObj = GeneralUtility::makeInstance(\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::class);
return (string)$cObj->typoLink_URL(['parameter' => (string)$pageUid]);
} catch (\Throwable $e) {
return '';
}
}
}
@@ -77,16 +77,21 @@ class UsecaseShowJsonRenderer
$layout = (string)($settings['layout'] ?? 'default');
$debugMode = (bool)($settings['debug'] ?? false);
$usecaseUid = (int)($settings['usecase'] ?? 0);
// Fallback: uid/slug from the request (React detail route).
if (!$usecaseUid) {
$params = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
$param = $params['tx_vitec_usecaseshow']['usecase'] ?? null;
if ($param !== null && $param !== '') {
$usecaseUid = is_numeric($param) ? (int)$param : $this->resolveSlug((string)$param);
}
// The story is resolved from the request ONLY (detail route from the
// list plugin's detailUrl): route-enhancer arguments live in the
// `routing` PageArguments, plain GET in query params.
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
$param = null;
$routing = $request?->getAttribute('routing');
if ($routing instanceof \TYPO3\CMS\Core\Routing\PageArguments) {
$param = $routing->getRouteArguments()['tx_vitec_usecaseshow']['usecase'] ?? null;
}
if ($param === null || $param === '') {
$param = ($request?->getQueryParams() ?? [])['tx_vitec_usecaseshow']['usecase'] ?? null;
}
$usecaseUid = ($param !== null && $param !== '')
? (is_numeric($param) ? (int)$param : $this->resolveSlug((string)$param))
: 0;
if (!$usecaseUid) {
return $debugMode
@@ -114,9 +119,11 @@ class UsecaseShowJsonRenderer
$serializer = GeneralUtility::makeInstance(UsecaseSerializer::class);
$backPid = (int)($settings['backPid'] ?? 0);
$response = [
'usecase' => $serializer->serializeDetail($usecase),
'layout' => $layout,
'backUrl' => $backPid > 0 ? ($this->resolvePageUrl($backPid) ?: null) : null,
];
if ($debugMode) {
@@ -145,4 +152,15 @@ class UsecaseShowJsonRenderer
return (int)($row['uid'] ?? 0);
}
/** Resolve a page uid to its frontend path (route enhancer aware). */
private function resolvePageUrl(int $pageUid): string
{
try {
$cObj = GeneralUtility::makeInstance(\TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer::class);
return (string)$cObj->typoLink_URL(['parameter' => (string)$pageUid]);
} catch (\Throwable $e) {
return '';
}
}
}