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,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 '';
}
}
}