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:
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
@@ -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
@@ -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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user