Zwischenstand vom 29.05.2026

This commit is contained in:
khaccount
2026-05-29 11:14:02 +02:00
parent b6d2142214
commit dba154e572
36 changed files with 5113 additions and 649 deletions

698
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller;
use Evomedien\Vitec\Service\OgImageGeneratorService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Backend module controller for OG Image generation.
*/
#[AsController]
final class OgImageController
{
public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly OgImageGeneratorService $ogImageGeneratorService,
private readonly FlashMessageService $flashMessageService,
private readonly PageRenderer $pageRenderer,
private readonly UriBuilder $uriBuilder,
) {}
/**
* Renders the OG image builder form.
*/
public function indexAction(ServerRequestInterface $request): ResponseInterface
{
$this->pageRenderer->addJsFile(
'EXT:vitec/Resources/Public/Javascript/og-image-preview.js'
);
$moduleTemplate = $this->moduleTemplateFactory->create($request);
$moduleTemplate->assignMultiple([
'formData' => $this->getDefaultFormData(),
'savedImages' => $this->ogImageGeneratorService->getSavedImages(),
'presets' => $this->getPresets(),
'backgroundImages' => $this->ogImageGeneratorService->getBackgroundImages(),
]);
return $moduleTemplate->renderResponse('OgImage/Index');
}
/**
* Handles form submission and generates the OG image.
*/
public function generateAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$formData = $parsedBody['ogimage'] ?? [];
[$success, $message, $filePath] = $this->ogImageGeneratorService->generate($formData);
$severity = $success
? ContextualFeedbackSeverity::OK
: ContextualFeedbackSeverity::ERROR;
$flashMessage = GeneralUtility::makeInstance(
FlashMessage::class,
$message,
$success ? 'OG Image created' : 'Generation failed',
$severity,
true
);
$this->flashMessageService
->getMessageQueueByIdentifier()
->addMessage($flashMessage);
$moduleUrl = (string)$this->uriBuilder->buildUriFromRoute('web_vitecogimage');
return new RedirectResponse($moduleUrl, 303);
}
/**
* Returns a base64 PNG preview without saving to disk (AJAX).
*/
public function previewAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$formData = $parsedBody['ogimage'] ?? [];
[$success, $message, $dataUri] = $this->ogImageGeneratorService->generatePreview($formData);
return new JsonResponse([
'success' => $success,
'message' => $message,
'dataUri' => $dataUri,
]);
}
// ──────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────
private function getDefaultFormData(): array
{
return [
'bg_type' => 'color',
'bg_color' => '#1a1a2e',
'bg_image' => '',
'overlay_opacity' => 40,
'title' => 'Your Page Title',
'title_color' => '#ffffff',
'title_size' => 64,
'subtitle' => 'A short description of your page',
'subtitle_color' => '#cccccc',
'subtitle_size' => 32,
'label_text' => 'VITEC',
'label_bg_color' => '#e63946',
'label_text_color' => '#ffffff',
'label_position' => 'top-left',
'logo_path' => '',
'output_format' => 'jpg',
'output_quality' => 90,
];
}
private function getPresets(): array
{
return [
'dark-blue' => [
'label' => 'Dark Blue',
'bg_color' => '#1a1a2e',
'title_color' => '#ffffff',
'subtitle_color' => '#aaaacc',
],
'light' => [
'label' => 'Clean Light',
'bg_color' => '#f8f9fa',
'title_color' => '#212529',
'subtitle_color' => '#6c757d',
],
'brand-red' => [
'label' => 'VITEC Brand Red',
'bg_color' => '#1f1f1f',
'title_color' => '#ffffff',
'subtitle_color' => '#e63946',
],
];
}
}

View File

@@ -3,6 +3,10 @@ declare(strict_types=1);
namespace Evomedien\Vitec\DataProcessing; namespace Evomedien\Vitec\DataProcessing;
use Evomedien\Vitec\UserFunc\ProductListJsonRenderer;
use Evomedien\Vitec\UserFunc\ProductShowJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer;
use Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer;
use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -15,6 +19,11 @@ use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
* { id, type, colPos, sorting, appearance, data } * { id, type, colPos, sorting, appearance, data }
* `data` only contains non-empty content-relevant fields. * `data` only contains non-empty content-relevant fields.
* *
* VITEC list-plugins nested as container children (productlist, productshow,
* usecaselist, usecaseshow) are resolved to the SAME JSON the top-level
* headless rendering produces, injected into `data` under their respective
* key. The raw pi_flexform XML is then dropped from `data`.
*
* Exception-safe. * Exception-safe.
*/ */
final class ContainerChildrenProcessor implements DataProcessorInterface final class ContainerChildrenProcessor implements DataProcessorInterface
@@ -27,7 +36,6 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
/** Technical / system / TCA-default fields — never sent to frontend. */ /** Technical / system / TCA-default fields — never sent to frontend. */
private const SYSTEM_FIELDS = [ private const SYSTEM_FIELDS = [
// versioning / language / workspace / housekeeping
'pid', 'sys_language_uid', 'l18n_parent', 'l18n_diffsource', 'pid', 'sys_language_uid', 'l18n_parent', 'l18n_diffsource',
'l10n_source', 'l10n_state', 'l10n_parent', 'l10n_source', 'l10n_state', 'l10n_parent',
't3_origuid', 'tx_impexp_origuid', 't3_origuid', 'tx_impexp_origuid',
@@ -38,8 +46,6 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
't3ver_id', 't3ver_label', 't3ver_count', 't3ver_tstamp', 't3ver_id', 't3ver_label', 't3ver_count', 't3ver_tstamp',
'editlock', 'sorting_foreign', 'rowDescription', 'editlock', 'sorting_foreign', 'rowDescription',
'spaceBefore', 'spaceAfter', // legacy 'spaceBefore', 'spaceAfter', // legacy
// TCA defaults that TYPO3 always sets on every tt_content row,
// regardless of CType — rarely relevant to the frontend:
'imagecols', 'sectionIndex', 'linkToTop', 'recursive', 'date', 'imagecols', 'sectionIndex', 'linkToTop', 'recursive', 'date',
'bullets_type', 'cols', 'bullets_type', 'cols',
'table_delimiter', 'table_enclosure', 'table_header_position', 'table_delimiter', 'table_enclosure', 'table_header_position',
@@ -53,6 +59,19 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
'header_layout', 'header_layout',
]; ];
/**
* Nested VITEC list-plugins: list_type => [ rendererClass, jsonKey ].
* The renderer's renderForRecord() is invoked with the child's own
* tt_content row so the result is record-accurate (works with multiple
* containers / plugins on the same page).
*/
private const PLUGIN_RENDERERS = [
'vitec_productlist' => [ProductListJsonRenderer::class, 'products'],
'vitec_productshow' => [ProductShowJsonRenderer::class, 'product'],
'vitec_usecaselist' => [UsecaseListJsonRenderer::class, 'usecases'],
'vitec_usecaseshow' => [UsecaseShowJsonRenderer::class, 'usecase'],
];
public function process( public function process(
ContentObjectRenderer $cObj, ContentObjectRenderer $cObj,
array $contentObjectConfiguration, array $contentObjectConfiguration,
@@ -124,6 +143,9 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
$data[$field] = $this->castValue($field, $value); $data[$field] = $this->castValue($field, $value);
} }
// Resolve nested VITEC list-plugins to their headless JSON.
$this->resolvePluginData($record, $data);
return [ return [
'id' => (int)$record['uid'], 'id' => (int)$record['uid'],
'type' => (string)$record['CType'], 'type' => (string)$record['CType'],
@@ -139,6 +161,48 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
]; ];
} }
/**
* If the child is a VITEC list-plugin, run its JSON renderer for THIS
* record and inject the decoded result under its key. Drops the raw
* pi_flexform XML afterwards. Never throws.
*
* @param array<string,mixed> $record
* @param array<string,mixed> $data
*/
private function resolvePluginData(array $record, array &$data): void
{
$listType = (string)($record['list_type'] ?? '');
if ($listType === '' || !isset(self::PLUGIN_RENDERERS[$listType])) {
return;
}
try {
[$rendererClass, $jsonKey] = self::PLUGIN_RENDERERS[$listType];
$renderer = GeneralUtility::makeInstance($rendererClass);
if (!method_exists($renderer, 'renderForRecord')) {
return;
}
$json = $renderer->renderForRecord($record);
if ($json === '' || $json === null) {
return;
}
$decoded = json_decode($json, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
return;
}
$data[$jsonKey] = $decoded;
// The raw FlexForm XML is noise once the plugin is resolved.
unset($data['pi_flexform']);
} catch (\Throwable $e) {
// Leave the raw data untouched on any failure.
}
}
private function isEmpty(mixed $value): bool private function isEmpty(mixed $value): bool
{ {
return $value === null return $value === null

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\DataProcessing;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\ContentObject\DataProcessorInterface;
/**
* Collect container children grouped by colPos and emit a lean, transport-
* ready structure. Each child is normalised to:
* { id, type, colPos, sorting, appearance, data }
* `data` only contains non-empty content-relevant fields.
*
* Exception-safe.
*/
final class ContainerChildrenProcessor implements DataProcessorInterface
{
/** Fields that go to the envelope (not to `data`). */
private const ENVELOPE = [
'uid', 'CType', 'colPos', 'sorting',
'layout', 'frame_class', 'space_before_class', 'space_after_class',
];
/** Technical / system / TCA-default fields — never sent to frontend. */
private const SYSTEM_FIELDS = [
// versioning / language / workspace / housekeeping
'pid', 'sys_language_uid', 'l18n_parent', 'l18n_diffsource',
'l10n_source', 'l10n_state', 'l10n_parent',
't3_origuid', 'tx_impexp_origuid',
'tx_container_parent',
'tstamp', 'crdate', 'cruser_id',
'hidden', 'deleted', 'starttime', 'endtime', 'fe_group',
't3ver_oid', 't3ver_wsid', 't3ver_state', 't3ver_stage',
't3ver_id', 't3ver_label', 't3ver_count', 't3ver_tstamp',
'editlock', 'sorting_foreign', 'rowDescription',
'spaceBefore', 'spaceAfter', // legacy
// TCA defaults that TYPO3 always sets on every tt_content row,
// regardless of CType — rarely relevant to the frontend:
'imagecols', 'sectionIndex', 'linkToTop', 'recursive', 'date',
'bullets_type', 'cols',
'table_delimiter', 'table_enclosure', 'table_header_position',
'table_tfoot', 'table_caption',
'filelink_size', 'filelink_sorting', 'filelink_sorting_direction',
'uploads_description', 'uploads_type',
];
/** Fields whose 0/empty value is still meaningful. */
private const KEEP_IF_ZERO = [
'header_layout',
];
public function process(
ContentObjectRenderer $cObj,
array $contentObjectConfiguration,
array $processorConfiguration,
array $processedData
): array {
$as = (string)($processorConfiguration['as'] ?? 'items');
try {
$parentUid = (int)($cObj->data['uid'] ?? 0);
if ($parentUid <= 0) {
$processedData[$as] = [];
return $processedData;
}
$pid = (int)($cObj->data['pid'] ?? 0);
$sysLanguageUid = (int)($cObj->data['sys_language_uid'] ?? 0);
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$rows = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('tx_container_parent', $qb->createNamedParameter($parentUid, Connection::PARAM_INT)),
$qb->expr()->eq('pid', $qb->createNamedParameter($pid, Connection::PARAM_INT)),
$qb->expr()->eq('sys_language_uid', $qb->createNamedParameter($sysLanguageUid, Connection::PARAM_INT))
)
->orderBy('colPos')
->addOrderBy('sorting')
->executeQuery()
->fetchAllAssociative();
$byColPos = [];
foreach ($rows as $record) {
$byColPos[(int)$record['colPos']][] = $this->normalise($record);
}
ksort($byColPos);
$items = [];
foreach ($byColPos as $colPos => $contentElements) {
$items[] = [
'config' => ['colPos' => $colPos],
'contentElements' => $contentElements,
];
}
$processedData[$as] = $items;
} catch (\Throwable $e) {
$processedData[$as] = [];
}
return $processedData;
}
private function normalise(array $record): array
{
$data = [];
foreach ($record as $field => $value) {
if (in_array($field, self::ENVELOPE, true)) {
continue;
}
if (in_array($field, self::SYSTEM_FIELDS, true)) {
continue;
}
if ($this->isEmpty($value) && !in_array($field, self::KEEP_IF_ZERO, true)) {
continue;
}
$data[$field] = $this->castValue($field, $value);
}
return [
'id' => (int)$record['uid'],
'type' => (string)$record['CType'],
'colPos' => (int)$record['colPos'],
'sorting' => (int)($record['sorting'] ?? 0),
'appearance' => [
'layout' => (string)($record['layout'] ?? ''),
'frameClass' => (string)($record['frame_class'] ?? 'default'),
'spaceBefore' => (string)($record['space_before_class'] ?? ''),
'spaceAfter' => (string)($record['space_after_class'] ?? ''),
],
'data' => (object)$data,
];
}
private function isEmpty(mixed $value): bool
{
return $value === null
|| $value === ''
|| $value === 0
|| $value === '0';
}
private function castValue(string $field, mixed $value): mixed
{
if (in_array($field, ['header_layout'], true)) {
return (int)$value;
}
return $value;
}
}

View File

@@ -0,0 +1,419 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Service;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Generates 1200×630 OG images using the PHP GD library.
*/
final class OgImageGeneratorService
{
private const OG_WIDTH = 1200;
private const OG_HEIGHT = 630;
/** Where generated images are stored relative to the web root */
private const SAVE_DIR = 'fileadmin/og-images/';
/** Font paths (ordered by preference) */
private const FONT_BOLD = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf';
private const FONT_REG = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
private const PADDING = 80;
// ──────────────────────────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────────────────────────
/**
* Generate and save an OG image.
*
* @return array{0: bool, 1: string, 2: string} [success, message, publicPath]
*/
public function generate(array $opts): array
{
try {
$gd = $this->buildImage($opts);
$saveDir = Environment::getPublicPath() . '/' . self::SAVE_DIR;
GeneralUtility::mkdir_deep($saveDir);
$hashSeed = json_encode($opts, JSON_THROW_ON_ERROR) . microtime(true) . random_int(1000, 9999);
$fileName = 'og-' . substr(sha1($hashSeed), 0, 12) . '.' . ($opts['output_format'] ?? 'jpg');
$fullPath = $saveDir . $fileName;
$this->saveImage($gd, $fullPath, $opts['output_format'] ?? 'jpg', (int)($opts['output_quality'] ?? 90));
imagedestroy($gd);
$publicUrl = '/' . self::SAVE_DIR . $fileName;
return [true, 'Image saved: ' . $publicUrl, $publicUrl];
} catch (\Throwable $e) {
return [false, 'Error: ' . $e->getMessage(), ''];
}
}
/**
* Generate a base64 data-URI preview (no file write).
*
* @return array{0: bool, 1: string, 2: string} [success, message, dataUri]
*/
public function generatePreview(array $opts): array
{
try {
$gd = $this->buildImage($opts);
ob_start();
imagepng($gd);
$raw = ob_get_clean();
imagedestroy($gd);
return [true, 'OK', 'data:image/png;base64,' . base64_encode($raw)];
} catch (\Throwable $e) {
return [false, $e->getMessage(), ''];
}
}
/**
* Return list of previously generated images in the save dir.
*/
public function getSavedImages(): array
{
$dir = Environment::getPublicPath() . '/' . self::SAVE_DIR;
if (!is_dir($dir)) {
return [];
}
$files = glob($dir . '*.{jpg,jpeg,png}', GLOB_BRACE) ?: [];
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
return array_map(static fn($f) => [
'path' => '/' . self::SAVE_DIR . basename($f),
'name' => basename($f),
'created' => date('Y-m-d H:i', filemtime($f)),
], array_slice($files, 0, 20));
}
/**
* Return selectable background images from fileadmin/og-backgrounds/.
*/
public function getBackgroundImages(): array
{
$dir = Environment::getPublicPath() . '/fileadmin/og-backgrounds/';
if (!is_dir($dir)) {
return [];
}
$files = glob($dir . '*.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [];
return array_map(static fn($f) => [
'path' => '/fileadmin/og-backgrounds/' . basename($f),
'label' => basename($f),
], $files);
}
// ──────────────────────────────────────────────────────────────────
// Core image builder
// ──────────────────────────────────────────────────────────────────
/** @return \GdImage */
private function buildImage(array $opts): \GdImage
{
$canvas = imagecreatetruecolor(self::OG_WIDTH, self::OG_HEIGHT);
if ($canvas === false) {
throw new \RuntimeException('imagecreatetruecolor failed GD not available.');
}
// Enable alpha blending
imagealphablending($canvas, true);
imagesavealpha($canvas, true);
// 1. Background
$this->drawBackground($canvas, $opts);
// 2. Optional dark overlay when bg_image is set
if (!empty($opts['bg_image']) && ($opts['bg_type'] ?? '') === 'image') {
$opacity = max(0, min(100, (int)($opts['overlay_opacity'] ?? 40)));
$this->drawOverlay($canvas, $opacity);
}
// 3. Label / badge
if (!empty(trim($opts['label_text'] ?? ''))) {
$this->drawLabel($canvas, $opts);
}
// 4. Title
if (!empty(trim($opts['title'] ?? ''))) {
$this->drawTitle($canvas, $opts);
}
// 5. Subtitle
if (!empty(trim($opts['subtitle'] ?? ''))) {
$this->drawSubtitle($canvas, $opts);
}
// 6. Logo (optional)
if (!empty($opts['logo_path'])) {
$this->drawLogo($canvas, $opts['logo_path']);
}
return $canvas;
}
// ──────────────────────────────────────────────────────────────────
// Drawing helpers
// ──────────────────────────────────────────────────────────────────
private function drawBackground(\GdImage $canvas, array $opts): void
{
$bgType = $opts['bg_type'] ?? 'color';
if ($bgType === 'image' && !empty($opts['bg_image'])) {
$imagePath = $this->resolvePath($opts['bg_image']);
$src = $this->loadImageFromPath($imagePath);
if ($src !== null) {
imagecopyresampled(
$canvas, $src,
0, 0, 0, 0,
self::OG_WIDTH, self::OG_HEIGHT,
imagesx($src), imagesy($src)
);
imagedestroy($src);
return;
}
}
// Solid colour fallback
$hex = ltrim($opts['bg_color'] ?? '#1a1a2e', '#');
[$r, $g, $b] = $this->hexToRgb($hex);
$bg = imagecolorallocate($canvas, $r, $g, $b);
imagefilledrectangle($canvas, 0, 0, self::OG_WIDTH - 1, self::OG_HEIGHT - 1, $bg);
}
private function drawOverlay(\GdImage $canvas, int $opacityPercent): void
{
// opacity 0 = fully transparent overlay, 100 = solid black
$alpha = (int)round(127 - ($opacityPercent / 100 * 127));
$color = imagecolorallocatealpha($canvas, 0, 0, 0, $alpha);
imagefilledrectangle($canvas, 0, 0, self::OG_WIDTH - 1, self::OG_HEIGHT - 1, $color);
}
private function drawLabel(\GdImage $canvas, array $opts): void
{
$text = strtoupper(trim($opts['label_text'] ?? 'LABEL'));
$fontSize = 22;
$font = $this->fontPath(true);
$padding = 14;
$bbox = imagettfbbox($fontSize, 0, $font, $text);
$textW = abs($bbox[4] - $bbox[0]);
$textH = abs($bbox[5] - $bbox[1]);
$boxW = $textW + $padding * 2;
$boxH = $textH + $padding;
$position = $opts['label_position'] ?? 'top-left';
[$bx, $by] = $this->labelCoords($position, $boxW, $boxH);
[$br, $bg, $bb] = $this->hexToRgb(ltrim($opts['label_bg_color'] ?? '#e63946', '#'));
$bgColor = imagecolorallocate($canvas, $br, $bg, $bb);
imagefilledrectangle($canvas, $bx, $by, $bx + $boxW, $by + $boxH, $bgColor);
[$tr, $tg, $tb] = $this->hexToRgb(ltrim($opts['label_text_color'] ?? '#ffffff', '#'));
$textColor = imagecolorallocate($canvas, $tr, $tg, $tb);
imagettftext($canvas, $fontSize, 0, $bx + $padding, $by + $textH + (int)($padding / 2), $textColor, $font, $text);
}
private function drawTitle(\GdImage $canvas, array $opts): void
{
$text = trim($opts['title'] ?? '');
$fontSize = max(20, min(120, (int)($opts['title_size'] ?? 64)));
$font = $this->fontPath(true);
$maxWidth = self::OG_WIDTH - self::PADDING * 2;
[$r, $g, $b] = $this->hexToRgb(ltrim($opts['title_color'] ?? '#ffffff', '#'));
$color = imagecolorallocate($canvas, $r, $g, $b);
$lines = $this->wrapText($text, $fontSize, $font, $maxWidth);
$lineH = (int)($fontSize * 1.3);
$startY = $this->titleStartY($opts, count($lines), $lineH, $fontSize);
foreach ($lines as $i => $line) {
$y = $startY + $i * $lineH;
imagettftext($canvas, $fontSize, 0, self::PADDING, $y, $color, $font, $line);
}
}
private function drawSubtitle(\GdImage $canvas, array $opts): void
{
$text = trim($opts['subtitle'] ?? '');
$fontSize = max(14, min(80, (int)($opts['subtitle_size'] ?? 32)));
$font = $this->fontPath(false);
$maxWidth = self::OG_WIDTH - self::PADDING * 2;
[$r, $g, $b] = $this->hexToRgb(ltrim($opts['subtitle_color'] ?? '#cccccc', '#'));
$color = imagecolorallocate($canvas, $r, $g, $b);
$lines = $this->wrapText($text, $fontSize, $font, $maxWidth);
$lineH = (int)($fontSize * 1.4);
$startY = $this->subtitleStartY($opts, $fontSize);
foreach ($lines as $i => $line) {
$y = $startY + $i * $lineH;
imagettftext($canvas, $fontSize, 0, self::PADDING, $y, $color, $font, $line);
}
}
private function drawLogo(\GdImage $canvas, string $logoPath): void
{
$path = $this->resolvePath($logoPath);
$src = $this->loadImageFromPath($path);
if ($src === null) {
return;
}
$logoW = 200;
$logoH = (int)(imagesy($src) * ($logoW / imagesx($src)));
$x = self::OG_WIDTH - self::PADDING - $logoW;
$y = self::OG_HEIGHT - self::PADDING - $logoH;
imagecopyresampled($canvas, $src, $x, $y, 0, 0, $logoW, $logoH, imagesx($src), imagesy($src));
imagedestroy($src);
}
// ──────────────────────────────────────────────────────────────────
// Layout helpers
// ──────────────────────────────────────────────────────────────────
private function titleStartY(array $opts, int $lineCount, int $lineH, int $fontSize): int
{
$totalH = $lineCount * $lineH;
$subH = empty(trim($opts['subtitle'] ?? '')) ? 0 : (int)($opts['subtitle_size'] ?? 32) + 20;
$block = $totalH + $subH;
$center = (int)((self::OG_HEIGHT - $block) / 2);
// nudge up slightly so text block feels centered
return max(self::PADDING + $fontSize, $center);
}
private function subtitleStartY(array $opts, int $fontSize): int
{
$titleSize = max(20, min(120, (int)($opts['title_size'] ?? 64)));
$titleText = trim($opts['title'] ?? '');
$font = $this->fontPath(true);
$maxWidth = self::OG_WIDTH - self::PADDING * 2;
$titleLines = $this->wrapText($titleText, $titleSize, $font, $maxWidth);
$titleLineH = (int)($titleSize * 1.3);
$titleBlock = count($titleLines) * $titleLineH;
$subH = empty(trim($opts['subtitle'] ?? '')) ? 0 : $fontSize + 20;
$block = $titleBlock + $subH;
$center = (int)((self::OG_HEIGHT - $block) / 2);
$topOfTitle = max(self::PADDING + $titleSize, $center);
return $topOfTitle + $titleBlock + 20;
}
private function labelCoords(string $position, int $w, int $h): array
{
$pad = self::PADDING;
return match ($position) {
'top-right' => [self::OG_WIDTH - $pad - $w, $pad],
'bottom-left' => [$pad, self::OG_HEIGHT - $pad - $h],
'bottom-right' => [self::OG_WIDTH - $pad - $w, self::OG_HEIGHT - $pad - $h],
default => [$pad, $pad], // top-left
};
}
// ──────────────────────────────────────────────────────────────────
// Utility helpers
// ──────────────────────────────────────────────────────────────────
/** Wrap text to fit within $maxWidth px */
private function wrapText(string $text, int $fontSize, string $font, int $maxWidth): array
{
$words = explode(' ', $text);
$lines = [];
$current = '';
foreach ($words as $word) {
$test = $current !== '' ? "$current $word" : $word;
$bbox = imagettfbbox($fontSize, 0, $font, $test);
$w = abs($bbox[4] - $bbox[0]);
if ($w > $maxWidth && $current !== '') {
$lines[] = $current;
$current = $word;
} else {
$current = $test;
}
}
if ($current !== '') {
$lines[] = $current;
}
return $lines ?: [''];
}
private function fontPath(bool $bold): string
{
$path = $bold ? self::FONT_BOLD : self::FONT_REG;
if (!file_exists($path)) {
$path = self::FONT_REG; // fallback to regular
}
if (!file_exists($path)) {
throw new \RuntimeException("TTF font not found at $path");
}
return $path;
}
private function hexToRgb(string $hex): array
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
return [
hexdec(substr($hex, 0, 2)),
hexdec(substr($hex, 2, 2)),
hexdec(substr($hex, 4, 2)),
];
}
private function resolvePath(string $path): string
{
if (str_starts_with($path, '/')) {
return Environment::getPublicPath() . $path;
}
return Environment::getPublicPath() . '/' . ltrim($path, '/');
}
/** @return \GdImage|null */
private function loadImageFromPath(string $path): ?\GdImage
{
if (!file_exists($path)) {
return null;
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return match ($ext) {
'jpg', 'jpeg' => imagecreatefromjpeg($path) ?: null,
'png' => imagecreatefrompng($path) ?: null,
'gif' => imagecreatefromgif($path) ?: null,
'webp' => imagecreatefromwebp($path) ?: null,
default => null,
};
}
private function saveImage(\GdImage $gd, string $path, string $format, int $quality): void
{
match (strtolower($format)) {
'png' => imagepng($gd, $path, max(0, min(9, (int)(9 - $quality / 11)))),
default => imagejpeg($gd, $path, $quality),
};
}
}

View File

@@ -16,23 +16,19 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Extbase\Service\ImageService;
/** /**
* UserFunc to render product list as JSON for headless * UserFunc to render product list as JSON for headless.
*
* `render()` performs page discovery (used for top-level list plugins via
* TypoScript). `renderForRecord()` processes one specific tt_content row and
* is used both internally and by ContainerChildrenProcessor to resolve a
* product-list plugin nested inside a b13 container.
*/ */
class ProductListJsonRenderer class ProductListJsonRenderer
{ {
public function render(string $content, array $conf): string public function render(string $content, array $conf): string
{ {
// IMPORTANT: Headless creates new cObj contexts when rendering JSON fields,
// losing the current content element context. Also, Extbase repositories don't work
// in UserFunc context because the full Extbase framework isn't bootstrapped.
//
// Solution: Query tt_content directly to find the plugin configuration,
// then use direct database queries for products instead of Extbase repositories.
// Get current page UID
$pageId = (int)($GLOBALS['TSFE']->id ?? 0); $pageId = (int)($GLOBALS['TSFE']->id ?? 0);
// Query tt_content for vitec_productlist on this page
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tt_content'); ->getQueryBuilderForTable('tt_content');
@@ -49,95 +45,93 @@ class ProductListJsonRenderer
->fetchAllAssociative(); ->fetchAllAssociative();
if (empty($contentElements)) { if (empty($contentElements)) {
return json_encode(['debug' => 'No vitec_productlist on page ' . $pageId]); // Not a product-list page: emit nothing so headless removes the key.
return '';
} }
// Take the first one (there should typically be only one) return $this->renderForRecord($contentElements[0]);
$contentElement = $contentElements[0]; }
// Parse FlexForm /**
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class); * Render exactly the given tt_content row (the product-list plugin element).
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? ''); * Exception-safe: returns '' on any failure.
$settings = $flexFormData['settings'] ?? []; *
* @param array<string,mixed> $contentElement tt_content row of the plugin
*/
public function renderForRecord(array $contentElement): string
{
try {
// Parse FlexForm of THIS element
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
// Extract category UIDs and debug setting $categoryUids = array_filter(
$categoryUids = array_filter( array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
);
$debugMode = (bool)($settings['debug'] ?? false);
$allProducts = (bool)($settings['allproducts'] ?? false);
// Extbase repositories don't work in UserFunc context
// Use direct database query instead
$productQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$productQuery = $productQueryBuilder
->select('p.*')
->from('tx_vitec_domain_model_product', 'p')
->where(
$productQueryBuilder->expr()->eq('p.deleted', 0),
$productQueryBuilder->expr()->eq('p.hidden', 0),
$productQueryBuilder->expr()->eq('p.legacy', 0),
$productQueryBuilder->expr()->eq('p.supportproduct', 0),
$productQueryBuilder->expr()->eq('p.hideonwebsite', 0),
$productQueryBuilder->expr()->eq('p.hideonproducts', 0),
$productQueryBuilder->expr()->eq('p.subproduct', 0)
); );
$debugMode = (bool)($settings['debug'] ?? false);
$allProducts = (bool)($settings['allproducts'] ?? false);
// Add category filter if specified and allProducts is not enabled // Extbase repositories don't work in UserFunc context — direct query.
if (!empty($categoryUids) && !$allProducts) { $productQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
// Join with sys_category_record_mm to filter by categories ->getQueryBuilderForTable('tx_vitec_domain_model_product');
$productQuery
->join( $productQuery = $productQueryBuilder
'p', ->select('p.*')
'sys_category_record_mm', ->from('tx_vitec_domain_model_product', 'p')
'mm', ->where(
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $productQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) . ' AND mm.fieldname = ' . $productQueryBuilder->createNamedParameter('categories', ParameterType::STRING) $productQueryBuilder->expr()->eq('p.deleted', 0),
) $productQueryBuilder->expr()->eq('p.hidden', 0),
->andWhere( $productQueryBuilder->expr()->eq('p.legacy', 0),
$productQueryBuilder->expr()->in('mm.uid_local', $productQueryBuilder->createNamedParameter($categoryUids, Connection::PARAM_INT_ARRAY)) $productQueryBuilder->expr()->eq('p.supportproduct', 0),
) $productQueryBuilder->expr()->eq('p.hideonwebsite', 0),
->groupBy('p.uid'); $productQueryBuilder->expr()->eq('p.hideonproducts', 0),
$productQueryBuilder->expr()->eq('p.subproduct', 0)
);
if (!empty($categoryUids) && !$allProducts) {
$productQuery
->join(
'p',
'sys_category_record_mm',
'mm',
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $productQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) . ' AND mm.fieldname = ' . $productQueryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->andWhere(
$productQueryBuilder->expr()->in('mm.uid_local', $productQueryBuilder->createNamedParameter($categoryUids, Connection::PARAM_INT_ARRAY))
)
->groupBy('p.uid');
}
$products = $productQuery->executeQuery()->fetchAllAssociative();
$productsData = [];
foreach ($products as $product) {
$productsData[] = $this->serializeProduct($product);
}
if ($debugMode) {
return json_encode([
'products' => $productsData,
'debug' => [
'pageId' => (int)($GLOBALS['TSFE']->id ?? 0),
'categoryUids' => $categoryUids,
'productCount' => count($productsData),
'settings' => $settings,
],
]);
}
return json_encode($productsData);
} catch (\Throwable $e) {
return '';
} }
$products = $productQuery->executeQuery()->fetchAllAssociative();
// Serialize products (they're already associative arrays from the query)
$productsData = [];
foreach ($products as $product) {
$productsData[] = $this->serializeProduct($product);
}
// If debug mode is enabled, return object with products and debug info
if ($debugMode) {
return json_encode([
'products' => $productsData,
'debug' => [
'pageId' => $pageId,
'categoryUids' => $categoryUids,
'productCount' => count($productsData),
'settings' => $settings
]
]);
}
// Otherwise return products array directly (backward compatible)
return json_encode($productsData);
} }
/** /**
* Serialize a single product DB row to the full headless JSON structure. * Serialize a single product DB row to the full headless JSON structure.
* *
* Includes every field declared on the Product domain model * @param array<string,mixed> $product
* (Evomedien\Vitec\Domain\Model\Product) plus all fully resolved
* relations (categories, productimage, downloads, ogimage, relatedprodukt).
*
* DB-only columns that are NOT part of the domain model
* (cta, links, sorting1-5, key1-3, apptext1-3, productlayout, image,
* relatedimage, system fields) are intentionally omitted.
*
* @param array<string,mixed> $product Associative DB row of tx_vitec_domain_model_product
* @return array<string,mixed> * @return array<string,mixed>
*/ */
protected function serializeProduct(array $product): array protected function serializeProduct(array $product): array
@@ -145,10 +139,8 @@ class ProductListJsonRenderer
$uid = (int)$product['uid']; $uid = (int)$product['uid'];
return [ return [
// --- identifier ---
'uid' => $uid, 'uid' => $uid,
// --- scalar string fields (Product domain model) ---
'title' => (string)($product['title'] ?? ''), 'title' => (string)($product['title'] ?? ''),
'slug' => (string)($product['slug'] ?? ''), 'slug' => (string)($product['slug'] ?? ''),
'urltitle' => (string)($product['urltitle'] ?? ''), 'urltitle' => (string)($product['urltitle'] ?? ''),
@@ -166,7 +158,6 @@ class ProductListJsonRenderer
'contentelement' => (string)($product['contentelement'] ?? ''), 'contentelement' => (string)($product['contentelement'] ?? ''),
'contentelementcta' => (string)($product['contentelementcta'] ?? ''), 'contentelementcta' => (string)($product['contentelementcta'] ?? ''),
// --- boolean flags (Product domain model) ---
'hideonapp' => (bool)($product['hideonapp'] ?? false), 'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false), 'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
'hideondatasheets' => (bool)($product['hideondatasheets'] ?? false), 'hideondatasheets' => (bool)($product['hideondatasheets'] ?? false),
@@ -176,10 +167,8 @@ class ProductListJsonRenderer
'supportproduct' => (bool)($product['supportproduct'] ?? false), 'supportproduct' => (bool)($product['supportproduct'] ?? false),
'subproduct' => (bool)($product['subproduct'] ?? false), 'subproduct' => (bool)($product['subproduct'] ?? false),
// --- convenience link (kept for backward compatibility) ---
'link' => '/product/' . (string)($product['slug'] ?? ''), 'link' => '/product/' . (string)($product['slug'] ?? ''),
// --- fully resolved relations ---
'categories' => $this->getProductCategories($uid), 'categories' => $this->getProductCategories($uid),
'images' => $this->getProductImages($uid), 'images' => $this->getProductImages($uid),
'downloads' => $this->getProductDownloads($uid), 'downloads' => $this->getProductDownloads($uid),
@@ -188,10 +177,6 @@ class ProductListJsonRenderer
]; ];
} }
/**
* Get product images from FAL (sys_file_reference)
* Processes images through ImageService and generates srcset for responsive images
*/
protected function getProductImages(int $productUid): array protected function getProductImages(int $productUid): array
{ {
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
@@ -217,10 +202,8 @@ class ProductListJsonRenderer
$images = []; $images = [];
foreach ($fileReferences as $fileRefData) { foreach ($fileReferences as $fileRefData) {
try { try {
// Get FAL FileReference object
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']); $fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
// Define image sizes for srcset
$sizes = [ $sizes = [
'small' => ['width' => 400, 'height' => null], 'small' => ['width' => 400, 'height' => null],
'medium' => ['width' => 800, 'height' => null], 'medium' => ['width' => 800, 'height' => null],
@@ -247,7 +230,6 @@ class ProductListJsonRenderer
]; ];
} }
// Get original/default image
$defaultProcessed = $imageService->applyProcessingInstructions( $defaultProcessed = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null] ['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
@@ -267,7 +249,6 @@ class ProductListJsonRenderer
] ]
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
// Skip images that can't be processed
continue; continue;
} }
} }
@@ -276,8 +257,6 @@ class ProductListJsonRenderer
} }
/** /**
* Get the single Open Graph image (ogimage) for a product, or null.
*
* @return array<string,mixed>|null * @return array<string,mixed>|null
*/ */
protected function getProductOgImage(int $productUid): ?array protected function getProductOgImage(int $productUid): ?array
@@ -331,9 +310,6 @@ class ProductListJsonRenderer
} }
} }
/**
* Get categories for a product (resolved sys_category records).
*/
protected function getProductCategories(int $productUid): array protected function getProductCategories(int $productUid): array
{ {
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
@@ -369,9 +345,6 @@ class ProductListJsonRenderer
}, $categories); }, $categories);
} }
/**
* Get all downloads for a product (resolved via tx_vitec_product_download_mm).
*/
protected function getProductDownloads(int $productUid): array protected function getProductDownloads(int $productUid): array
{ {
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
@@ -400,7 +373,6 @@ class ProductListJsonRenderer
foreach ($downloads as $download) { foreach ($downloads as $download) {
$fileInfo = null; $fileInfo = null;
// Get file information from FAL if file reference exists
if (!empty($download['file'])) { if (!empty($download['file'])) {
$fileQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class) $fileQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference'); ->getQueryBuilderForTable('sys_file_reference');
@@ -448,12 +420,6 @@ class ProductListJsonRenderer
return $result; return $result;
} }
/**
* Get related products (resolved via tx_vitec_product_related_mm).
*
* Returns a shallow representation (no nested relations) to avoid
* infinite recursion between mutually related products.
*/
protected function getRelatedProducts(int $productUid): array protected function getRelatedProducts(int $productUid): array
{ {
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
@@ -480,8 +446,6 @@ class ProductListJsonRenderer
$result = []; $result = [];
foreach ($related as $rel) { foreach ($related as $rel) {
$relUid = (int)$rel['uid']; $relUid = (int)$rel['uid'];
$images = $this->getProductImages($relUid);
$result[] = [ $result[] = [
'uid' => $relUid, 'uid' => $relUid,
'title' => (string)($rel['title'] ?? ''), 'title' => (string)($rel['title'] ?? ''),
@@ -490,7 +454,7 @@ class ProductListJsonRenderer
'teaser' => (string)($rel['teaser'] ?? ''), 'teaser' => (string)($rel['teaser'] ?? ''),
'description' => (string)($rel['description'] ?? ''), 'description' => (string)($rel['description'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''), 'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $images, 'images' => $this->getProductImages($relUid),
]; ];
} }

View File

@@ -0,0 +1,499 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Domain\Repository\ProductRepository;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Resource\FileReference;
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 to render product list as JSON for headless
*/
class ProductListJsonRenderer
{
public function render(string $content, array $conf): string
{
// IMPORTANT: Headless creates new cObj contexts when rendering JSON fields,
// losing the current content element context. Also, Extbase repositories don't work
// in UserFunc context because the full Extbase framework isn't bootstrapped.
//
// Solution: Query tt_content directly to find the plugin configuration,
// then use direct database queries for products instead of Extbase repositories.
// Get current page UID
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
// Query tt_content for vitec_productlist on this page
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('list_type', $queryBuilder->createNamedParameter('vitec_productlist', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
return json_encode(['debug' => 'No vitec_productlist on page ' . $pageId]);
}
// Take the first one (there should typically be only one)
$contentElement = $contentElements[0];
// Parse FlexForm
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
// Extract category UIDs and debug setting
$categoryUids = array_filter(
array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
);
$debugMode = (bool)($settings['debug'] ?? false);
$allProducts = (bool)($settings['allproducts'] ?? false);
// Extbase repositories don't work in UserFunc context
// Use direct database query instead
$productQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$productQuery = $productQueryBuilder
->select('p.*')
->from('tx_vitec_domain_model_product', 'p')
->where(
$productQueryBuilder->expr()->eq('p.deleted', 0),
$productQueryBuilder->expr()->eq('p.hidden', 0),
$productQueryBuilder->expr()->eq('p.legacy', 0),
$productQueryBuilder->expr()->eq('p.supportproduct', 0),
$productQueryBuilder->expr()->eq('p.hideonwebsite', 0),
$productQueryBuilder->expr()->eq('p.hideonproducts', 0),
$productQueryBuilder->expr()->eq('p.subproduct', 0)
);
// Add category filter if specified and allProducts is not enabled
if (!empty($categoryUids) && !$allProducts) {
// Join with sys_category_record_mm to filter by categories
$productQuery
->join(
'p',
'sys_category_record_mm',
'mm',
'mm.uid_foreign = p.uid AND mm.tablenames = ' . $productQueryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) . ' AND mm.fieldname = ' . $productQueryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->andWhere(
$productQueryBuilder->expr()->in('mm.uid_local', $productQueryBuilder->createNamedParameter($categoryUids, Connection::PARAM_INT_ARRAY))
)
->groupBy('p.uid');
}
$products = $productQuery->executeQuery()->fetchAllAssociative();
// Serialize products (they're already associative arrays from the query)
$productsData = [];
foreach ($products as $product) {
$productsData[] = $this->serializeProduct($product);
}
// If debug mode is enabled, return object with products and debug info
if ($debugMode) {
return json_encode([
'products' => $productsData,
'debug' => [
'pageId' => $pageId,
'categoryUids' => $categoryUids,
'productCount' => count($productsData),
'settings' => $settings
]
]);
}
// Otherwise return products array directly (backward compatible)
return json_encode($productsData);
}
/**
* Serialize a single product DB row to the full headless JSON structure.
*
* Includes every field declared on the Product domain model
* (Evomedien\Vitec\Domain\Model\Product) plus all fully resolved
* relations (categories, productimage, downloads, ogimage, relatedprodukt).
*
* DB-only columns that are NOT part of the domain model
* (cta, links, sorting1-5, key1-3, apptext1-3, productlayout, image,
* relatedimage, system fields) are intentionally omitted.
*
* @param array<string,mixed> $product Associative DB row of tx_vitec_domain_model_product
* @return array<string,mixed>
*/
protected function serializeProduct(array $product): array
{
$uid = (int)$product['uid'];
return [
// --- identifier ---
'uid' => $uid,
// --- scalar string fields (Product domain model) ---
'title' => (string)($product['title'] ?? ''),
'slug' => (string)($product['slug'] ?? ''),
'urltitle' => (string)($product['urltitle'] ?? ''),
'seotitle' => (string)($product['seotitle'] ?? ''),
'seometa' => (string)($product['seometa'] ?? ''),
'keywords' => (string)($product['keywords'] ?? ''),
'structureddata' => (string)($product['structureddata'] ?? ''),
'teaser' => (string)($product['teaser'] ?? ''),
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => (string)($product['applications'] ?? ''),
'description' => (string)($product['description'] ?? ''),
'highlights' => (string)($product['highlights'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => (string)($product['contentelement'] ?? ''),
'contentelementcta' => (string)($product['contentelementcta'] ?? ''),
// --- boolean flags (Product domain model) ---
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
'hideondatasheets' => (bool)($product['hideondatasheets'] ?? false),
'hideonproducts' => (bool)($product['hideonproducts'] ?? false),
'shortcut' => (bool)($product['shortcut'] ?? false),
'legacy' => (bool)($product['legacy'] ?? false),
'supportproduct' => (bool)($product['supportproduct'] ?? false),
'subproduct' => (bool)($product['subproduct'] ?? false),
// --- convenience link (kept for backward compatibility) ---
'link' => '/product/' . (string)($product['slug'] ?? ''),
// --- fully resolved relations ---
'categories' => $this->getProductCategories($uid),
'images' => $this->getProductImages($uid),
'downloads' => $this->getProductDownloads($uid),
'ogimage' => $this->getProductOgImage($uid),
'relatedprodukt' => $this->getRelatedProducts($uid),
];
}
/**
* Get product images from FAL (sys_file_reference)
* Processes images through ImageService and generates srcset for responsive images
*/
protected function getProductImages(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileReferences = $queryBuilder
->select('sfr.uid', 'sfr.uid_local', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->executeQuery()
->fetchAllAssociative();
$imageService = GeneralUtility::makeInstance(ImageService::class);
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$images = [];
foreach ($fileReferences as $fileRefData) {
try {
// Get FAL FileReference object
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
// Define image sizes for srcset
$sizes = [
'small' => ['width' => 400, 'height' => null],
'medium' => ['width' => 800, 'height' => null],
'large' => ['width' => 1200, 'height' => null],
'xlarge' => ['width' => 1600, 'height' => null],
];
$srcset = [];
foreach ($sizes as $sizeName => $dimensions) {
$processedImage = $imageService->applyProcessingInstructions(
$fileReference,
[
'width' => $dimensions['width'],
'height' => $dimensions['height'],
'crop' => $fileRefData['crop'] ?? null
]
);
$imageUri = $imageService->getImageUri($processedImage);
$srcset[] = [
'url' => $imageUri,
'width' => $dimensions['width'],
'descriptor' => $dimensions['width'] . 'w'
];
}
// Get original/default image
$defaultProcessed = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
);
$images[] = [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($defaultProcessed),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type')
]
];
} catch (\Exception $e) {
// Skip images that can't be processed
continue;
}
}
return $images;
}
/**
* Get the single Open Graph image (ogimage) for a product, or null.
*
* @return array<string,mixed>|null
*/
protected function getProductOgImage(int $productUid): ?array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $queryBuilder
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter('ogimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$processed = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 1200, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($processed),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
/**
* Get categories for a product (resolved sys_category records).
*/
protected function getProductCategories(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
];
}, $categories);
}
/**
* Get all downloads for a product (resolved via tx_vitec_product_download_mm).
*/
protected function getProductDownloads(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$downloads = $queryBuilder
->select('d.*')
->from('tx_vitec_domain_model_download', 'd')
->join(
'd',
'tx_vitec_product_download_mm',
'mm',
'mm.uid_foreign = d.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('d.deleted', 0),
$queryBuilder->expr()->eq('d.hidden', 0),
$queryBuilder->expr()->eq('d.hideonwebsite', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$result = [];
foreach ($downloads as $download) {
$fileInfo = null;
// Get file information from FAL if file reference exists
if (!empty($download['file'])) {
$fileQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRef = $fileQueryBuilder
->select('fr.uid', 'f.uid as file_uid', 'f.identifier', 'f.name', 'f.size', 'f.extension', 'f.mime_type')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter((int)$download['uid'], ParameterType::INTEGER)),
$fileQueryBuilder->expr()->eq('fr.tablenames', $fileQueryBuilder->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.fieldname', $fileQueryBuilder->createNamedParameter('file', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.deleted', 0),
$fileQueryBuilder->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if ($fileRef) {
$fileInfo = [
'uid' => (int)$fileRef['file_uid'],
'name' => $fileRef['name'],
'url' => '/fileadmin' . $fileRef['identifier'],
'size' => (int)$fileRef['size'],
'extension' => $fileRef['extension'],
'mimeType' => $fileRef['mime_type'] ?? '',
];
}
}
$result[] = [
'uid' => (int)$download['uid'],
'title' => $download['title'] ?? '',
'slug' => $download['slug'] ?? '',
'teaser' => $download['teaser'] ?? '',
'description' => $download['description'] ?? '',
'keywords' => $download['keywords'] ?? '',
'icon' => $download['icon'] ?? '',
'file' => $fileInfo,
];
}
return $result;
}
/**
* Get related products (resolved via tx_vitec_product_related_mm).
*
* Returns a shallow representation (no nested relations) to avoid
* infinite recursion between mutually related products.
*/
protected function getRelatedProducts(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$related = $queryBuilder
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
->from('tx_vitec_domain_model_product', 'p')
->join(
'p',
'tx_vitec_product_related_mm',
'mm',
'mm.uid_foreign = p.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.deleted', 0),
$queryBuilder->expr()->eq('p.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$result = [];
foreach ($related as $rel) {
$relUid = (int)$rel['uid'];
$images = $this->getProductImages($relUid);
$result[] = [
'uid' => $relUid,
'title' => (string)($rel['title'] ?? ''),
'slug' => (string)($rel['slug'] ?? ''),
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => (string)($rel['description'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $images,
];
}
return $result;
}
}

View File

@@ -14,25 +14,22 @@ use TYPO3\CMS\Core\Imaging\ImageService;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
/** /**
* UserFunc to render single product data as JSON for headless output * UserFunc to render a single product as JSON for headless output.
*
* `render()` performs page discovery (top-level plugin via TypoScript).
* `renderForRecord()` processes one specific tt_content row and is reused
* by ContainerChildrenProcessor for product-show plugins nested in a
* b13 container.
*/ */
class ProductShowJsonRenderer class ProductShowJsonRenderer
{ {
public function render(string $content, array $conf): string public function render(string $content, array $conf): string
{ {
$pageId = (int)$GLOBALS['TSFE']->id; $pageId = (int)($GLOBALS['TSFE']->id ?? 0);
// DEBUG: Log that the UserFunc is being called
$debugInfo = [
'userFuncCalled' => true,
'pageId' => $pageId,
'requestUri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
];
// Query tt_content for vitec_productshow on this page
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content'); ->getQueryBuilderForTable('tt_content');
$contentElements = $queryBuilder $contentElements = $queryBuilder
->select('*') ->select('*')
->from('tt_content') ->from('tt_content')
@@ -44,140 +41,161 @@ class ProductShowJsonRenderer
) )
->executeQuery() ->executeQuery()
->fetchAllAssociative(); ->fetchAllAssociative();
$debugInfo['contentElementsFound'] = count($contentElements);
if (empty($contentElements)) { if (empty($contentElements)) {
$debugInfo['error'] = 'No vitec_productshow on page'; return '';
return json_encode(['debug' => $debugInfo]);
} }
// Take the first one return $this->renderForRecord($contentElements[0]);
$contentElement = $contentElements[0]; }
// Parse FlexForm /**
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class); * Render exactly the given tt_content row (the product-show plugin element).
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? ''); * Exception-safe: returns '' on any failure.
$settings = $flexFormData['settings'] ?? []; *
* @param array<string,mixed> $contentElement
// Get product UID from FlexForm or route parameter */
$productUid = (int)($settings['product'] ?? 0); public function renderForRecord(array $contentElement): string
$layout = (int)($settings['layout'] ?? 0); {
$debugMode = (bool)($settings['debug'] ?? false); try {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
// If no product selected in FlexForm, try to get from route parameter
if (!$productUid) { $flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
// Get the product parameter from GET request $flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$routeParams = $GLOBALS['TYPO3_REQUEST']->getQueryParams(); $settings = $flexFormData['settings'] ?? [];
$productParam = $routeParams['tx_vitec_productshow']['product'] ?? null;
$productUid = (int)($settings['product'] ?? 0);
if ($productParam) { $layout = (int)($settings['layout'] ?? 0);
// If it's a slug, resolve it to UID $debugMode = (bool)($settings['debug'] ?? false);
if (!is_numeric($productParam)) {
$slugQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) if (!$productUid) {
->getQueryBuilderForTable('tx_vitec_domain_model_product'); $routeParams = $GLOBALS['TYPO3_REQUEST']->getQueryParams();
$productParam = $routeParams['tx_vitec_productshow']['product'] ?? null;
$productBySlug = $slugQueryBuilder
->select('uid') if ($productParam) {
->from('tx_vitec_domain_model_product') if (!is_numeric($productParam)) {
->where( $slugQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
$slugQueryBuilder->expr()->eq('slug', $slugQueryBuilder->createNamedParameter($productParam)), ->getQueryBuilderForTable('tx_vitec_domain_model_product');
$slugQueryBuilder->expr()->eq('deleted', 0),
$slugQueryBuilder->expr()->eq('hidden', 0) $productBySlug = $slugQueryBuilder
) ->select('uid')
->executeQuery() ->from('tx_vitec_domain_model_product')
->fetchAssociative(); ->where(
$slugQueryBuilder->expr()->eq('slug', $slugQueryBuilder->createNamedParameter($productParam)),
$productUid = (int)($productBySlug['uid'] ?? 0); $slugQueryBuilder->expr()->eq('deleted', 0),
} else { $slugQueryBuilder->expr()->eq('hidden', 0)
$productUid = (int)$productParam; )
->executeQuery()
->fetchAssociative();
$productUid = (int)($productBySlug['uid'] ?? 0);
} else {
$productUid = (int)$productParam;
}
} }
} }
}
if (!$productUid) {
if (!$productUid) { return $debugMode
return json_encode([ ? json_encode(['error' => 'No product selected or found', 'debug' => ['settings' => $settings]])
'error' => 'No product selected or found', : '';
'debug' => [ }
'settings' => $settings,
'routeParams' => $routeParams ?? [], $productQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
'allQueryParams' => $GLOBALS['TYPO3_REQUEST']->getQueryParams() ?? [], ->getQueryBuilderForTable('tx_vitec_domain_model_product');
'requestUri' => $GLOBALS['TYPO3_REQUEST']->getUri()->getPath() ?? ''
] $product = $productQueryBuilder
]); ->select('*')
} ->from('tx_vitec_domain_model_product')
->where(
// Query product $productQueryBuilder->expr()->eq('uid', $productQueryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$productQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) $productQueryBuilder->expr()->eq('deleted', 0),
->getQueryBuilderForTable('tx_vitec_domain_model_product'); $productQueryBuilder->expr()->eq('hidden', 0)
)
$product = $productQueryBuilder ->executeQuery()
->select('*') ->fetchAssociative();
->from('tx_vitec_domain_model_product')
->where( if (!$product) {
$productQueryBuilder->expr()->eq('uid', $productQueryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)), return $debugMode
$productQueryBuilder->expr()->eq('deleted', 0), ? json_encode(['error' => 'Product not found', 'debug' => ['productUid' => $productUid]])
$productQueryBuilder->expr()->eq('hidden', 0) : '';
) }
->executeQuery()
->fetchAssociative(); $response = [
'product' => $this->serializeProduct($product),
if (!$product) {
return json_encode([
'error' => 'Product not found',
'debug' => $debugMode ? ['productUid' => $productUid] : null
]);
}
// Get product images
$images = $this->getProductImages((int)$product['uid']);
// Get categories
$categories = $this->getProductCategories((int)$product['uid']);
// Get downloads
$downloads = $this->getProductDownloads((int)$product['uid']);
// Build response
$response = [
'product' => [
'uid' => (int)$product['uid'],
'title' => $product['title'],
'subtitle' => $product['subtitle'],
'slug' => $product['slug'],
'teaser' => $product['teaser'],
'description' => $product['description'],
'seotitle' => $product['seotitle'],
'categories' => $categories,
'images' => $images,
'downloads' => $downloads,
],
'layout' => $layout,
'settings' => [
'layout' => $layout, 'layout' => $layout,
], 'settings' => [
]; 'layout' => $layout,
],
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId,
'productUid' => $productUid,
'layout' => $layout,
'settings' => $settings,
]; ];
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId,
'productUid' => $productUid,
'layout' => $layout,
'settings' => $settings,
];
}
return json_encode($response);
} catch (\Throwable $e) {
return '';
} }
return json_encode($response);
} }
/** /**
* Get all images for a product with FAL and ImageService processing * @param array<string,mixed> $product
* @return array<string,mixed>
*/ */
protected function serializeProduct(array $product): array
{
$uid = (int)$product['uid'];
return [
'uid' => $uid,
'title' => (string)($product['title'] ?? ''),
'slug' => (string)($product['slug'] ?? ''),
'urltitle' => (string)($product['urltitle'] ?? ''),
'seotitle' => (string)($product['seotitle'] ?? ''),
'seometa' => (string)($product['seometa'] ?? ''),
'keywords' => (string)($product['keywords'] ?? ''),
'structureddata' => (string)($product['structureddata'] ?? ''),
'teaser' => (string)($product['teaser'] ?? ''),
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => (string)($product['applications'] ?? ''),
'description' => (string)($product['description'] ?? ''),
'highlights' => (string)($product['highlights'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => (string)($product['contentelement'] ?? ''),
'contentelementcta' => (string)($product['contentelementcta'] ?? ''),
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
'hideondatasheets' => (bool)($product['hideondatasheets'] ?? false),
'hideonproducts' => (bool)($product['hideonproducts'] ?? false),
'shortcut' => (bool)($product['shortcut'] ?? false),
'legacy' => (bool)($product['legacy'] ?? false),
'supportproduct' => (bool)($product['supportproduct'] ?? false),
'subproduct' => (bool)($product['subproduct'] ?? false),
'link' => '/product/' . (string)($product['slug'] ?? ''),
'categories' => $this->getProductCategories($uid),
'images' => $this->getProductImages($uid),
'downloads' => $this->getProductDownloads($uid),
'ogimage' => $this->getProductOgImage($uid),
'relatedprodukt' => $this->getRelatedProducts($uid),
];
}
protected function getProductImages(int $productUid): array protected function getProductImages(int $productUid): array
{ {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference'); ->getQueryBuilderForTable('sys_file_reference');
$fileReferences = $queryBuilder $fileReferences = $queryBuilder
->select('*') ->select('*')
->from('sys_file_reference') ->from('sys_file_reference')
@@ -191,27 +209,25 @@ class ProductShowJsonRenderer
->orderBy('sorting_foreign', 'ASC') ->orderBy('sorting_foreign', 'ASC')
->executeQuery() ->executeQuery()
->fetchAllAssociative(); ->fetchAllAssociative();
if (empty($fileReferences)) { if (empty($fileReferences)) {
return []; return [];
} }
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class); $resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class); $imageService = GeneralUtility::makeInstance(ImageService::class);
$images = []; $images = [];
foreach ($fileReferences as $fileRef) { foreach ($fileReferences as $fileRef) {
try { try {
$fileReference = $resourceFactory->getFileReferenceObject($fileRef['uid']); $fileReference = $resourceFactory->getFileReferenceObject($fileRef['uid']);
$originalFile = $fileReference->getOriginalFile(); $originalFile = $fileReference->getOriginalFile();
// Process main image
$processedImage = $imageService->applyProcessingInstructions( $processedImage = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => '1874c', 'height' => '625c'] ['width' => '1874c', 'height' => '625c']
); );
// Generate srcset
$srcset = []; $srcset = [];
foreach ([400, 800, 1200, 1600] as $width) { foreach ([400, 800, 1200, 1600] as $width) {
$processedVariant = $imageService->applyProcessingInstructions( $processedVariant = $imageService->applyProcessingInstructions(
@@ -224,7 +240,7 @@ class ProductShowJsonRenderer
'descriptor' => $width . 'w', 'descriptor' => $width . 'w',
]; ];
} }
$images[] = [ $images[] = [
'uid' => $fileRef['uid'], 'uid' => $fileRef['uid'],
'url' => $imageService->getImageUri($processedImage), 'url' => $imageService->getImageUri($processedImage),
@@ -239,22 +255,73 @@ class ProductShowJsonRenderer
], ],
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
// Skip invalid file references
continue; continue;
} }
} }
return $images; return $images;
} }
/** /**
* Get categories for a product * @return array<string,mixed>|null
*/ */
protected function getProductOgImage(int $productUid): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRef = $queryBuilder
->select('*')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('ogimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRef) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject($fileRef['uid']);
$originalFile = $fileReference->getOriginalFile();
$processedImage = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 1200]
);
return [
'uid' => $fileRef['uid'],
'url' => $imageService->getImageUri($processedImage),
'title' => $fileReference->getTitle() ?: '',
'alternative' => $fileReference->getAlternative() ?: '',
'description' => $fileReference->getDescription() ?: '',
'properties' => [
'width' => $originalFile->getProperty('width'),
'height' => $originalFile->getProperty('height'),
'mimeType' => $originalFile->getMimeType(),
],
];
} catch (\Exception $e) {
return null;
}
}
protected function getProductCategories(int $productUid): array protected function getProductCategories(int $productUid): array
{ {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category'); ->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder $categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description') ->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c') ->from('sys_category', 'c')
@@ -262,9 +329,9 @@ class ProductShowJsonRenderer
'c', 'c',
'sys_category_record_mm', 'sys_category_record_mm',
'mm', 'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' . 'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) . $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) .
' AND mm.fieldname = ' . ' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING) $queryBuilder->createNamedParameter('categories', ParameterType::STRING)
) )
->where( ->where(
@@ -275,7 +342,7 @@ class ProductShowJsonRenderer
->orderBy('mm.sorting', 'ASC') ->orderBy('mm.sorting', 'ASC')
->executeQuery() ->executeQuery()
->fetchAllAssociative(); ->fetchAllAssociative();
return array_map(function ($cat) { return array_map(function ($cat) {
return [ return [
'uid' => (int)$cat['uid'], 'uid' => (int)$cat['uid'],
@@ -284,15 +351,12 @@ class ProductShowJsonRenderer
]; ];
}, $categories); }, $categories);
} }
/**
* Get all downloads for a product
*/
protected function getProductDownloads(int $productUid): array protected function getProductDownloads(int $productUid): array
{ {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download'); ->getQueryBuilderForTable('tx_vitec_domain_model_download');
$downloads = $queryBuilder $downloads = $queryBuilder
->select('d.*') ->select('d.*')
->from('tx_vitec_domain_model_download', 'd') ->from('tx_vitec_domain_model_download', 'd')
@@ -311,16 +375,15 @@ class ProductShowJsonRenderer
->orderBy('mm.sorting', 'ASC') ->orderBy('mm.sorting', 'ASC')
->executeQuery() ->executeQuery()
->fetchAllAssociative(); ->fetchAllAssociative();
$result = []; $result = [];
foreach ($downloads as $download) { foreach ($downloads as $download) {
$fileInfo = null; $fileInfo = null;
// Get file information from FAL if file reference exists
if (!empty($download['file'])) { if (!empty($download['file'])) {
$fileQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class) $fileQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference'); ->getQueryBuilderForTable('sys_file_reference');
$fileRef = $fileQueryBuilder $fileRef = $fileQueryBuilder
->select('fr.uid', 'f.uid as file_uid', 'f.identifier', 'f.name', 'f.size', 'f.extension', 'f.mime_type') ->select('fr.uid', 'f.uid as file_uid', 'f.identifier', 'f.name', 'f.size', 'f.extension', 'f.mime_type')
->from('sys_file_reference', 'fr') ->from('sys_file_reference', 'fr')
@@ -336,7 +399,7 @@ class ProductShowJsonRenderer
->setMaxResults(1) ->setMaxResults(1)
->executeQuery() ->executeQuery()
->fetchAssociative(); ->fetchAssociative();
if ($fileRef) { if ($fileRef) {
$fileInfo = [ $fileInfo = [
'uid' => (int)$fileRef['file_uid'], 'uid' => (int)$fileRef['file_uid'],
@@ -348,7 +411,7 @@ class ProductShowJsonRenderer
]; ];
} }
} }
$result[] = [ $result[] = [
'uid' => (int)$download['uid'], 'uid' => (int)$download['uid'],
'title' => $download['title'] ?? '', 'title' => $download['title'] ?? '',
@@ -360,7 +423,48 @@ class ProductShowJsonRenderer
'file' => $fileInfo, 'file' => $fileInfo,
]; ];
} }
return $result;
}
protected function getRelatedProducts(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$related = $queryBuilder
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
->from('tx_vitec_domain_model_product', 'p')
->join(
'p',
'tx_vitec_product_related_mm',
'mm',
'mm.uid_foreign = p.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.deleted', 0),
$queryBuilder->expr()->eq('p.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$result = [];
foreach ($related as $rel) {
$relUid = (int)$rel['uid'];
$result[] = [
'uid' => $relUid,
'title' => (string)($rel['title'] ?? ''),
'slug' => (string)($rel['slug'] ?? ''),
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => (string)($rel['description'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $this->getProductImages($relUid),
];
}
return $result; return $result;
} }
} }

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Imaging\ImageService;
use Doctrine\DBAL\ParameterType;
/**
* UserFunc to render single product data as JSON for headless output
*/
class ProductShowJsonRenderer
{
public function render(string $content, array $conf): string
{
$pageId = (int)$GLOBALS['TSFE']->id;
// DEBUG: Log that the UserFunc is being called
$debugInfo = [
'userFuncCalled' => true,
'pageId' => $pageId,
'requestUri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
];
// Query tt_content for vitec_productshow on this page
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('list_type', $queryBuilder->createNamedParameter('vitec_productshow', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
$debugInfo['contentElementsFound'] = count($contentElements);
if (empty($contentElements)) {
$debugInfo['error'] = 'No vitec_productshow on page';
return json_encode(['debug' => $debugInfo]);
}
// Take the first one
$contentElement = $contentElements[0];
// Parse FlexForm
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
// Get product UID from FlexForm or route parameter
$productUid = (int)($settings['product'] ?? 0);
$layout = (int)($settings['layout'] ?? 0);
$debugMode = (bool)($settings['debug'] ?? false);
// If no product selected in FlexForm, try to get from route parameter
if (!$productUid) {
// Get the product parameter from GET request
$routeParams = $GLOBALS['TYPO3_REQUEST']->getQueryParams();
$productParam = $routeParams['tx_vitec_productshow']['product'] ?? null;
if ($productParam) {
// If it's a slug, resolve it to UID
if (!is_numeric($productParam)) {
$slugQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$productBySlug = $slugQueryBuilder
->select('uid')
->from('tx_vitec_domain_model_product')
->where(
$slugQueryBuilder->expr()->eq('slug', $slugQueryBuilder->createNamedParameter($productParam)),
$slugQueryBuilder->expr()->eq('deleted', 0),
$slugQueryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
$productUid = (int)($productBySlug['uid'] ?? 0);
} else {
$productUid = (int)$productParam;
}
}
}
if (!$productUid) {
return json_encode([
'error' => 'No product selected or found',
'debug' => [
'settings' => $settings,
'routeParams' => $routeParams ?? [],
'allQueryParams' => $GLOBALS['TYPO3_REQUEST']->getQueryParams() ?? [],
'requestUri' => $GLOBALS['TYPO3_REQUEST']->getUri()->getPath() ?? ''
]
]);
}
// Query product
$productQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$product = $productQueryBuilder
->select('*')
->from('tx_vitec_domain_model_product')
->where(
$productQueryBuilder->expr()->eq('uid', $productQueryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$productQueryBuilder->expr()->eq('deleted', 0),
$productQueryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$product) {
return json_encode([
'error' => 'Product not found',
'debug' => $debugMode ? ['productUid' => $productUid] : null
]);
}
// Get product images
$images = $this->getProductImages((int)$product['uid']);
// Get categories
$categories = $this->getProductCategories((int)$product['uid']);
// Get downloads
$downloads = $this->getProductDownloads((int)$product['uid']);
// Build response
$response = [
'product' => [
'uid' => (int)$product['uid'],
'title' => $product['title'],
'subtitle' => $product['subtitle'],
'slug' => $product['slug'],
'teaser' => $product['teaser'],
'description' => $product['description'],
'seotitle' => $product['seotitle'],
'categories' => $categories,
'images' => $images,
'downloads' => $downloads,
],
'layout' => $layout,
'settings' => [
'layout' => $layout,
],
];
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId,
'productUid' => $productUid,
'layout' => $layout,
'settings' => $settings,
];
}
return json_encode($response);
}
/**
* Get all images for a product with FAL and ImageService processing
*/
protected function getProductImages(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileReferences = $queryBuilder
->select('*')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->executeQuery()
->fetchAllAssociative();
if (empty($fileReferences)) {
return [];
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$images = [];
foreach ($fileReferences as $fileRef) {
try {
$fileReference = $resourceFactory->getFileReferenceObject($fileRef['uid']);
$originalFile = $fileReference->getOriginalFile();
// Process main image
$processedImage = $imageService->applyProcessingInstructions(
$fileReference,
['width' => '1874c', 'height' => '625c']
);
// Generate srcset
$srcset = [];
foreach ([400, 800, 1200, 1600] as $width) {
$processedVariant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width . 'c', 'height' => (int)($width / 3) . 'c']
);
$srcset[] = [
'url' => $imageService->getImageUri($processedVariant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$images[] = [
'uid' => $fileRef['uid'],
'url' => $imageService->getImageUri($processedImage),
'title' => $fileReference->getTitle() ?: '',
'alternative' => $fileReference->getAlternative() ?: '',
'description' => $fileReference->getDescription() ?: '',
'srcset' => $srcset,
'properties' => [
'width' => $originalFile->getProperty('width'),
'height' => $originalFile->getProperty('height'),
'mimeType' => $originalFile->getMimeType(),
],
];
} catch (\Exception $e) {
// Skip invalid file references
continue;
}
}
return $images;
}
/**
* Get categories for a product
*/
protected function getProductCategories(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'],
'description' => $cat['description'] ?? '',
];
}, $categories);
}
/**
* Get all downloads for a product
*/
protected function getProductDownloads(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$downloads = $queryBuilder
->select('d.*')
->from('tx_vitec_domain_model_download', 'd')
->join(
'd',
'tx_vitec_product_download_mm',
'mm',
'mm.uid_foreign = d.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('d.deleted', 0),
$queryBuilder->expr()->eq('d.hidden', 0),
$queryBuilder->expr()->eq('d.hideonwebsite', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$result = [];
foreach ($downloads as $download) {
$fileInfo = null;
// Get file information from FAL if file reference exists
if (!empty($download['file'])) {
$fileQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRef = $fileQueryBuilder
->select('fr.uid', 'f.uid as file_uid', 'f.identifier', 'f.name', 'f.size', 'f.extension', 'f.mime_type')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter((int)$download['uid'], ParameterType::INTEGER)),
$fileQueryBuilder->expr()->eq('fr.tablenames', $fileQueryBuilder->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.fieldname', $fileQueryBuilder->createNamedParameter('file', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.deleted', 0),
$fileQueryBuilder->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if ($fileRef) {
$fileInfo = [
'uid' => (int)$fileRef['file_uid'],
'name' => $fileRef['name'],
'url' => '/fileadmin' . $fileRef['identifier'],
'size' => (int)$fileRef['size'],
'extension' => $fileRef['extension'],
'mimeType' => $fileRef['mime_type'] ?? '',
];
}
}
$result[] = [
'uid' => (int)$download['uid'],
'title' => $download['title'] ?? '',
'slug' => $download['slug'] ?? '',
'teaser' => $download['teaser'] ?? '',
'description' => $download['description'] ?? '',
'keywords' => $download['keywords'] ?? '',
'icon' => $download['icon'] ?? '',
'file' => $fileInfo,
];
}
return $result;
}
}

View File

@@ -0,0 +1,512 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
use TYPO3\CMS\Core\Resource\FileReference;
use TYPO3\CMS\Core\Imaging\ImageService;
use Doctrine\DBAL\ParameterType;
/**
* UserFunc to render single product data as JSON for headless output
*/
class ProductShowJsonRenderer
{
public function render(string $content, array $conf): string
{
$pageId = (int)$GLOBALS['TSFE']->id;
// DEBUG: Log that the UserFunc is being called
$debugInfo = [
'userFuncCalled' => true,
'pageId' => $pageId,
'requestUri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
];
// Query tt_content for vitec_productshow on this page
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('list_type', $queryBuilder->createNamedParameter('vitec_productshow', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
$debugInfo['contentElementsFound'] = count($contentElements);
if (empty($contentElements)) {
$debugInfo['error'] = 'No vitec_productshow on page';
return json_encode(['debug' => $debugInfo]);
}
// Take the first one
$contentElement = $contentElements[0];
// Parse FlexForm
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
// Get product UID from FlexForm or route parameter
$productUid = (int)($settings['product'] ?? 0);
$layout = (int)($settings['layout'] ?? 0);
$debugMode = (bool)($settings['debug'] ?? false);
// If no product selected in FlexForm, try to get from route parameter
if (!$productUid) {
// Get the product parameter from GET request
$routeParams = $GLOBALS['TYPO3_REQUEST']->getQueryParams();
$productParam = $routeParams['tx_vitec_productshow']['product'] ?? null;
if ($productParam) {
// If it's a slug, resolve it to UID
if (!is_numeric($productParam)) {
$slugQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$productBySlug = $slugQueryBuilder
->select('uid')
->from('tx_vitec_domain_model_product')
->where(
$slugQueryBuilder->expr()->eq('slug', $slugQueryBuilder->createNamedParameter($productParam)),
$slugQueryBuilder->expr()->eq('deleted', 0),
$slugQueryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
$productUid = (int)($productBySlug['uid'] ?? 0);
} else {
$productUid = (int)$productParam;
}
}
}
if (!$productUid) {
return json_encode([
'error' => 'No product selected or found',
'debug' => [
'settings' => $settings,
'routeParams' => $routeParams ?? [],
'allQueryParams' => $GLOBALS['TYPO3_REQUEST']->getQueryParams() ?? [],
'requestUri' => $GLOBALS['TYPO3_REQUEST']->getUri()->getPath() ?? ''
]
]);
}
// Query product
$productQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$product = $productQueryBuilder
->select('*')
->from('tx_vitec_domain_model_product')
->where(
$productQueryBuilder->expr()->eq('uid', $productQueryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$productQueryBuilder->expr()->eq('deleted', 0),
$productQueryBuilder->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$product) {
return json_encode([
'error' => 'Product not found',
'debug' => $debugMode ? ['productUid' => $productUid] : null
]);
}
// Build response (full Product domain model + resolved relations)
$response = [
'product' => $this->serializeProduct($product),
'layout' => $layout,
'settings' => [
'layout' => $layout,
],
];
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId,
'productUid' => $productUid,
'layout' => $layout,
'settings' => $settings,
];
}
return json_encode($response);
}
/**
* Serialize a single product DB row to the full headless JSON structure.
*
* Includes every field declared on the Product domain model
* (Evomedien\Vitec\Domain\Model\Product) plus all fully resolved
* relations (categories, productimage, downloads, ogimage, relatedprodukt).
*
* Kept structurally identical to ProductListJsonRenderer::serializeProduct()
* so list and detail endpoints expose the same product schema.
*
* @param array<string,mixed> $product Associative DB row of tx_vitec_domain_model_product
* @return array<string,mixed>
*/
protected function serializeProduct(array $product): array
{
$uid = (int)$product['uid'];
return [
// --- identifier ---
'uid' => $uid,
// --- scalar string fields (Product domain model) ---
'title' => (string)($product['title'] ?? ''),
'slug' => (string)($product['slug'] ?? ''),
'urltitle' => (string)($product['urltitle'] ?? ''),
'seotitle' => (string)($product['seotitle'] ?? ''),
'seometa' => (string)($product['seometa'] ?? ''),
'keywords' => (string)($product['keywords'] ?? ''),
'structureddata' => (string)($product['structureddata'] ?? ''),
'teaser' => (string)($product['teaser'] ?? ''),
'subtitle' => (string)($product['subtitle'] ?? ''),
'video' => (string)($product['video'] ?? ''),
'applications' => (string)($product['applications'] ?? ''),
'description' => (string)($product['description'] ?? ''),
'highlights' => (string)($product['highlights'] ?? ''),
'shortcutpid' => (string)($product['shortcutpid'] ?? ''),
'contentelement' => (string)($product['contentelement'] ?? ''),
'contentelementcta' => (string)($product['contentelementcta'] ?? ''),
// --- boolean flags (Product domain model) ---
'hideonapp' => (bool)($product['hideonapp'] ?? false),
'hideonwebsite' => (bool)($product['hideonwebsite'] ?? false),
'hideondatasheets' => (bool)($product['hideondatasheets'] ?? false),
'hideonproducts' => (bool)($product['hideonproducts'] ?? false),
'shortcut' => (bool)($product['shortcut'] ?? false),
'legacy' => (bool)($product['legacy'] ?? false),
'supportproduct' => (bool)($product['supportproduct'] ?? false),
'subproduct' => (bool)($product['subproduct'] ?? false),
// --- convenience link (kept for backward compatibility) ---
'link' => '/product/' . (string)($product['slug'] ?? ''),
// --- fully resolved relations ---
'categories' => $this->getProductCategories($uid),
'images' => $this->getProductImages($uid),
'downloads' => $this->getProductDownloads($uid),
'ogimage' => $this->getProductOgImage($uid),
'relatedprodukt' => $this->getRelatedProducts($uid),
];
}
/**
* Get all images for a product with FAL and ImageService processing
*/
protected function getProductImages(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileReferences = $queryBuilder
->select('*')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('productimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->executeQuery()
->fetchAllAssociative();
if (empty($fileReferences)) {
return [];
}
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$images = [];
foreach ($fileReferences as $fileRef) {
try {
$fileReference = $resourceFactory->getFileReferenceObject($fileRef['uid']);
$originalFile = $fileReference->getOriginalFile();
// Process main image
$processedImage = $imageService->applyProcessingInstructions(
$fileReference,
['width' => '1874c', 'height' => '625c']
);
// Generate srcset
$srcset = [];
foreach ([400, 800, 1200, 1600] as $width) {
$processedVariant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width . 'c', 'height' => (int)($width / 3) . 'c']
);
$srcset[] = [
'url' => $imageService->getImageUri($processedVariant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$images[] = [
'uid' => $fileRef['uid'],
'url' => $imageService->getImageUri($processedImage),
'title' => $fileReference->getTitle() ?: '',
'alternative' => $fileReference->getAlternative() ?: '',
'description' => $fileReference->getDescription() ?: '',
'srcset' => $srcset,
'properties' => [
'width' => $originalFile->getProperty('width'),
'height' => $originalFile->getProperty('height'),
'mimeType' => $originalFile->getMimeType(),
],
];
} catch (\Exception $e) {
// Skip invalid file references
continue;
}
}
return $images;
}
/**
* Get the single Open Graph image (ogimage) for a product, or null.
*
* @return array<string,mixed>|null
*/
protected function getProductOgImage(int $productUid): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRef = $queryBuilder
->select('*')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING)),
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('ogimage', ParameterType::STRING)),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('hidden', 0)
)
->orderBy('sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRef) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject($fileRef['uid']);
$originalFile = $fileReference->getOriginalFile();
$processedImage = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 1200]
);
return [
'uid' => $fileRef['uid'],
'url' => $imageService->getImageUri($processedImage),
'title' => $fileReference->getTitle() ?: '',
'alternative' => $fileReference->getAlternative() ?: '',
'description' => $fileReference->getDescription() ?: '',
'properties' => [
'width' => $originalFile->getProperty('width'),
'height' => $originalFile->getProperty('height'),
'mimeType' => $originalFile->getMimeType(),
],
];
} catch (\Exception $e) {
return null;
}
}
/**
* Get categories for a product
*/
protected function getProductCategories(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_product', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'],
'description' => $cat['description'] ?? '',
];
}, $categories);
}
/**
* Get all downloads for a product
*/
protected function getProductDownloads(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_download');
$downloads = $queryBuilder
->select('d.*')
->from('tx_vitec_domain_model_download', 'd')
->join(
'd',
'tx_vitec_product_download_mm',
'mm',
'mm.uid_foreign = d.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('d.deleted', 0),
$queryBuilder->expr()->eq('d.hidden', 0),
$queryBuilder->expr()->eq('d.hideonwebsite', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$result = [];
foreach ($downloads as $download) {
$fileInfo = null;
// Get file information from FAL if file reference exists
if (!empty($download['file'])) {
$fileQueryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRef = $fileQueryBuilder
->select('fr.uid', 'f.uid as file_uid', 'f.identifier', 'f.name', 'f.size', 'f.extension', 'f.mime_type')
->from('sys_file_reference', 'fr')
->join('fr', 'sys_file', 'f', 'fr.uid_local = f.uid')
->where(
$fileQueryBuilder->expr()->eq('fr.uid_foreign', $fileQueryBuilder->createNamedParameter((int)$download['uid'], ParameterType::INTEGER)),
$fileQueryBuilder->expr()->eq('fr.tablenames', $fileQueryBuilder->createNamedParameter('tx_vitec_domain_model_download', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.fieldname', $fileQueryBuilder->createNamedParameter('file', ParameterType::STRING)),
$fileQueryBuilder->expr()->eq('fr.deleted', 0),
$fileQueryBuilder->expr()->eq('f.missing', 0)
)
->orderBy('fr.sorting_foreign', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if ($fileRef) {
$fileInfo = [
'uid' => (int)$fileRef['file_uid'],
'name' => $fileRef['name'],
'url' => '/fileadmin' . $fileRef['identifier'],
'size' => (int)$fileRef['size'],
'extension' => $fileRef['extension'],
'mimeType' => $fileRef['mime_type'] ?? '',
];
}
}
$result[] = [
'uid' => (int)$download['uid'],
'title' => $download['title'] ?? '',
'slug' => $download['slug'] ?? '',
'teaser' => $download['teaser'] ?? '',
'description' => $download['description'] ?? '',
'keywords' => $download['keywords'] ?? '',
'icon' => $download['icon'] ?? '',
'file' => $fileInfo,
];
}
return $result;
}
/**
* Get related products (resolved via tx_vitec_product_related_mm).
*
* Returns a shallow representation (no nested relations) to avoid
* infinite recursion between mutually related products.
*/
protected function getRelatedProducts(int $productUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_product');
$related = $queryBuilder
->select('p.uid', 'p.title', 'p.slug', 'p.subtitle', 'p.teaser', 'p.description')
->from('tx_vitec_domain_model_product', 'p')
->join(
'p',
'tx_vitec_product_related_mm',
'mm',
'mm.uid_foreign = p.uid'
)
->where(
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('p.deleted', 0),
$queryBuilder->expr()->eq('p.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
$result = [];
foreach ($related as $rel) {
$relUid = (int)$rel['uid'];
$result[] = [
'uid' => $relUid,
'title' => (string)($rel['title'] ?? ''),
'slug' => (string)($rel['slug'] ?? ''),
'subtitle' => (string)($rel['subtitle'] ?? ''),
'teaser' => (string)($rel['teaser'] ?? ''),
'description' => (string)($rel['description'] ?? ''),
'link' => '/product/' . (string)($rel['slug'] ?? ''),
'images' => $this->getProductImages($relUid),
];
}
return $result;
}
}

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
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 to render the usecase list as JSON for headless output.
*
* `render()` = page discovery (top-level plugin). `renderForRecord()` =
* one specific tt_content row, reused by ContainerChildrenProcessor for
* usecase-list plugins nested in a b13 container. Exception-safe.
*/
class UsecaseListJsonRenderer
{
public function render(string $content, array $conf): string
{
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
$ttContentQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $ttContentQb
->select('*')
->from('tt_content')
->where(
$ttContentQb->expr()->eq('pid', $ttContentQb->createNamedParameter($pageId, ParameterType::INTEGER)),
$ttContentQb->expr()->eq('list_type', $ttContentQb->createNamedParameter('vitec_usecaselist', ParameterType::STRING)),
$ttContentQb->expr()->eq('deleted', 0),
$ttContentQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
return '';
}
return $this->renderForRecord($contentElements[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'] ?? [];
$debugMode = (bool)($settings['debug'] ?? false);
$usecaseQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$usecases = $usecaseQb
->select('u.*')
->from('tx_vitec_domain_model_usecase', 'u')
->where(
$usecaseQb->expr()->eq('u.deleted', 0),
$usecaseQb->expr()->eq('u.hidden', 0),
$usecaseQb->expr()->eq('u.hideonwebsite', 0)
)
->orderBy('u.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$usecasesData = [];
foreach ($usecases as $usecase) {
$usecasesData[] = $this->serializeUsecase($usecase);
}
if ($debugMode) {
return json_encode([
'usecases' => $usecasesData,
'debug' => [
'pageId' => (int)($GLOBALS['TSFE']->id ?? 0),
'usecaseCount' => count($usecasesData),
'settings' => $settings,
],
]);
}
return json_encode($usecasesData);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $usecase
* @return array<string,mixed>
*/
protected function serializeUsecase(array $usecase): array
{
$uid = (int)$usecase['uid'];
return [
'uid' => $uid,
'title' => (string)($usecase['title'] ?? ''),
'slug' => (string)($usecase['slug'] ?? ''),
'subtitle' => (string)($usecase['subtitle'] ?? ''),
'teaser' => (string)($usecase['teaser'] ?? ''),
'description' => (string)($usecase['description'] ?? ''),
'singlepid' => (string)($usecase['singlepid'] ?? ''),
'hideonapp' => (bool)($usecase['hideonapp'] ?? false),
'hideonwebsite' => (bool)($usecase['hideonwebsite'] ?? false),
'categories' => $this->getUsecaseCategories($uid),
'caseimage' => $this->getUsecaseImage($uid, 'caseimage'),
'logoimage' => $this->getUsecaseImage($uid, 'logoimage'),
];
}
/**
* @return array<string,mixed>|null
*/
protected function getUsecaseImage(int $usecaseUid, string $fieldName): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $queryBuilder
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$srcset = [];
foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
protected function getUsecaseCategories(int $usecaseUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
];
}, $categories);
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Database\Connection;
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 to render the usecase list as JSON for headless output.
*
* Mirrors ProductListJsonRenderer: Extbase repositories don't work in a
* UserFunc context (framework not bootstrapped), so tt_content and the
* usecase records are queried directly. Exception-safe: returns an empty
* string on any failure so headless `ifEmptyUnsetKey` can drop the key.
*/
class UsecaseListJsonRenderer
{
public function render(string $content, array $conf): string
{
try {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
// Find the vitec_usecaselist plugin on this page
$ttContentQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $ttContentQb
->select('*')
->from('tt_content')
->where(
$ttContentQb->expr()->eq('pid', $ttContentQb->createNamedParameter($pageId, ParameterType::INTEGER)),
$ttContentQb->expr()->eq('list_type', $ttContentQb->createNamedParameter('vitec_usecaselist', ParameterType::STRING)),
$ttContentQb->expr()->eq('deleted', 0),
$ttContentQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
// Not a usecase-list page (or none configured): emit nothing
// so headless removes the key entirely.
return '';
}
$contentElement = $contentElements[0];
// Parse FlexForm (debug flag only; the list shows all visible usecases)
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$debugMode = (bool)($settings['debug'] ?? false);
// Query visible usecases directly (Extbase unavailable in UserFunc)
$usecaseQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$usecases = $usecaseQb
->select('u.*')
->from('tx_vitec_domain_model_usecase', 'u')
->where(
$usecaseQb->expr()->eq('u.deleted', 0),
$usecaseQb->expr()->eq('u.hidden', 0),
$usecaseQb->expr()->eq('u.hideonwebsite', 0)
)
->orderBy('u.title', 'ASC')
->executeQuery()
->fetchAllAssociative();
$usecasesData = [];
foreach ($usecases as $usecase) {
$usecasesData[] = $this->serializeUsecase($usecase);
}
if ($debugMode) {
return json_encode([
'usecases' => $usecasesData,
'debug' => [
'pageId' => $pageId,
'usecaseCount' => count($usecasesData),
'settings' => $settings,
],
]);
}
return json_encode($usecasesData);
} catch (\Throwable $e) {
return '';
}
}
/**
* Serialize a single usecase DB row to the full headless JSON structure.
*
* Includes every field declared on the Usecase domain model
* (Evomedien\Vitec\Domain\Model\Usecase) plus all resolved relations
* (categories, caseimage, logoimage). Kept structurally analogous to
* ProductListJsonRenderer::serializeProduct().
*
* @param array<string,mixed> $usecase
* @return array<string,mixed>
*/
protected function serializeUsecase(array $usecase): array
{
$uid = (int)$usecase['uid'];
return [
// --- identifier ---
'uid' => $uid,
// --- scalar string fields (Usecase domain model) ---
'title' => (string)($usecase['title'] ?? ''),
'slug' => (string)($usecase['slug'] ?? ''),
'subtitle' => (string)($usecase['subtitle'] ?? ''),
'teaser' => (string)($usecase['teaser'] ?? ''),
'description' => (string)($usecase['description'] ?? ''),
'singlepid' => (string)($usecase['singlepid'] ?? ''),
// --- boolean flags (Usecase domain model) ---
'hideonapp' => (bool)($usecase['hideonapp'] ?? false),
'hideonwebsite' => (bool)($usecase['hideonwebsite'] ?? false),
// --- fully resolved relations ---
'categories' => $this->getUsecaseCategories($uid),
'caseimage' => $this->getUsecaseImage($uid, 'caseimage'),
'logoimage' => $this->getUsecaseImage($uid, 'logoimage'),
];
}
/**
* Resolve a single FAL image field (caseimage / logoimage) with srcset.
*
* @return array<string,mixed>|null
*/
protected function getUsecaseImage(int $usecaseUid, string $fieldName): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $queryBuilder
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$sizes = [400, 800, 1200, 1600];
$srcset = [];
foreach ($sizes as $width) {
$processedVariant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($processedVariant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
/**
* Get categories for a usecase (resolved sys_category records).
*/
protected function getUsecaseCategories(int $usecaseUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
];
}, $categories);
}
}

View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
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 to render a single usecase as JSON for headless output.
*
* `render()` = page discovery (top-level plugin). `renderForRecord()` =
* one specific tt_content row, reused by ContainerChildrenProcessor for
* usecase-show plugins nested in a b13 container. Exception-safe.
*/
class UsecaseShowJsonRenderer
{
public function render(string $content, array $conf): string
{
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
$ttContentQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $ttContentQb
->select('*')
->from('tt_content')
->where(
$ttContentQb->expr()->eq('pid', $ttContentQb->createNamedParameter($pageId, ParameterType::INTEGER)),
$ttContentQb->expr()->eq('list_type', $ttContentQb->createNamedParameter('vitec_usecaseshow', ParameterType::STRING)),
$ttContentQb->expr()->eq('deleted', 0),
$ttContentQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
return '';
}
return $this->renderForRecord($contentElements[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$usecaseUid = (int)($settings['usecase'] ?? 0);
$layout = (string)($settings['layout'] ?? 'default');
$debugMode = (bool)($settings['debug'] ?? false);
if (!$usecaseUid) {
$routeParams = $GLOBALS['TYPO3_REQUEST']->getQueryParams();
$usecaseParam = $routeParams['tx_vitec_usecaseshow']['usecase'] ?? null;
if ($usecaseParam) {
if (!is_numeric($usecaseParam)) {
$slugQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$bySlug = $slugQb
->select('uid')
->from('tx_vitec_domain_model_usecase')
->where(
$slugQb->expr()->eq('slug', $slugQb->createNamedParameter($usecaseParam)),
$slugQb->expr()->eq('deleted', 0),
$slugQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
$usecaseUid = (int)($bySlug['uid'] ?? 0);
} else {
$usecaseUid = (int)$usecaseParam;
}
}
}
if (!$usecaseUid) {
return $debugMode
? json_encode(['error' => 'No usecase selected or found', 'debug' => ['settings' => $settings]])
: '';
}
$usecaseQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$usecase = $usecaseQb
->select('*')
->from('tx_vitec_domain_model_usecase')
->where(
$usecaseQb->expr()->eq('uid', $usecaseQb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$usecaseQb->expr()->eq('deleted', 0),
$usecaseQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$usecase) {
return $debugMode
? json_encode(['error' => 'Usecase not found', 'debug' => ['usecaseUid' => $usecaseUid]])
: '';
}
$response = [
'usecase' => $this->serializeUsecase($usecase),
'layout' => $layout,
'settings' => [
'layout' => $layout,
],
];
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId,
'usecaseUid' => $usecaseUid,
'layout' => $layout,
'settings' => $settings,
];
}
return json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* @param array<string,mixed> $usecase
* @return array<string,mixed>
*/
protected function serializeUsecase(array $usecase): array
{
$uid = (int)$usecase['uid'];
return [
'uid' => $uid,
'title' => (string)($usecase['title'] ?? ''),
'slug' => (string)($usecase['slug'] ?? ''),
'subtitle' => (string)($usecase['subtitle'] ?? ''),
'teaser' => (string)($usecase['teaser'] ?? ''),
'description' => (string)($usecase['description'] ?? ''),
'singlepid' => (string)($usecase['singlepid'] ?? ''),
'hideonapp' => (bool)($usecase['hideonapp'] ?? false),
'hideonwebsite' => (bool)($usecase['hideonwebsite'] ?? false),
'categories' => $this->getUsecaseCategories($uid),
'caseimage' => $this->getUsecaseImage($uid, 'caseimage'),
'logoimage' => $this->getUsecaseImage($uid, 'logoimage'),
];
}
/**
* @return array<string,mixed>|null
*/
protected function getUsecaseImage(int $usecaseUid, string $fieldName): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $queryBuilder
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$srcset = [];
foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
protected function getUsecaseCategories(int $usecaseUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
];
}, $categories);
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
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 to render a single usecase as JSON for headless output.
*
* Mirrors ProductShowJsonRenderer. Extbase repositories are unavailable in a
* UserFunc context, so tt_content and the usecase record are queried directly.
* Exception-safe: returns '' on any failure / when not applicable so headless
* `ifEmptyUnsetKey` drops the key entirely.
*/
class UsecaseShowJsonRenderer
{
public function render(string $content, array $conf): string
{
try {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
// Find the vitec_usecaseshow plugin on this page
$ttContentQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$contentElements = $ttContentQb
->select('*')
->from('tt_content')
->where(
$ttContentQb->expr()->eq('pid', $ttContentQb->createNamedParameter($pageId, ParameterType::INTEGER)),
$ttContentQb->expr()->eq('list_type', $ttContentQb->createNamedParameter('vitec_usecaseshow', ParameterType::STRING)),
$ttContentQb->expr()->eq('deleted', 0),
$ttContentQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($contentElements)) {
return '';
}
$contentElement = $contentElements[0];
// Parse FlexForm
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$usecaseUid = (int)($settings['usecase'] ?? 0);
$layout = (string)($settings['layout'] ?? 'default');
$debugMode = (bool)($settings['debug'] ?? false);
// Fall back to route / query parameter (uid or slug)
if (!$usecaseUid) {
$routeParams = $GLOBALS['TYPO3_REQUEST']->getQueryParams();
$usecaseParam = $routeParams['tx_vitec_usecaseshow']['usecase'] ?? null;
if ($usecaseParam) {
if (!is_numeric($usecaseParam)) {
$slugQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$bySlug = $slugQb
->select('uid')
->from('tx_vitec_domain_model_usecase')
->where(
$slugQb->expr()->eq('slug', $slugQb->createNamedParameter($usecaseParam)),
$slugQb->expr()->eq('deleted', 0),
$slugQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
$usecaseUid = (int)($bySlug['uid'] ?? 0);
} else {
$usecaseUid = (int)$usecaseParam;
}
}
}
if (!$usecaseUid) {
return $debugMode
? json_encode(['error' => 'No usecase selected or found', 'debug' => ['settings' => $settings]])
: '';
}
// Query the usecase
$usecaseQb = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_vitec_domain_model_usecase');
$usecase = $usecaseQb
->select('*')
->from('tx_vitec_domain_model_usecase')
->where(
$usecaseQb->expr()->eq('uid', $usecaseQb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$usecaseQb->expr()->eq('deleted', 0),
$usecaseQb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAssociative();
if (!$usecase) {
return $debugMode
? json_encode(['error' => 'Usecase not found', 'debug' => ['usecaseUid' => $usecaseUid]])
: '';
}
$response = [
'usecase' => $this->serializeUsecase($usecase),
'layout' => $layout,
'settings' => [
'layout' => $layout,
],
];
if ($debugMode) {
$response['debug'] = [
'pageId' => $pageId,
'usecaseUid' => $usecaseUid,
'layout' => $layout,
'settings' => $settings,
];
}
return json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
/**
* Serialize a single usecase DB row. Structurally identical to
* UsecaseListJsonRenderer::serializeUsecase() so list and detail
* endpoints expose the same usecase schema.
*
* @param array<string,mixed> $usecase
* @return array<string,mixed>
*/
protected function serializeUsecase(array $usecase): array
{
$uid = (int)$usecase['uid'];
return [
'uid' => $uid,
'title' => (string)($usecase['title'] ?? ''),
'slug' => (string)($usecase['slug'] ?? ''),
'subtitle' => (string)($usecase['subtitle'] ?? ''),
'teaser' => (string)($usecase['teaser'] ?? ''),
'description' => (string)($usecase['description'] ?? ''),
'singlepid' => (string)($usecase['singlepid'] ?? ''),
'hideonapp' => (bool)($usecase['hideonapp'] ?? false),
'hideonwebsite' => (bool)($usecase['hideonwebsite'] ?? false),
'categories' => $this->getUsecaseCategories($uid),
'caseimage' => $this->getUsecaseImage($uid, 'caseimage'),
'logoimage' => $this->getUsecaseImage($uid, 'logoimage'),
];
}
/**
* Resolve a single FAL image field (caseimage / logoimage) with srcset.
*
* @return array<string,mixed>|null
*/
protected function getUsecaseImage(int $usecaseUid, string $fieldName): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_file_reference');
$fileRefData = $queryBuilder
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
->from('sys_file_reference', 'sfr')
->where(
$queryBuilder->expr()->eq('sfr.tablenames', $queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.fieldname', $queryBuilder->createNamedParameter($fieldName, ParameterType::STRING)),
$queryBuilder->expr()->eq('sfr.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('sfr.deleted', 0),
$queryBuilder->expr()->eq('sfr.hidden', 0)
)
->orderBy('sfr.sorting_foreign')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$fileRefData) {
return null;
}
try {
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
$imageService = GeneralUtility::makeInstance(ImageService::class);
$fileReference = $resourceFactory->getFileReferenceObject((int)$fileRefData['uid']);
$srcset = [];
foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null]
);
return [
'uid' => (int)$fileRefData['uid'],
'url' => $imageService->getImageUri($default),
'title' => $fileRefData['title'] ?? '',
'alternative' => $fileRefData['alternative'] ?? '',
'description' => $fileRefData['description'] ?? '',
'srcset' => $srcset,
'properties' => [
'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'),
],
];
} catch (\Exception $e) {
return null;
}
}
/**
* Get categories for a usecase (resolved sys_category records).
*/
protected function getUsecaseCategories(int $usecaseUid): array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('sys_category');
$categories = $queryBuilder
->select('c.uid', 'c.title', 'c.description')
->from('sys_category', 'c')
->join(
'c',
'sys_category_record_mm',
'mm',
'mm.uid_local = c.uid AND mm.tablenames = ' .
$queryBuilder->createNamedParameter('tx_vitec_domain_model_usecase', ParameterType::STRING) .
' AND mm.fieldname = ' .
$queryBuilder->createNamedParameter('categories', ParameterType::STRING)
)
->where(
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('c.deleted', 0),
$queryBuilder->expr()->eq('c.hidden', 0)
)
->orderBy('mm.sorting', 'ASC')
->executeQuery()
->fetchAllAssociative();
return array_map(static function ($cat) {
return [
'uid' => (int)$cat['uid'],
'title' => $cat['title'] ?? '',
'description' => $cat['description'] ?? '',
];
}, $categories);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Evomedien\Vitec\Controller\OgImageController;
return [
'web_vitecogimage' => [
'parent' => 'web',
'position' => ['after' => 'web_info'],
'access' => 'user',
'workspaces' => 'live',
'path' => '/module/web/vitec-ogimage',
'labels' => [
'title' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_ogimage.xlf:mlang_tabs_tab',
'shortDescription' => 'LLL:EXT:vitec/Resources/Private/Language/locallang_ogimage.xlf:mlang_labels_tabdescr',
],
'extensionName' => 'Vitec',
'iconIdentifier' => 'vitec-ogimage',
'routes' => [
'_default' => [
'target' => OgImageController::class . '::indexAction',
],
'generate' => [
'target' => OgImageController::class . '::generateAction',
'methods' => ['POST'],
],
'preview' => [
'target' => OgImageController::class . '::previewAction',
'methods' => ['POST'],
],
],
],
];

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<T3DataStructure>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.flexform.sheet</sheetTitle>
<type>array</type>
<el>
<settings.cssClass>
<label>LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.cssclass.label</label>
<description>LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.cssclass.description</description>
<config>
<type>input</type>
<size>30</size>
<eval>trim</eval>
</config>
</settings.cssClass>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>

View File

@@ -3,27 +3,22 @@
<sheets> <sheets>
<sDEF> <sDEF>
<ROOT> <ROOT>
<sheetTitle> <sheetTitle>Usecase List</sheetTitle>
Select Usecase
</sheetTitle>
<type>array</type> <type>array</type>
<el> <el>
<settings.usecase> <settings.debug>
<label> <label>Allow Debug Output.</label>
Usecase
</label>
<config> <config>
<type>select</type> <type>check</type>
<renderType>selectSingle</renderType> <items type="array">
<foreign_table>tx_vitec_domain_model_usecase</foreign_table> <numIndex index="0" type="array">
<foreign_table_where>AND tx_vitec_domain_model_usecase.hidden = 0 AND tx_vitec_domain_model_usecase.deleted = 0 ORDER BY tx_vitec_domain_model_usecase.title</foreign_table_where> <label>LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.enabled</label>
<size>1</size> </numIndex>
<minitems>0</minitems> </items>
<maxitems>1</maxitems>
</config> </config>
</settings.usecase> </settings.debug>
</el> </el>
</ROOT> </ROOT>
</sDEF> </sDEF>
</sheets> </sheets>
</T3DataStructure> </T3DataStructure>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<T3DataStructure>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>
Select Usecase
</sheetTitle>
<type>array</type>
<el>
<settings.usecase>
<label>
Usecase
</label>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<foreign_table>tx_vitec_domain_model_usecase</foreign_table>
<foreign_table_where>AND tx_vitec_domain_model_usecase.hidden = 0 AND tx_vitec_domain_model_usecase.deleted = 0 ORDER BY tx_vitec_domain_model_usecase.title</foreign_table_where>
<size>1</size>
<minitems>0</minitems>
<maxitems>1</maxitems>
</config>
</settings.usecase>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>

View File

@@ -24,4 +24,8 @@ return [
'provider' => SvgIconProvider::class, 'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-cols-33-66.svg', 'source' => 'EXT:vitec/Resources/Public/Icons/vitec-cols-33-66.svg',
], ],
'vitec-ogimage' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-ogimage.svg',
],
]; ];

View File

@@ -8,28 +8,82 @@ config {
} }
} }
# Headless: Add products field to vitec_productlist # =============================================================================
# Headless list-plugin JSON renderers
#
# `tt_content.list.20.<list_type>` renders the element body for that subtype
# (only ever invoked for the matching list_type — safe as-is).
#
# `tt_content.list.fields.content.fields.<key>` is GLOBAL: without scoping it
# would inject <key> into EVERY list plugin's content. Each such field is
# therefore guarded with:
# stdWrap.if { equals.field = list_type ... } -> empty for other plugins
# ifEmptyUnsetKey = 1 -> empty key removed entirely
# (headless JsonContentObject honours ifEmptyUnsetKey: '' / false => unset).
# =============================================================================
# --- vitec_productlist --------------------------------------------------------
tt_content.list.20.vitec_productlist = USER tt_content.list.20.vitec_productlist = USER
tt_content.list.20.vitec_productlist { tt_content.list.20.vitec_productlist {
userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render
} }
# Override the JSON structure for vitec_productlist
tt_content.list.fields.content.fields.products = USER tt_content.list.fields.content.fields.products = USER
tt_content.list.fields.content.fields.products { tt_content.list.fields.content.fields.products {
userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render
stdWrap.if {
value = vitec_productlist
equals.field = list_type
}
ifEmptyUnsetKey = 1
} }
# Headless: Add product field to vitec_productshow # --- vitec_productshow --------------------------------------------------------
tt_content.list.20.vitec_productshow = USER tt_content.list.20.vitec_productshow = USER
tt_content.list.20.vitec_productshow { tt_content.list.20.vitec_productshow {
userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render
} }
# Override the JSON structure for vitec_productshow
tt_content.list.fields.content.fields.product = USER tt_content.list.fields.content.fields.product = USER
tt_content.list.fields.content.fields.product { tt_content.list.fields.content.fields.product {
userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render
stdWrap.if {
value = vitec_productshow
equals.field = list_type
}
ifEmptyUnsetKey = 1
}
# --- vitec_usecaselist --------------------------------------------------------
tt_content.list.20.vitec_usecaselist = USER
tt_content.list.20.vitec_usecaselist {
userFunc = Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer->render
}
tt_content.list.fields.content.fields.usecases = USER
tt_content.list.fields.content.fields.usecases {
userFunc = Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer->render
stdWrap.if {
value = vitec_usecaselist
equals.field = list_type
}
ifEmptyUnsetKey = 1
}
# --- vitec_usecaseshow --------------------------------------------------------
tt_content.list.20.vitec_usecaseshow = USER
tt_content.list.20.vitec_usecaseshow {
userFunc = Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer->render
}
tt_content.list.fields.content.fields.usecase = USER
tt_content.list.fields.content.fields.usecase {
userFunc = Evomedien\Vitec\UserFunc\UsecaseShowJsonRenderer->render
stdWrap.if {
value = vitec_usecaseshow
equals.field = list_type
}
ifEmptyUnsetKey = 1
} }
# Include container (layout) rendering definitions # Include container (layout) rendering definitions

View File

@@ -0,0 +1,44 @@
config {
pageTitleProviders {
vitec {
provider = Evomedien\Vitec\PageTitle\ProductPageTitleProvider
before = record
before = seo
}
}
}
# Headless: Add products field to vitec_productlist
tt_content.list.20.vitec_productlist = USER
tt_content.list.20.vitec_productlist {
userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render
}
# Override the JSON structure for vitec_productlist
tt_content.list.fields.content.fields.products = USER
tt_content.list.fields.content.fields.products {
userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render
}
# Headless: Add product field to vitec_productshow
tt_content.list.20.vitec_productshow = USER
tt_content.list.20.vitec_productshow {
userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render
}
# Override the JSON structure for vitec_productshow
tt_content.list.fields.content.fields.product = USER
tt_content.list.fields.content.fields.product {
userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render
}
# Include container (layout) rendering definitions
@import 'EXT:vitec/Configuration/TypoScript/Headless/vitec_containers.typoscript'
# Ensure headless's content element JSON definitions take precedence over
# fluid_styled_content's HTML definitions. This is loaded at the end of the
# Vitecset to guarantee winning load order.
@import 'EXT:headless/Configuration/TypoScript/ContentElement/*.typoscript'
# Include menu (navigation) JSON definitions
@import 'EXT:vitec/Configuration/TypoScript/Headless/vitec_menus.typoscript'

View File

@@ -0,0 +1,82 @@
config {
pageTitleProviders {
vitec {
provider = Evomedien\Vitec\PageTitle\ProductPageTitleProvider
before = record
before = seo
}
}
}
# =============================================================================
# Headless list-plugin JSON renderers
#
# `tt_content.list.20.<list_type>` renders the element body for that subtype
# (only ever invoked for the matching list_type — safe as-is).
#
# `tt_content.list.fields.content.fields.<key>` is GLOBAL: without scoping it
# would inject <key> into EVERY list plugin's content. Each such field is
# therefore guarded with:
# stdWrap.if { equals.field = list_type ... } -> empty for other plugins
# ifEmptyUnsetKey = 1 -> empty key removed entirely
# (headless JsonContentObject honours ifEmptyUnsetKey: '' / false => unset).
# =============================================================================
# --- vitec_productlist --------------------------------------------------------
tt_content.list.20.vitec_productlist = USER
tt_content.list.20.vitec_productlist {
userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render
}
tt_content.list.fields.content.fields.products = USER
tt_content.list.fields.content.fields.products {
userFunc = Evomedien\Vitec\UserFunc\ProductListJsonRenderer->render
stdWrap.if {
value = vitec_productlist
equals.field = list_type
}
ifEmptyUnsetKey = 1
}
# --- vitec_productshow --------------------------------------------------------
tt_content.list.20.vitec_productshow = USER
tt_content.list.20.vitec_productshow {
userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render
}
tt_content.list.fields.content.fields.product = USER
tt_content.list.fields.content.fields.product {
userFunc = Evomedien\Vitec\UserFunc\ProductShowJsonRenderer->render
stdWrap.if {
value = vitec_productshow
equals.field = list_type
}
ifEmptyUnsetKey = 1
}
# --- vitec_usecaselist --------------------------------------------------------
tt_content.list.20.vitec_usecaselist = USER
tt_content.list.20.vitec_usecaselist {
userFunc = Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer->render
}
tt_content.list.fields.content.fields.usecases = USER
tt_content.list.fields.content.fields.usecases {
userFunc = Evomedien\Vitec\UserFunc\UsecaseListJsonRenderer->render
stdWrap.if {
value = vitec_usecaselist
equals.field = list_type
}
ifEmptyUnsetKey = 1
}
# Include container (layout) rendering definitions
@import 'EXT:vitec/Configuration/TypoScript/Headless/vitec_containers.typoscript'
# Ensure headless's content element JSON definitions take precedence over
# fluid_styled_content's HTML definitions. This is loaded at the end of the
# Vitecset to guarantee winning load order.
@import 'EXT:headless/Configuration/TypoScript/ContentElement/*.typoscript'
# Include menu (navigation) JSON definitions
@import 'EXT:vitec/Configuration/TypoScript/Headless/vitec_menus.typoscript'

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
defined('TYPO3') or die();
use B13\Container\Tca\ContainerConfiguration;
use B13\Container\Tca\Registry;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
(static function (): void {
$l = 'LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:';
/** @var Registry $registry */
$registry = GeneralUtility::makeInstance(Registry::class);
$registry->configureContainer(
(new ContainerConfiguration(
'vitec_container',
$l . 'container.title',
$l . 'container.description',
[
[
['name' => $l . 'container.column', 'colPos' => 220],
],
]
))
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-container.svg')
->setGroup('vitec')
->setSaveAndCloseInNewContentElementWizard(true)
);
$GLOBALS['TCA']['tt_content']['types']['vitec_container']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
tx_vitec_bg_variant,
pi_flexform;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.flexform.label,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
--palette--;;language,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
--palette--;;hidden,
--palette--;;access';
// Bind the Container FlexForm to this CType.
// Core tt_content.pi_flexform uses ds_pointerField = 'list_type,CType'.
// The official helper registers the data structure under the key
// '*,vitec_container' (wildcard list_type + CType), which is the
// version-proof way to attach a FlexForm to a CType-based element.
ExtensionManagementUtility::addPiFlexFormValue(
'*',
'FILE:EXT:vitec/Configuration/FlexForms/Container.xml',
'vitec_container'
);
ExtensionManagementUtility::addPageTSConfig(<<<TSCONFIG
mod.wizards.newContentElement.wizardItems.vitec.elements.vitec_container {
iconIdentifier = vitec-container
title = LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.title
description = LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.description
tt_content_defValues {
CType = vitec_container
}
}
TSCONFIG);
})();

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
defined('TYPO3') or die();
use B13\Container\Tca\ContainerConfiguration;
use B13\Container\Tca\Registry;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
(static function (): void {
$l = 'LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:';
/** @var Registry $registry */
$registry = GeneralUtility::makeInstance(Registry::class);
$registry->configureContainer(
(new ContainerConfiguration(
'vitec_container',
$l . 'container.title',
$l . 'container.description',
[
[
['name' => $l . 'container.column', 'colPos' => 220],
],
]
))
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-container.svg')
->setGroup('vitec')
->setSaveAndCloseInNewContentElementWizard(true)
);
$GLOBALS['TCA']['tt_content']['types']['vitec_container']['showitem'] =
'--palette--;;general,
header;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.heading,
subheader;LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:section.subline,
tx_vitec_bg_variant,
pi_flexform,
--div--;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:tabs.appearance,
--palette--;;frames,
--palette--;;appearanceLinks,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language,
--palette--;;language,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access,
--palette--;;hidden,
--palette--;;access';
// Bind the Container FlexForm to this CType.
// Core tt_content.pi_flexform uses ds_pointerField = 'list_type,CType',
// so the CType value is a valid data-structure key.
$GLOBALS['TCA']['tt_content']['columns']['pi_flexform']['config']['ds']['vitec_container'] =
'FILE:EXT:vitec/Configuration/FlexForms/Container.xml';
ExtensionManagementUtility::addPageTSConfig(<<<TSCONFIG
mod.wizards.newContentElement.wizardItems.vitec.elements.vitec_container {
iconIdentifier = vitec-container
title = LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.title
description = LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:container.description
tt_content_defValues {
CType = vitec_container
}
}
TSCONFIG);
})();

View File

@@ -87,3 +87,14 @@ tt_content.vitec_cols_33_33_33 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_25_25_25_25 =< tt_content.vitec_cols_50_50 tt_content.vitec_cols_25_25_25_25 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_66_33 =< tt_content.vitec_cols_50_50 tt_content.vitec_cols_66_33 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_33_66 =< tt_content.vitec_cols_50_50 tt_content.vitec_cols_33_66 =< tt_content.vitec_cols_50_50
# -----------------------------------------------------------------------------
# VITEC · Container — single-column container with a custom CSS class.
# Inherits the full structure of the grid containers and adds the `cssClass`
# field, read from the element's FlexForm (settings.cssClass).
# -----------------------------------------------------------------------------
tt_content.vitec_container =< tt_content.vitec_cols_50_50
tt_content.vitec_container.fields.cssClass = TEXT
tt_content.vitec_container.fields.cssClass.data = flexform:pi_flexform:settings.cssClass
tt_content.vitec_container.fields.cssClass.stdWrap.ifEmpty.cObject = TEXT
tt_content.vitec_container.fields.cssClass.stdWrap.ifEmpty.cObject.value =

View File

@@ -0,0 +1,89 @@
# =============================================================================
# VITEC container content elements — self-contained JSON rendering
# Children collected via Evomedien\Vitec\DataProcessing\ContainerChildrenProcessor
# (exception-safe; returns [] on any error, never crashes the outer CE).
# =============================================================================
tt_content.vitec_cols_50_50 = JSON
tt_content.vitec_cols_50_50 {
fields {
id = INT
id.field = uid
type = TEXT
type.field = CType
colPos = INT
colPos.field = colPos
categories = COA
categories {
10 = CONTENT
10 {
table = sys_category
select {
pidInList = root
selectFields = sys_category.title
join = sys_category_record_mm on sys_category_record_mm.uid_local = sys_category.uid
where {
field = uid
wrap = AND sys_category_record_mm.tablenames = 'tt_content' AND sys_category_record_mm.uid_foreign=|
}
}
renderObj = TEXT
renderObj {
field = title
wrap = |###BREAK###
}
}
stdWrap.split {
token = ###BREAK###
cObjNum = 1 |*|2|*| 3
1 {
current = 1
stdWrap.wrap = |
}
2 {
current = 1
stdWrap.wrap = ,|
}
3 {
current = 1
stdWrap.wrap = |
}
}
}
appearance = JSON
appearance {
fields {
layout = TEXT
layout.field = layout
frameClass = TEXT
frameClass.field = frame_class
spaceBefore = TEXT
spaceBefore.field = space_before_class
spaceAfter = TEXT
spaceAfter.field = space_after_class
}
}
header = TEXT
header.field = header
subheader = TEXT
subheader.field = subheader
tx_vitec_bg_variant = TEXT
tx_vitec_bg_variant.field = tx_vitec_bg_variant
items = JSON
items {
dataProcessing {
10 = Evomedien\Vitec\DataProcessing\ContainerChildrenProcessor
10.as = items
}
}
}
}
tt_content.vitec_cols_33_33_33 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_25_25_25_25 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_66_33 =< tt_content.vitec_cols_50_50
tt_content.vitec_cols_33_66 =< tt_content.vitec_cols_50_50

View File

@@ -68,6 +68,28 @@
<source>Narrow sidebar + wide main column</source> <source>Narrow sidebar + wide main column</source>
</trans-unit> </trans-unit>
<trans-unit id="container.title" resname="container.title">
<source>VITEC · Container</source>
</trans-unit>
<trans-unit id="container.description" resname="container.description">
<source>Generic container for other content elements with a custom CSS class</source>
</trans-unit>
<trans-unit id="container.column" resname="container.column">
<source>Content</source>
</trans-unit>
<trans-unit id="container.flexform.sheet" resname="container.flexform.sheet">
<source>Container Settings</source>
</trans-unit>
<trans-unit id="container.flexform.label" resname="container.flexform.label">
<source>Container Settings</source>
</trans-unit>
<trans-unit id="container.cssclass.label" resname="container.cssclass.label">
<source>CSS Class</source>
</trans-unit>
<trans-unit id="container.cssclass.description" resname="container.cssclass.description">
<source>Custom CSS class that is rendered on the container in the frontend.</source>
</trans-unit>
<trans-unit id="column.1" resname="column.1"> <trans-unit id="column.1" resname="column.1">
<source>Column 1</source> <source>Column 1</source>
</trans-unit> </trans-unit>

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<xliff version="1.0">
<file source-language="en" datatype="plaintext" original="EXT:vitec/Resources/Private/Language/locallang_containers.xlf" product-name="vitec">
<header/>
<body>
<trans-unit id="group.header" resname="group.header">
<source>VITEC</source>
</trans-unit>
<trans-unit id="section.heading" resname="section.heading">
<source>Section Heading</source>
</trans-unit>
<trans-unit id="section.subline" resname="section.subline">
<source>Section Subline</source>
</trans-unit>
<trans-unit id="bg_variant.label" resname="bg_variant.label">
<source>Background Variant</source>
</trans-unit>
<trans-unit id="bg_variant.option.none" resname="bg_variant.option.none">
<source>None</source>
</trans-unit>
<trans-unit id="bg_variant.option.orange" resname="bg_variant.option.orange">
<source>Orange</source>
</trans-unit>
<trans-unit id="bg_variant.option.blue" resname="bg_variant.option.blue">
<source>Blue</source>
</trans-unit>
<trans-unit id="bg_variant.option.graphite" resname="bg_variant.option.graphite">
<source>Graphite</source>
</trans-unit>
<trans-unit id="bg_variant.option.midnight" resname="bg_variant.option.midnight">
<source>Midnight</source>
</trans-unit>
<trans-unit id="cols_50_50.title" resname="cols_50_50.title">
<source>VITEC · Two Columns (50 / 50)</source>
</trans-unit>
<trans-unit id="cols_50_50.description" resname="cols_50_50.description">
<source>Two equal columns</source>
</trans-unit>
<trans-unit id="cols_33_33_33.title" resname="cols_33_33_33.title">
<source>VITEC · Three Columns (33 / 33 / 33)</source>
</trans-unit>
<trans-unit id="cols_33_33_33.description" resname="cols_33_33_33.description">
<source>Three equal columns</source>
</trans-unit>
<trans-unit id="cols_25_25_25_25.title" resname="cols_25_25_25_25.title">
<source>VITEC · Four Columns (25 / 25 / 25 / 25)</source>
</trans-unit>
<trans-unit id="cols_25_25_25_25.description" resname="cols_25_25_25_25.description">
<source>Four equal columns</source>
</trans-unit>
<trans-unit id="cols_66_33.title" resname="cols_66_33.title">
<source>VITEC · Two Columns (66 / 33)</source>
</trans-unit>
<trans-unit id="cols_66_33.description" resname="cols_66_33.description">
<source>Wide main column + narrow sidebar</source>
</trans-unit>
<trans-unit id="cols_33_66.title" resname="cols_33_66.title">
<source>VITEC · Two Columns (33 / 66)</source>
</trans-unit>
<trans-unit id="cols_33_66.description" resname="cols_33_66.description">
<source>Narrow sidebar + wide main column</source>
</trans-unit>
<trans-unit id="column.1" resname="column.1">
<source>Column 1</source>
</trans-unit>
<trans-unit id="column.2" resname="column.2">
<source>Column 2</source>
</trans-unit>
<trans-unit id="column.3" resname="column.3">
<source>Column 3</source>
</trans-unit>
<trans-unit id="column.4" resname="column.4">
<source>Column 4</source>
</trans-unit>
<trans-unit id="column.main_66" resname="column.main_66">
<source>Main (66%)</source>
</trans-unit>
<trans-unit id="column.sidebar_33" resname="column.sidebar_33">
<source>Sidebar (33%)</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<xliff version="1.0">
<file source-language="en" datatype="plaintext" original="EXT:vitec/Resources/Private/Language/locallang_containers.xlf" product-name="vitec">
<header/>
<body>
<trans-unit id="group.header" resname="group.header">
<source>VITEC</source>
</trans-unit>
<trans-unit id="section.heading" resname="section.heading">
<source>Section Heading</source>
</trans-unit>
<trans-unit id="section.subline" resname="section.subline">
<source>Section Subline</source>
</trans-unit>
<trans-unit id="bg_variant.label" resname="bg_variant.label">
<source>Background Variant</source>
</trans-unit>
<trans-unit id="bg_variant.option.none" resname="bg_variant.option.none">
<source>None</source>
</trans-unit>
<trans-unit id="bg_variant.option.orange" resname="bg_variant.option.orange">
<source>Orange</source>
</trans-unit>
<trans-unit id="bg_variant.option.blue" resname="bg_variant.option.blue">
<source>Blue</source>
</trans-unit>
<trans-unit id="bg_variant.option.graphite" resname="bg_variant.option.graphite">
<source>Graphite</source>
</trans-unit>
<trans-unit id="bg_variant.option.midnight" resname="bg_variant.option.midnight">
<source>Midnight</source>
</trans-unit>
<trans-unit id="cols_50_50.title" resname="cols_50_50.title">
<source>VITEC · Two Columns (50 / 50)</source>
</trans-unit>
<trans-unit id="cols_50_50.description" resname="cols_50_50.description">
<source>Two equal columns</source>
</trans-unit>
<trans-unit id="cols_33_33_33.title" resname="cols_33_33_33.title">
<source>VITEC · Three Columns (33 / 33 / 33)</source>
</trans-unit>
<trans-unit id="cols_33_33_33.description" resname="cols_33_33_33.description">
<source>Three equal columns</source>
</trans-unit>
<trans-unit id="cols_25_25_25_25.title" resname="cols_25_25_25_25.title">
<source>VITEC · Four Columns (25 / 25 / 25 / 25)</source>
</trans-unit>
<trans-unit id="cols_25_25_25_25.description" resname="cols_25_25_25_25.description">
<source>Four equal columns</source>
</trans-unit>
<trans-unit id="cols_66_33.title" resname="cols_66_33.title">
<source>VITEC · Two Columns (66 / 33)</source>
</trans-unit>
<trans-unit id="cols_66_33.description" resname="cols_66_33.description">
<source>Wide main column + narrow sidebar</source>
</trans-unit>
<trans-unit id="cols_33_66.title" resname="cols_33_66.title">
<source>VITEC · Two Columns (33 / 66)</source>
</trans-unit>
<trans-unit id="cols_33_66.description" resname="cols_33_66.description">
<source>Narrow sidebar + wide main column</source>
</trans-unit>
<trans-unit id="container.title" resname="container.title">
<source>VITEC · Container</source>
</trans-unit>
<trans-unit id="container.description" resname="container.description">
<source>Generic container for other content elements with a custom CSS class</source>
</trans-unit>
<trans-unit id="container.column" resname="container.column">
<source>Content</source>
</trans-unit>
<trans-unit id="container.flexform.sheet" resname="container.flexform.sheet">
<source>Container Settings</source>
</trans-unit>
<trans-unit id="container.cssclass.label" resname="container.cssclass.label">
<source>CSS Class</source>
</trans-unit>
<trans-unit id="container.cssclass.description" resname="container.cssclass.description">
<source>Custom CSS class that is rendered on the container in the frontend.</source>
</trans-unit>
<trans-unit id="column.1" resname="column.1">
<source>Column 1</source>
</trans-unit>
<trans-unit id="column.2" resname="column.2">
<source>Column 2</source>
</trans-unit>
<trans-unit id="column.3" resname="column.3">
<source>Column 3</source>
</trans-unit>
<trans-unit id="column.4" resname="column.4">
<source>Column 4</source>
</trans-unit>
<trans-unit id="column.main_66" resname="column.main_66">
<source>Main (66%)</source>
</trans-unit>
<trans-unit id="column.sidebar_33" resname="column.sidebar_33">
<source>Sidebar (33%)</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="EXT:vitec/Resources/Private/Language/locallang_ogimage.xlf">
<body>
<trans-unit id="mlang_tabs_tab">
<source>OG Image</source>
</trans-unit>
<trans-unit id="mlang_labels_tabdescr">
<source>Generate Open Graph images (1200×630) for social media sharing.</source>
</trans-unit>
<trans-unit id="mlang_labels_tablabel">
<source>OG Image Generator</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1,7 +1,7 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" <html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
xmlns:n="http://typo3.org/ns/GeorgRinger/News/ViewHelpers"> xmlns:n="http://typo3.org/ns/GeorgRinger/News/ViewHelpers">
<div class="vitec-administration"> <div class="vitec-administration">
<f:flashMessages/> <f:flashMessages queueIdentifier="core.template.flashMessages" />
<h2>Hi</h2> <h2>Hi</h2>
<f:render section="main"/> <f:render section="main"/>
</div> </div>

View File

@@ -0,0 +1,288 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-typo3-fluid="true">
<f:layout name="Backend/Default" />
<f:section name="main">
<div class="module-docheader-bar module-docheader-bar-navigation">
<div class="module-docheader-bar-column-left">
<h2 class="t3js-title-inlineedit">OG Image Generator</h2>
</div>
</div>
<div class="row" id="vitec-ogimage-wrap">
<!-- ══════════════════════════════════════════════════════
LEFT: FORM
═══════════════════════════════════════════════════════ -->
<div class="col-md-7">
<f:flashMessages queueIdentifier="core.template.flashMessages" />
<form action="{f:be.uri(route: 'web_vitecogimage.generate')}" method="post" id="vitecOgForm" data-preview-url="{f:be.uri(route: 'web_vitecogimage.preview')}">
<!-- Preset quick-picks -->
<div class="card mb-3">
<div class="card-header"><strong>Quick Presets</strong></div>
<div class="card-body d-flex gap-2 flex-wrap">
<f:for each="{presets}" as="preset" key="key">
<button type="button" class="btn btn-outline-secondary btn-sm js-preset"
data-preset="{key}"
data-bg="{preset.bg_color}"
data-title-color="{preset.title_color}"
data-subtitle-color="{preset.subtitle_color}">
{preset.label}
</button>
</f:for>
</div>
</div>
<!-- Background -->
<div class="card mb-3">
<div class="card-header"><strong>Background</strong></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Type</label>
<div class="d-flex gap-3">
<label class="form-check-label">
<input class="form-check-input me-1 js-bg-type" type="radio"
name="ogimage[bg_type]" value="color"
<f:if condition="{formData.bg_type} == 'color'">checked</f:if>/>
Solid colour
</label>
<label class="form-check-label">
<input class="form-check-input me-1 js-bg-type" type="radio"
name="ogimage[bg_type]" value="image"
<f:if condition="{formData.bg_type} == 'image'">checked</f:if>/>
Image
</label>
</div>
</div>
<div id="bg-color-wrap" class="mb-3">
<label class="form-label">Background Colour</label>
<div class="d-flex align-items-center gap-2">
<input type="color" class="form-control form-control-color js-live"
name="ogimage[bg_color]" id="bgColor"
value="{formData.bg_color}" />
<input type="text" class="form-control js-hex-sync" style="max-width:120px"
data-target="bgColor" value="{formData.bg_color}" />
</div>
</div>
<div id="bg-image-wrap" class="mb-3" style="display:none">
<label class="form-label">Background Image</label>
<f:if condition="{backgroundImages}">
<f:then>
<select class="form-select js-live" name="ogimage[bg_image]">
<option value="">— none —</option>
<f:for each="{backgroundImages}" as="img">
<option value="{img.path}"
<f:if condition="{formData.bg_image} == {img.path}">selected</f:if>>{img.label}</option>
</f:for>
</select>
<small class="text-muted">Upload images to <code>fileadmin/og-backgrounds/</code> to list them here.</small>
</f:then>
<f:else>
<input type="text" class="form-control js-live"
name="ogimage[bg_image]"
placeholder="/fileadmin/og-backgrounds/my-bg.jpg"
value="{formData.bg_image}" />
<small class="text-muted">No images found in <code>fileadmin/og-backgrounds/</code>. Enter a path manually, or upload files there.</small>
</f:else>
</f:if>
<div class="mt-2">
<label class="form-label">Dark overlay opacity: <span id="overlayLabel">{formData.overlay_opacity}</span>%</label>
<input type="range" class="form-range js-live" min="0" max="90" step="5"
name="ogimage[overlay_opacity]" value="{formData.overlay_opacity}"
oninput="document.getElementById('overlayLabel').textContent=this.value"/>
</div>
</div>
</div>
</div>
<!-- Text -->
<div class="card mb-3">
<div class="card-header"><strong>Text</strong></div>
<div class="card-body">
<div class="row mb-3">
<div class="col-8">
<label class="form-label">Title</label>
<input type="text" class="form-control js-live"
name="ogimage[title]" value="{formData.title}"
placeholder="Your headline" />
</div>
<div class="col-2">
<label class="form-label">Size</label>
<input type="number" class="form-control js-live"
name="ogimage[title_size]" value="{formData.title_size}"
min="20" max="120" />
</div>
<div class="col-2">
<label class="form-label">Colour</label>
<input type="color" class="form-control form-control-color js-live"
name="ogimage[title_color]" id="titleColor"
value="{formData.title_color}" />
</div>
</div>
<div class="row mb-2">
<div class="col-8">
<label class="form-label">Subtitle / Description</label>
<input type="text" class="form-control js-live"
name="ogimage[subtitle]" value="{formData.subtitle}"
placeholder="Short description" />
</div>
<div class="col-2">
<label class="form-label">Size</label>
<input type="number" class="form-control js-live"
name="ogimage[subtitle_size]" value="{formData.subtitle_size}"
min="14" max="80" />
</div>
<div class="col-2">
<label class="form-label">Colour</label>
<input type="color" class="form-control form-control-color js-live"
name="ogimage[subtitle_color]" id="subtitleColor"
value="{formData.subtitle_color}" />
</div>
</div>
</div>
</div>
<!-- Label / Badge -->
<div class="card mb-3">
<div class="card-header"><strong>Label / Badge</strong></div>
<div class="card-body">
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Label text (leave blank to hide)</label>
<input type="text" class="form-control js-live"
name="ogimage[label_text]" value="{formData.label_text}"
placeholder="e.g. VITEC" />
</div>
<div class="col-3">
<label class="form-label">Background</label>
<input type="color" class="form-control form-control-color js-live"
name="ogimage[label_bg_color]" id="labelBg"
value="{formData.label_bg_color}" />
</div>
<div class="col-3">
<label class="form-label">Text colour</label>
<input type="color" class="form-control form-control-color js-live"
name="ogimage[label_text_color]" id="labelText"
value="{formData.label_text_color}" />
</div>
</div>
<div class="mb-2">
<label class="form-label">Position</label>
<select class="form-select js-live" name="ogimage[label_position]">
<option value="top-left" <f:if condition="{formData.label_position} == 'top-left'">selected</f:if>>Top Left</option>
<option value="top-right" <f:if condition="{formData.label_position} == 'top-right'">selected</f:if>>Top Right</option>
<option value="bottom-left" <f:if condition="{formData.label_position} == 'bottom-left'">selected</f:if>>Bottom Left</option>
<option value="bottom-right"<f:if condition="{formData.label_position} == 'bottom-right'">selected</f:if>>Bottom Right</option>
</select>
</div>
</div>
</div>
<!-- Output -->
<div class="card mb-3">
<div class="card-header"><strong>Output</strong></div>
<div class="card-body row">
<div class="col-4">
<label class="form-label">Format</label>
<select class="form-select" name="ogimage[output_format]">
<option value="jpg" <f:if condition="{formData.output_format} == 'jpg'">selected</f:if>>JPG</option>
<option value="png" <f:if condition="{formData.output_format} == 'png'">selected</f:if>>PNG</option>
</select>
</div>
<div class="col-4">
<label class="form-label">Quality (JPG): <span id="qualLabel">{formData.output_quality}</span></label>
<input type="range" class="form-range" min="50" max="100" step="5"
name="ogimage[output_quality]" value="{formData.output_quality}"
oninput="document.getElementById('qualLabel').textContent=this.value"/>
</div>
</div>
</div>
<div class="d-flex gap-2 mb-4">
<button type="button" class="btn btn-secondary" id="btnPreview">
Preview
</button>
<button type="submit" class="btn btn-primary">
Save Image to fileadmin
</button>
</div>
</form>
</div><!-- /col form -->
<!-- ══════════════════════════════════════════════════════
RIGHT: PREVIEW + SAVED IMAGES
═══════════════════════════════════════════════════════ -->
<div class="col-md-5">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Preview <small class="text-muted">(1200 × 630)</small></strong>
<span id="previewSpinner" class="spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
</div>
<div class="card-body p-2 bg-secondary">
<div style="position:relative; padding-top: 52.5%;"><!-- 630/1200 -->
<img id="ogPreviewImg" src="" alt="preview"
style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;display:none;" />
<div id="ogPreviewPlaceholder"
style="position:absolute;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:#aaa;">
Click "Preview" to render
</div>
</div>
</div>
<div class="card-footer text-end">
<a id="previewDownload" href="#" download="og-preview.png"
class="btn btn-sm btn-outline-secondary d-none">
Download preview
</a>
</div>
</div>
<f:if condition="{savedImages}">
<div class="card">
<div class="card-header"><strong>Recent saved images</strong></div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead><tr><th>File</th><th>Created</th><th></th></tr></thead>
<tbody>
<f:for each="{savedImages}" as="img">
<tr>
<td class="text-truncate" style="max-width:140px">{img.name}</td>
<td><small>{img.created}</small></td>
<td>
<a href="{img.path}" target="_blank" class="btn btn-xs btn-outline-primary p-1 lh-1">
View
</a>
</td>
</tr>
</f:for>
</tbody>
</table>
</div>
</div>
</f:if>
</div><!-- /col preview -->
</div><!-- /row -->
</f:section>
</html>

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<rect x="1.5" y="2.5" width="13" height="11" rx="1" fill="none" stroke="#000" stroke-width="1.2"/>
<rect x="3.5" y="4.5" width="9" height="2.2" rx="0.3" fill="#000" opacity="0.55"/>
<rect x="3.5" y="7.6" width="9" height="2.2" rx="0.3" fill="#000" opacity="0.35"/>
<rect x="3.5" y="10.7" width="9" height="1.6" rx="0.3" fill="#000" opacity="0.2"/>
</svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
<rect x="1" y="3" width="14" height="10" rx="1.5" fill="#e63946"/>
<rect x="1" y="3" width="14" height="10" rx="1.5" stroke="#c1121f" stroke-width="0.5"/>
<text x="2" y="10.5" font-size="4.5" font-family="sans-serif" fill="#fff" font-weight="bold">OG</text>
<circle cx="11.5" cy="6.5" r="2" fill="#fff" opacity="0.9"/>
<polyline points="1,11 5,7 8,9.5 11,6 15,10" stroke="#fff" stroke-width="1" fill="none" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@@ -0,0 +1,173 @@
/**
* OG Image Backend Module live preview via AJAX
* EXT:vitec/Resources/Public/Javascript/og-image-preview.js
*/
(function () {
'use strict';
/** Debounce helper */
function debounce(fn, ms) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), ms);
};
}
/** Collect all form values into a FormData-compatible plain object */
function collectFormData() {
const form = document.getElementById('vitecOgForm');
if (!form) return {};
const fd = new FormData(form);
const out = {};
for (const [key, val] of fd.entries()) {
// key looks like "ogimage[something]"
const match = key.match(/^ogimage\[(.+)]$/);
if (match) {
out[key] = val;
}
}
return out;
}
/** POST to the preview endpoint, update the <img> */
async function requestPreview() {
const form = document.getElementById('vitecOgForm');
const spinner = document.getElementById('previewSpinner');
const img = document.getElementById('ogPreviewImg');
const placeholder = document.getElementById('ogPreviewPlaceholder');
const dl = document.getElementById('previewDownload');
if (!form) return;
if (spinner) spinner.classList.remove('d-none');
try {
const body = new URLSearchParams(collectFormData());
const previewUrl = form.dataset.previewUrl || window.location.href;
const resp = await fetch(previewUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!resp.ok) throw new Error('Server error ' + resp.status);
const json = await resp.json();
if (json.success && json.dataUri) {
img.src = json.dataUri;
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
if (dl) {
dl.href = json.dataUri;
dl.classList.remove('d-none');
}
} else {
console.warn('[OG Preview]', json.message);
}
} catch (e) {
console.error('[OG Preview] fetch failed:', e);
} finally {
if (spinner) spinner.classList.add('d-none');
}
}
const debouncedPreview = debounce(requestPreview, 600);
// ── Wire up events once DOM is ready ──────────────────────────────
document.addEventListener('DOMContentLoaded', function () {
// Manual preview button
const btn = document.getElementById('btnPreview');
if (btn) {
btn.addEventListener('click', requestPreview);
}
// Auto-preview on any .js-live input change
document.querySelectorAll('.js-live').forEach(function (el) {
el.addEventListener('input', debouncedPreview);
el.addEventListener('change', debouncedPreview);
});
// Background type toggle (show colour vs image picker)
function syncBgType() {
const colorWrap = document.getElementById('bg-color-wrap');
const imageWrap = document.getElementById('bg-image-wrap');
const selected = document.querySelector('input.js-bg-type:checked');
if (!selected) return;
if (selected.value === 'image') {
colorWrap && (colorWrap.style.display = 'none');
imageWrap && (imageWrap.style.display = 'block');
} else {
colorWrap && (colorWrap.style.display = 'block');
imageWrap && (imageWrap.style.display = 'none');
}
}
document.querySelectorAll('input.js-bg-type').forEach(function (el) {
el.addEventListener('change', function () {
syncBgType();
debouncedPreview();
});
});
const bgImageSelect = document.querySelector('select[name="ogimage[bg_image]"]');
if (bgImageSelect) {
bgImageSelect.addEventListener('change', function () {
const imageTypeRadio = document.querySelector('input.js-bg-type[value="image"]');
if (imageTypeRadio) {
imageTypeRadio.checked = true;
}
syncBgType();
requestPreview();
});
}
syncBgType(); // initialise on load
// Hex text ↔ colour picker sync
document.querySelectorAll('.js-hex-sync').forEach(function (hexInput) {
const targetId = hexInput.dataset.target;
const picker = document.getElementById(targetId);
if (!picker) return;
picker.addEventListener('input', function () {
hexInput.value = picker.value;
});
hexInput.addEventListener('input', function () {
const val = hexInput.value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
picker.value = val;
picker.dispatchEvent(new Event('input', { bubbles: true }));
}
});
});
// Preset buttons
document.querySelectorAll('.js-preset').forEach(function (btn) {
btn.addEventListener('click', function () {
const bg = btn.dataset.bg;
const tc = btn.dataset.titleColor;
const sc = btn.dataset.subtitleColor;
const bgInput = document.getElementById('bgColor');
if (bgInput && bg) { bgInput.value = bg; bgInput.dispatchEvent(new Event('input', {bubbles:true})); }
const tInput = document.getElementById('titleColor');
if (tInput && tc) { tInput.value = tc; tInput.dispatchEvent(new Event('input', {bubbles:true})); }
const sInput = document.getElementById('subtitleColor');
if (sInput && sc) { sInput.value = sc; sInput.dispatchEvent(new Event('input', {bubbles:true})); }
debouncedPreview();
});
});
});
})();

View File

@@ -4,6 +4,14 @@ defined('TYPO3') || die();
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Imaging\IconRegistry; use TYPO3\CMS\Core\Imaging\IconRegistry;
use TYPO3\CMS\Extbase\Utility\ExtensionUtility; use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
// Configure view template paths for the OG Image backend module
$GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['vitec']['view'] = [
'templateRootPaths' => ['10' => 'EXT:vitec/Resources/Private/Templates/'],
'layoutRootPaths' => ['10' => 'EXT:vitec/Resources/Private/Layouts/'],
'partialRootPaths' => ['10' => 'EXT:vitec/Resources/Private/Partials/'],
];
// Register VITEC Backend Layout data provider — 21 BE layouts, one per pages.layout (0..20) // Register VITEC Backend Layout data provider — 21 BE layouts, one per pages.layout (0..20)
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['BackendLayoutDataProvider']['vitec'] $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['BackendLayoutDataProvider']['vitec']
= \Evomedien\Vitec\View\BackendLayoutDataProvider::class; = \Evomedien\Vitec\View\BackendLayoutDataProvider::class;