Formular-Infrastruktur: Contact/Demo/Helpdesk mit E-Mail- und Salesforce-Delivery, JSON-Renderer-Erweiterungen, Crop-Variants

Build-Artefakte in public/_frontend/assets nicht enthalten.
This commit is contained in:
2026-07-30 13:12:45 +02:00
parent eae40f4286
commit a739dd8fe3
51 changed files with 1598 additions and 303 deletions

530
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,8 +17,8 @@ cd "$(dirname "$0")" || exit 1
echo "[perms] Project root: $(pwd)" echo "[perms] Project root: $(pwd)"
echo "[perms] Setting files 644 / directories 755 under Resources/Public + _assets ..." echo "[perms] Setting files 644 / directories 755 under Resources/Public + _assets ..."
find public/_assets packages/vitec/Resources/Public -type f -exec chmod 644 {} \; 2>/dev/null find public/_assets public/fileadmin/icons packages/vitec/Resources/Public -type f -exec chmod 644 {} \; 2>/dev/null
find public/_assets packages/vitec/Resources/Public -type d -exec chmod 755 {} \; 2>/dev/null find public/_assets public/fileadmin/icons packages/vitec/Resources/Public -type d -exec chmod 755 {} \; 2>/dev/null
echo "[perms] Flushing TYPO3 caches ..." echo "[perms] Flushing TYPO3 caches ..."
if [ -x vendor/bin/typo3 ]; then if [ -x vendor/bin/typo3 ]; then

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Controller;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
/**
* FormController — Extbase registration target for the VITEC form plugins
* (Contactform, Demoform, Helpdeskform). In headless mode the JSON output
* is produced by FormsJsonRenderer; submissions are handled by
* FormSubmissionMiddleware.
*/
class FormController extends ActionController
{
public function listAction(): ResponseInterface
{
return $this->htmlResponse();
}
}

View File

@@ -15,6 +15,7 @@ use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer; use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\LocationsJsonRenderer; use Evomedien\Vitec\UserFunc\LocationsJsonRenderer;
use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer; use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer; use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer; use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\Connection;
@@ -117,6 +118,9 @@ final class ContainerChildrenProcessor implements DataProcessorInterface
'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'], 'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'],
'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'], 'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'],
'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'], 'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'],
'vitec_contactform' => [FormsJsonRenderer::class, 'form'],
'vitec_demoform' => [FormsJsonRenderer::class, 'form'],
'vitec_helpdeskform' => [FormsJsonRenderer::class, 'form'],
'news_pi1' => [NewsJsonRenderer::class, 'news'], 'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'], 'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'], 'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Forms\Delivery;
/**
* Delivery strategy for VITEC form submissions.
* Implementations MUST throw on failure — the middleware logs the error
* on the stored submission and keeps the data safe.
*/
interface DeliveryInterface
{
/**
* @param array<string,string> $payload validated, filtered field values
* @param array<string,mixed> $settings FlexForm settings of the form plugin
*/
public function deliver(string $formKey, array $payload, array $settings): void;
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Forms\Delivery;
use TYPO3\CMS\Core\Mail\MailMessage;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Sends the submission as a plain-text mail to the FlexForm-configured
* recipient(s). Sender = system default (MAIL settings); reply-to = the
* submitter's email when present.
*/
final class EmailDelivery implements DeliveryInterface
{
public function deliver(string $formKey, array $payload, array $settings): void
{
$recipients = GeneralUtility::trimExplode(',', (string)($settings['recipient'] ?? ''), true);
if ($recipients === []) {
throw new \RuntimeException('No recipient configured in the form plugin (FlexForm "Recipient").');
}
$subject = trim((string)($settings['subject'] ?? ''));
if ($subject === '') {
$subject = 'VITEC website form: ' . $formKey;
}
$lines = [];
foreach ($payload as $field => $value) {
$lines[] = str_pad($field . ':', 16) . $value;
}
$mail = GeneralUtility::makeInstance(MailMessage::class);
$mail->to(...$recipients)
->subject($subject)
->text("Form: $formKey\n\n" . implode("\n", $lines));
$submitterEmail = trim((string)($payload['email'] ?? ''));
if ($submitterEmail !== '' && filter_var($submitterEmail, FILTER_VALIDATE_EMAIL)) {
$mail->replyTo($submitterEmail);
}
if (!$mail->send()) {
throw new \RuntimeException('MailMessage::send() reported failure.');
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Forms\Delivery;
/**
* Salesforce delivery — PREPARED STUB.
*
* The old website posted the contact form to Salesforce Web-to-Lead
* (custom field ids like 00N3z00000DCiyV). When credentials / the target
* setup are decided, implement deliver() here (Web-to-Lead POST or REST
* API) including the camelCase -> Salesforce field mapping. Until then any
* submission with delivery=salesforce is stored with status "failed" and
* this message — no data is lost.
*/
final class SalesforceDelivery implements DeliveryInterface
{
public function deliver(string $formKey, array $payload, array $settings): void
{
throw new \RuntimeException(
'Salesforce delivery is prepared but not configured yet — submission kept in the log.'
);
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Forms;
/**
* Single source of truth for the VITEC form plugins.
*
* The SAME definition drives (a) the JSON the React frontend renders the
* form from and (b) the server-side validation of submissions in
* FormSubmissionMiddleware. Field names are camelCase (React contract);
* mapping to Salesforce field ids happens inside the Salesforce delivery.
*
* Field spec: name, type (text|email|tel|select|textarea), label, required,
* options (select only) OR optionsSource (frontend-supplied list, e.g. the
* ISO country list).
*/
final class FormDefinitions
{
public const HONEYPOT_FIELD = '_website';
/** CType => form key */
public const CTYPE_MAP = [
'vitec_contactform' => 'contact',
'vitec_demoform' => 'demo',
'vitec_helpdeskform' => 'helpdesk',
];
private const SOLUTION_OPTIONS = [
'IPTV Distribution',
'Digital Signage',
'Video Streaming',
'Video Contribution',
'Situational Awareness and ISR',
'Remote Production',
'Encoders & Decoders',
'Custom Design Service',
];
/**
* @return array<string,mixed>|null
*/
public static function get(string $formKey): ?array
{
$all = self::all();
return $all[$formKey] ?? null;
}
/**
* @return array<string,array<string,mixed>>
*/
public static function all(): array
{
$contactFields = [
self::text('firstName', 'First Name', true),
self::text('lastName', 'Last Name', true),
self::text('company', 'Company', true),
self::field('email', 'email', 'Email', true),
self::field('phone', 'tel', 'Phone', true),
self::text('street', 'Street', false),
self::text('city', 'City', false),
self::text('zip', 'Zip / Postal Code', false),
self::countrySelect(),
self::select('state', 'State', false, [], 'usStates'),
self::select('solution', 'Solution of Interest', false, self::SOLUTION_OPTIONS),
self::field('message', 'textarea', 'Message', false),
];
return [
'contact' => [
'formKey' => 'contact',
'title' => 'Contact VITEC',
'fields' => $contactFields,
],
'demo' => [
'formKey' => 'demo',
'title' => 'Request a Demo',
'fields' => $contactFields,
],
'helpdesk' => [
'formKey' => 'helpdesk',
'title' => 'Request Access to Online HelpDesk',
'fields' => [
self::text('firstName', 'First Name', true),
self::text('lastName', 'Last Name', true),
self::text('company', 'Company', true),
self::field('phone', 'tel', 'Phone Number', false),
self::field('email', 'email', 'Email Address', true),
self::countrySelect(true),
self::text('product', 'Product', false),
self::text('serialNumber', 'Serial Number', false),
],
],
];
}
/**
* Validate a submission payload against the definition.
*
* @param array<string,mixed> $payload
* @return array<string,string> field => error (empty = valid)
*/
public static function validate(string $formKey, array $payload): array
{
$definition = self::get($formKey);
if ($definition === null) {
return ['_form' => 'Unknown form'];
}
$errors = [];
foreach ($definition['fields'] as $field) {
$name = $field['name'];
$value = trim((string)($payload[$name] ?? ''));
if (($field['required'] ?? false) && $value === '') {
$errors[$name] = 'This field is required.';
continue;
}
if ($value === '') {
continue;
}
if ($field['type'] === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$name] = 'Please enter a valid email address.';
}
if (mb_strlen($value) > 5000) {
$errors[$name] = 'Value is too long.';
}
}
return $errors;
}
/**
* Reduce a raw payload to the defined fields (drops everything unknown).
*
* @param array<string,mixed> $payload
* @return array<string,string>
*/
public static function filterPayload(string $formKey, array $payload): array
{
$definition = self::get($formKey);
if ($definition === null) {
return [];
}
$out = [];
foreach ($definition['fields'] as $field) {
$out[$field['name']] = trim((string)($payload[$field['name']] ?? ''));
}
return $out;
}
// ------------------------------------------------------------ field DSL
/** @return array<string,mixed> */
private static function field(string $name, string $type, string $label, bool $required): array
{
return ['name' => $name, 'type' => $type, 'label' => $label, 'required' => $required];
}
/** @return array<string,mixed> */
private static function text(string $name, string $label, bool $required): array
{
return self::field($name, 'text', $label, $required);
}
/**
* @param array<int,string> $options
* @return array<string,mixed>
*/
private static function select(string $name, string $label, bool $required, array $options, string $optionsSource = ''): array
{
$field = self::field($name, 'select', $label, $required);
if ($options !== []) {
$field['options'] = $options;
}
if ($optionsSource !== '') {
// The frontend supplies this list (e.g. ISO countries) — keeps
// the definition lean and the lists consistent app-wide.
$field['optionsSource'] = $optionsSource;
}
return $field;
}
/** @return array<string,mixed> */
private static function countrySelect(bool $required = false): array
{
return self::select('country', 'Country', $required, [], 'countries');
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Middleware;
use Evomedien\Vitec\Forms\Delivery\DeliveryInterface;
use Evomedien\Vitec\Forms\Delivery\EmailDelivery;
use Evomedien\Vitec\Forms\Delivery\SalesforceDelivery;
use Evomedien\Vitec\Forms\FormDefinitions;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Headless form endpoint: POST /api/vitec/form/<formKey>
*
* Accepts JSON (or form-encoded) submissions from the React frontend for
* the VITEC form plugins (contact | demo | helpdesk):
*
* 1. honeypot check (silently accepted, nothing stored)
* 2. server-side validation against FormDefinitions (single source of truth)
* 3. submission stored in tx_vitec_form_submission (backup log)
* 4. delivery via strategy: email (active) or salesforce (prepared stub) —
* configured in the FlexForm of the form's plugin element
*
* A stored-but-undelivered submission still responds success:true — the
* data is safe in the log and the delivery status is visible in the backend.
*/
final class FormSubmissionMiddleware implements MiddlewareInterface
{
private const PATH_PATTERN = '#^/api/vitec/form/([a-z]+)/?$#';
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = $request->getUri()->getPath();
if (preg_match(self::PATH_PATTERN, $path, $m) !== 1) {
return $handler->handle($request);
}
$formKey = $m[1];
if (strtoupper($request->getMethod()) !== 'POST') {
return new JsonResponse(['success' => false, 'errors' => ['_form' => 'POST only']], 405);
}
if (FormDefinitions::get($formKey) === null) {
return new JsonResponse(['success' => false, 'errors' => ['_form' => 'Unknown form']], 404);
}
try {
$payload = $this->parseBody($request);
// Honeypot: pretend success, store nothing.
if (trim((string)($payload[FormDefinitions::HONEYPOT_FIELD] ?? '')) !== '') {
return new JsonResponse(['success' => true]);
}
$errors = FormDefinitions::validate($formKey, $payload);
if ($errors !== []) {
return new JsonResponse(['success' => false, 'errors' => $errors], 422);
}
$clean = FormDefinitions::filterPayload($formKey, $payload);
$settings = $this->pluginSettings($formKey);
$method = (string)($settings['delivery'] ?? 'email');
$submissionUid = $this->store($formKey, $clean, $method);
try {
$this->delivery($method)->deliver($formKey, $clean, $settings);
$this->updateStatus($submissionUid, 'sent', '');
} catch (\Throwable $e) {
$this->updateStatus($submissionUid, 'failed', $e->getMessage());
}
// Data is safely logged either way.
return new JsonResponse(['success' => true]);
} catch (\Throwable $e) {
return new JsonResponse(['success' => false, 'errors' => ['_form' => 'Unexpected error']], 500);
}
}
/** @return array<string,mixed> */
private function parseBody(ServerRequestInterface $request): array
{
$contentType = $request->getHeaderLine('Content-Type');
if (str_contains($contentType, 'application/json')) {
$decoded = json_decode((string)$request->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
$parsed = $request->getParsedBody();
return is_array($parsed) ? $parsed : [];
}
/**
* Delivery settings come from the FlexForm of the (first) plugin element
* of this form type — one configuration per form type.
*
* @return array<string,mixed>
*/
private function pluginSettings(string $formKey): array
{
try {
$ctype = array_search($formKey, FormDefinitions::CTYPE_MAP, true);
if ($ctype === false) {
return [];
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$row = $qb
->select('pi_flexform')
->from('tt_content')
->where(
$qb->expr()->eq('CType', $qb->createNamedParameter($ctype)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->orderBy('uid', 'ASC')
->setMaxResults(1)
->executeQuery()
->fetchAssociative();
if (!$row) {
return [];
}
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$data = $flexFormService->convertFlexFormContentToArray((string)$row['pi_flexform']);
return $data['settings'] ?? [];
} catch (\Throwable $e) {
return [];
}
}
private function delivery(string $method): DeliveryInterface
{
return $method === 'salesforce'
? GeneralUtility::makeInstance(SalesforceDelivery::class)
: GeneralUtility::makeInstance(EmailDelivery::class);
}
/** @param array<string,string> $payload */
private function store(string $formKey, array $payload, string $method): int
{
$conn = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tx_vitec_form_submission');
$conn->insert('tx_vitec_form_submission', [
'pid' => 0,
'crdate' => time(),
'tstamp' => time(),
'form_key' => $formKey,
'payload' => (string)json_encode($payload, JSON_UNESCAPED_UNICODE),
'delivery_method' => $method,
'delivery_status' => 'pending',
]);
return (int)$conn->lastInsertId();
}
private function updateStatus(int $uid, string $status, string $error): void
{
GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tx_vitec_form_submission')
->update(
'tx_vitec_form_submission',
['delivery_status' => $status, 'delivery_error' => mb_substr($error, 0, 1000), 'tstamp' => time()],
['uid' => $uid]
);
}
}

View File

@@ -17,6 +17,7 @@ use Evomedien\Vitec\UserFunc\DatasheetsJsonRenderer;
use Evomedien\Vitec\UserFunc\EventlistJsonRenderer; use Evomedien\Vitec\UserFunc\EventlistJsonRenderer;
use Evomedien\Vitec\UserFunc\LocationsJsonRenderer; use Evomedien\Vitec\UserFunc\LocationsJsonRenderer;
use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer; use Evomedien\Vitec\UserFunc\CustomerlogosJsonRenderer;
use Evomedien\Vitec\UserFunc\FormsJsonRenderer;
use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer; use Evomedien\Vitec\UserFunc\ModelcardJsonRenderer;
use Evomedien\Vitec\UserFunc\NewsJsonRenderer; use Evomedien\Vitec\UserFunc\NewsJsonRenderer;
use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -82,6 +83,9 @@ final class ContentElementResolver
'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'], 'vitec_locationlist' => [LocationsJsonRenderer::class, 'locations'],
'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'], 'vitec_customerlogos' => [CustomerlogosJsonRenderer::class, 'customerlogos'],
'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'], 'vitec_modelcard' => [ModelcardJsonRenderer::class, 'card'],
'vitec_contactform' => [FormsJsonRenderer::class, 'form'],
'vitec_demoform' => [FormsJsonRenderer::class, 'form'],
'vitec_helpdeskform' => [FormsJsonRenderer::class, 'form'],
'news_pi1' => [NewsJsonRenderer::class, 'news'], 'news_pi1' => [NewsJsonRenderer::class, 'news'],
'news_newsliststicky' => [NewsJsonRenderer::class, 'news'], 'news_newsliststicky' => [NewsJsonRenderer::class, 'news'],
'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'], 'news_newsselectedlist' => [NewsJsonRenderer::class, 'news'],

View File

@@ -9,6 +9,7 @@ use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\ResourceFactory; use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService; use TYPO3\CMS\Extbase\Service\ImageService;
use TYPO3\CMS\Core\Imaging\ImageManipulation\CropVariantCollection;
/** /**
* Serialises a Success Story (tx_vitec_domain_model_usecase) DB row to the * Serialises a Success Story (tx_vitec_domain_model_usecase) DB row to the
@@ -43,7 +44,7 @@ final class UsecaseSerializer
'slug' => (string)($u['slug'] ?? ''), 'slug' => (string)($u['slug'] ?? ''),
'subtitle' => (string)($u['subtitle'] ?? ''), 'subtitle' => (string)($u['subtitle'] ?? ''),
'teaser' => (string)($u['teaser'] ?? ''), 'teaser' => (string)($u['teaser'] ?? ''),
'cardImage' => $this->image($uid, 'card_image'), 'cardImage' => $this->image($uid, 'card_image', null, true),
'customerLogo' => $this->image($uid, 'customer_logo'), 'customerLogo' => $this->image($uid, 'customer_logo'),
'markets' => $this->relationMulti('tx_vitec_usecase_market_mm', 'tx_vitec_domain_model_market', $uid), 'markets' => $this->relationMulti('tx_vitec_usecase_market_mm', 'tx_vitec_domain_model_market', $uid),
'categories' => $this->categories($uid), 'categories' => $this->categories($uid),
@@ -100,7 +101,7 @@ final class UsecaseSerializer
* *
* @return array<string,mixed>|null * @return array<string,mixed>|null
*/ */
public function image(int $recordUid, string $fieldName, ?string $table = null): ?array public function image(int $recordUid, string $fieldName, ?string $table = null, bool $withCropVariants = false): ?array
{ {
try { try {
$table = $table ?? self::TABLE; $table = $table ?? self::TABLE;
@@ -142,43 +143,92 @@ final class UsecaseSerializer
]; ];
} }
$cropString = (string)($ref['crop'] ?? '');
$payload = $this->processImagePayload($imageService, $fileReference, $cropString !== '' ? $cropString : null);
$srcset = []; $result = [
foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $ref['crop'] ?? null]
);
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $ref['crop'] ?? null]
);
return [
'uid' => (int)$ref['uid'], 'uid' => (int)$ref['uid'],
'url' => $imageService->getImageUri($default), 'url' => $payload['url'],
'title' => (string)($ref['title'] ?? ''), 'title' => (string)($ref['title'] ?? ''),
'alternative' => (string)($ref['alternative'] ?? ''), 'alternative' => (string)($ref['alternative'] ?? ''),
'description' => (string)($ref['description'] ?? ''), 'description' => (string)($ref['description'] ?? ''),
'srcset' => $srcset, 'srcset' => $payload['srcset'],
'properties' => [ 'properties' => [
'width' => $fileReference->getProperty('width'), 'width' => $fileReference->getProperty('width'),
'height' => $fileReference->getProperty('height'), 'height' => $fileReference->getProperty('height'),
'mimeType' => $fileReference->getProperty('mime_type'), 'mimeType' => $fileReference->getProperty('mime_type'),
], ],
]; ];
// Zusatz-Zuschnitte (Card 4:3 / Large Card 16:9) nur fuer das "erste Bild"
// der karten-faehigen Modelle; faellt auf das Vollbild zurueck, wenn keine
// Variante gesetzt ist.
if ($withCropVariants) {
$result['crops'] = $this->cropVariantPayloads($imageService, $fileReference, $cropString);
}
return $result;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return null; return null;
} }
} }
/**
* Build {url, srcset} for one crop input (crop JSON string, absolute Area
* or null = full image). srcset steps whose processing would fall back to
* the original file are skipped (avoids raw-/403-leaks for sources that are
* smaller than the requested width).
*
* @param string|\TYPO3\CMS\Core\Imaging\ImageManipulation\Area|null $crop
* @return array{url:string,srcset:array<int,array<string,mixed>>}
*/
private function processImagePayload(ImageService $imageService, \TYPO3\CMS\Core\Resource\FileReference $fileReference, $crop): array
{
$srcset = [];
foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions(
$fileReference,
['width' => $width, 'crop' => $crop, 'fileExtension' => 'webp']
);
if ($variant->usesOriginalFile()) {
continue;
}
$srcset[] = [
'url' => $imageService->getImageUri($variant),
'width' => $width,
'descriptor' => $width . 'w',
];
}
$default = $imageService->applyProcessingInstructions(
$fileReference,
['width' => 800, 'crop' => $crop, 'fileExtension' => 'webp']
);
return [
'url' => $imageService->getImageUri($default),
'srcset' => $srcset,
];
}
/**
* card + largeCard crop-variant payloads for a model's first image.
* Each variant falls back to the full image when no explicit crop is set.
*
* @return array<string,array{url:string,srcset:array<int,array<string,mixed>>}>
*/
private function cropVariantPayloads(ImageService $imageService, \TYPO3\CMS\Core\Resource\FileReference $fileReference, string $cropString): array
{
$collection = CropVariantCollection::create($cropString);
$out = [];
foreach (['card', 'largeCard'] as $variantKey) {
$area = $collection->getCropArea($variantKey);
$crop = $area->isEmpty() ? null : $area->makeAbsoluteBasedOnFile($fileReference);
$out[$variantKey] = $this->processImagePayload($imageService, $fileReference, $crop);
}
return $out;
}
/** /**
* All FAL images of one field (multi-reference), same shape as image(). * All FAL images of one field (multi-reference), same shape as image().
* *
@@ -260,13 +310,16 @@ final class UsecaseSerializer
foreach (self::IMAGE_WIDTHS as $width) { foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions( $variant = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => $width, 'crop' => $ref['crop'] ?? null] ['width' => $width, 'crop' => $ref['crop'] ?? null, 'fileExtension' => 'webp']
); );
if ($variant->usesOriginalFile()) {
continue; // Quelle zu klein fuer diese Breite: kein Raw-/403-Leak
}
$srcset[] = ['url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w']; $srcset[] = ['url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w'];
} }
$default = $imageService->applyProcessingInstructions( $default = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 800, 'crop' => $ref['crop'] ?? null] ['width' => 800, 'crop' => $ref['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Tca;
/**
* Zentrale cropVariants-Definition fuer das "erste Bild" der Karten-faehigen
* Modelle (Product, Success Story, Market, Solution).
*
* Liefert drei Zuschnitt-Varianten, die im Backend-Image-Editor je einen
* eigenen Tab bekommen:
* - default : freier/allgemeiner Zuschnitt (Detail-/Hero-/Listen-Nutzung)
* - card : 4:3 fuer die normale Card-Darstellung
* - largeCard : 16:9 fuer die grosse/Feature-Card-Darstellung
*
* Ein freier Zuschnitt ("Free") ist bei jeder Variante zusaetzlich moeglich;
* die genannte Ratio ist lediglich der vorgegebene Standard-Rahmen.
*
* Wird aus den TCA-Dateien der o.g. Modelle heraus referenziert:
* 'cropVariants' => \Evomedien\Vitec\Tca\CropVariants::firstImage(),
*/
final class CropVariants
{
/**
* @return array<string,array<string,mixed>>
*/
public static function firstImage(): array
{
return [
'default' => [
'title' => 'Default',
'allowedAspectRatios' => [
'NaN' => ['title' => 'Free', 'value' => 0.0],
'16:9' => ['title' => '16:9', 'value' => 16 / 9],
'4:3' => ['title' => '4:3', 'value' => 4 / 3],
'1:1' => ['title' => '1:1', 'value' => 1.0],
],
],
'card' => [
'title' => 'Card (4:3)',
'allowedAspectRatios' => [
'4:3' => ['title' => '4:3', 'value' => 4 / 3],
'NaN' => ['title' => 'Free', 'value' => 0.0],
],
],
'largeCard' => [
'title' => 'Large Card (16:9)',
'allowedAspectRatios' => [
'16:9' => ['title' => '16:9', 'value' => 16 / 9],
'NaN' => ['title' => 'Free', 'value' => 0.0],
],
],
];
}
}

View File

@@ -204,13 +204,13 @@ class CustomerlogosJsonRenderer
foreach (self::IMAGE_WIDTHS as $width) { foreach (self::IMAGE_WIDTHS as $width) {
$variant = $imageService->applyProcessingInstructions( $variant = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => $width, 'crop' => $ref['crop'] ?? null] ['width' => $width, 'crop' => $ref['crop'] ?? null, 'fileExtension' => 'webp']
); );
$srcset[] = ['url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w']; $srcset[] = ['url' => $imageService->getImageUri($variant), 'width' => $width, 'descriptor' => $width . 'w'];
} }
$default = $imageService->applyProcessingInstructions( $default = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 400, 'crop' => $ref['crop'] ?? null] ['width' => 400, 'crop' => $ref['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -338,7 +338,7 @@ class DatasheetsJsonRenderer
$default = $imageService->applyProcessingInstructions( $default = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 400, 'crop' => $fileRefData['crop'] ?? null] ['width' => 400, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -211,7 +211,7 @@ class DownloadcardcollectionJsonRenderer
foreach ([400, 800, 1200, 1600] as $width) { foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions( $variant = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null] ['width' => $width, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
$srcset[] = [ $srcset[] = [
'url' => $imageService->getImageUri($variant), 'url' => $imageService->getImageUri($variant),
@@ -222,7 +222,7 @@ class DownloadcardcollectionJsonRenderer
$default = $imageService->applyProcessingInstructions( $default = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null] ['width' => 800, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -247,7 +247,7 @@ class EventlistJsonRenderer
foreach ([400, 800] as $width) { foreach ([400, 800] as $width) {
$variant = $imageService->applyProcessingInstructions( $variant = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null] ['width' => $width, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
$srcset[] = [ $srcset[] = [
'url' => $imageService->getImageUri($variant), 'url' => $imageService->getImageUri($variant),
@@ -258,7 +258,7 @@ class EventlistJsonRenderer
$default = $imageService->applyProcessingInstructions( $default = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 400, 'crop' => $fileRefData['crop'] ?? null] ['width' => 400, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
/**
* Headless JSON renderer for the site-wide favicon / app-icon set.
*
* Emits a stable `favicons` object into every page response so the React
* frontend can render the <link>/<meta> head tags from the CMS instead of
* hard-coding them. The icon files live under public/fileadmin/icons/ and are
* referenced root-relative — identical convention to the image URLs emitted by
* UsecaseSerializer::image() (getImageUri also returns /fileadmin/... paths).
* If the frontend ever runs on a separate origin from the CMS, it must prefix
* these hrefs with the CMS base URL, exactly as it does for image URLs.
*
* Static by nature (same for every page) — no DB access, exception-free.
*/
final class FaviconsJsonRenderer
{
private const BASE = '/fileadmin/icons';
/** Browser UI / address-bar tint (VITEC navy). */
private const THEME_COLOR = '#26358C';
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$payload = [
'themeColor' => self::THEME_COLOR,
'manifest' => self::BASE . '/site.webmanifest',
// Ready-to-render <link> descriptors, ordered most- to least-specific.
'links' => [
['rel' => 'icon', 'type' => 'image/x-icon', 'href' => self::BASE . '/favicon.ico'],
['rel' => 'icon', 'type' => 'image/png', 'sizes' => '32x32', 'href' => self::BASE . '/favicon-32.png'],
['rel' => 'icon', 'type' => 'image/png', 'sizes' => '16x16', 'href' => self::BASE . '/favicon-16.png'],
['rel' => 'apple-touch-icon', 'sizes' => '180x180', 'href' => self::BASE . '/apple-touch-icon.png'],
['rel' => 'manifest', 'href' => self::BASE . '/site.webmanifest'],
],
];
return json_encode($payload) ?: '';
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\UserFunc;
use Doctrine\DBAL\ParameterType;
use Evomedien\Vitec\Forms\FormDefinitions;
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* UserFunc: render ONE of the VITEC form plugins as JSON (headless).
* Serves vitec_contactform, vitec_demoform and vitec_helpdeskform — the
* CType picks the definition from FormDefinitions.
*
* Output under content.form:
* { "formKey": "contact", "title": "…", "endpoint": "/api/vitec/form/contact",
* "honeypot": "_website", "fields": [ … ] }
*
* The React frontend renders the fields generically and POSTs JSON to the
* endpoint (handled by FormSubmissionMiddleware). Exception-safe.
*/
class FormsJsonRenderer
{
#[AsAllowedCallable]
public function render(string $content, array $conf): string
{
$row = is_array($this->cObj->data ?? null) ? $this->cObj->data : null;
if ($row && isset(FormDefinitions::CTYPE_MAP[(string)($row['CType'] ?? '')])) {
return $this->renderForRecord($row);
}
$pageId = 0;
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
if ($request !== null) {
$pageInfo = $request->getAttribute('frontend.page.information');
if ($pageInfo !== null) {
$pageId = (int)$pageInfo->getId();
}
}
if ($pageId <= 0) {
$pageId = (int)($GLOBALS['TSFE']->id ?? 0);
}
if ($pageId <= 0) {
return '';
}
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('tt_content');
$ces = $qb
->select('*')
->from('tt_content')
->where(
$qb->expr()->eq('pid', $qb->createNamedParameter($pageId, ParameterType::INTEGER)),
$qb->expr()->in('CType', $qb->createNamedParameter(
array_keys(FormDefinitions::CTYPE_MAP),
\TYPO3\CMS\Core\Database\Connection::PARAM_STR_ARRAY
)),
$qb->expr()->eq('deleted', 0),
$qb->expr()->eq('hidden', 0)
)
->executeQuery()
->fetchAllAssociative();
if (empty($ces)) {
return '';
}
return $this->renderForRecord($ces[0]);
}
/**
* @param array<string,mixed> $contentElement
*/
public function renderForRecord(array $contentElement): string
{
try {
$formKey = FormDefinitions::CTYPE_MAP[(string)($contentElement['CType'] ?? '')] ?? '';
$definition = FormDefinitions::get($formKey);
if ($definition === null) {
return '';
}
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];
$debugMode = (bool)($settings['debug'] ?? false);
$response = [
'formKey' => $definition['formKey'],
'title' => $definition['title'],
'endpoint' => '/api/vitec/form/' . $definition['formKey'],
'honeypot' => FormDefinitions::HONEYPOT_FIELD,
'fields' => $definition['fields'],
];
if ($debugMode) {
$response['debug'] = ['settings' => $settings];
}
return (string)json_encode($response);
} catch (\Throwable $e) {
return '';
}
}
}

View File

@@ -201,7 +201,7 @@ class MarketShowJsonRenderer
foreach ([400, 800, 1200, 1600] as $width) { foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions( $variant = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null] ['width' => $width, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
$srcset[] = [ $srcset[] = [
'url' => $imageService->getImageUri($variant), 'url' => $imageService->getImageUri($variant),
@@ -212,7 +212,7 @@ class MarketShowJsonRenderer
$default = $imageService->applyProcessingInstructions( $default = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null] ['width' => 800, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -157,8 +157,8 @@ class ModelcardJsonRenderer
'slug' => (string)($row['slug'] ?? ''), 'slug' => (string)($row['slug'] ?? ''),
'subtitle' => (string)($row['subtitle'] ?? ''), 'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''), 'teaser' => (string)($row['teaser'] ?? ''),
'image' => $serializer->image($uid, 'image', 'tx_vitec_domain_model_product') 'image' => $serializer->image($uid, 'image', 'tx_vitec_domain_model_product', true)
?? $serializer->image($uid, 'productimage', 'tx_vitec_domain_model_product'), ?? $serializer->image($uid, 'productimage', 'tx_vitec_domain_model_product', true),
]; ];
} }
@@ -169,7 +169,7 @@ class ModelcardJsonRenderer
'subtitle' => (string)($row['subtitle'] ?? ''), 'subtitle' => (string)($row['subtitle'] ?? ''),
'teaser' => (string)($row['teaser'] ?? ''), 'teaser' => (string)($row['teaser'] ?? ''),
'description' => (string)($row['description'] ?? ''), 'description' => (string)($row['description'] ?? ''),
'image' => $serializer->image($uid, 'image', self::MODEL_TABLES[$modelType]), 'image' => $serializer->image($uid, 'image', self::MODEL_TABLES[$modelType], true),
]; ];
} }
} }

View File

@@ -596,7 +596,7 @@ final class NewsJsonRenderer
foreach ($rows as $row) { foreach ($rows as $row) {
try { try {
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']); $fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
$processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1400]); $processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1400, 'fileExtension' => 'webp']);
$files[] = [ $files[] = [
'uid' => (int)$row['uid'], 'uid' => (int)$row['uid'],

View File

@@ -246,7 +246,8 @@ class ProductListJsonRenderer
[ [
'width' => $dimensions['width'], 'width' => $dimensions['width'],
'height' => $dimensions['height'], 'height' => $dimensions['height'],
'crop' => $fileRefData['crop'] ?? null 'crop' => $fileRefData['crop'] ?? null,
'fileExtension' => 'webp'
] ]
); );
@@ -260,7 +261,7 @@ class ProductListJsonRenderer
$defaultProcessed = $imageService->applyProcessingInstructions( $defaultProcessed = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null] ['width' => 800, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
$images[] = [ $images[] = [
@@ -318,7 +319,7 @@ class ProductListJsonRenderer
$processed = $imageService->applyProcessingInstructions( $processed = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 1200, 'crop' => $fileRefData['crop'] ?? null] ['width' => 1200, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -327,14 +327,14 @@ class ProductShowJsonRenderer
$processedImage = $imageService->applyProcessingInstructions( $processedImage = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => '1874c', 'height' => '625c'] ['width' => '1874c', 'height' => '625c', 'fileExtension' => 'webp']
); );
$srcset = []; $srcset = [];
foreach ([400, 800, 1200, 1600] as $width) { foreach ([400, 800, 1200, 1600] as $width) {
$processedVariant = $imageService->applyProcessingInstructions( $processedVariant = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => $width . 'c', 'height' => (int)($width / 3) . 'c'] ['width' => $width . 'c', 'height' => (int)($width / 3) . 'c', 'fileExtension' => 'webp']
); );
$srcset[] = [ $srcset[] = [
'url' => $imageService->getImageUri($processedVariant), 'url' => $imageService->getImageUri($processedVariant),
@@ -399,7 +399,7 @@ class ProductShowJsonRenderer
$processedImage = $imageService->applyProcessingInstructions( $processedImage = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 1200] ['width' => 1200, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -201,7 +201,7 @@ class SolutionShowJsonRenderer
foreach ([400, 800, 1200, 1600] as $width) { foreach ([400, 800, 1200, 1600] as $width) {
$variant = $imageService->applyProcessingInstructions( $variant = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => $width, 'crop' => $fileRefData['crop'] ?? null] ['width' => $width, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
$srcset[] = [ $srcset[] = [
'url' => $imageService->getImageUri($variant), 'url' => $imageService->getImageUri($variant),
@@ -212,7 +212,7 @@ class SolutionShowJsonRenderer
$default = $imageService->applyProcessingInstructions( $default = $imageService->applyProcessingInstructions(
$fileReference, $fileReference,
['width' => 800, 'crop' => $fileRefData['crop'] ?? null] ['width' => 800, 'crop' => $fileRefData['crop'] ?? null, 'fileExtension' => 'webp']
); );
return [ return [

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Contact Form Settings</sheetTitle>
<type>array</type>
<el>
<settings.delivery>
<label>Delivery</label>
<onChange>reload</onChange>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>E-Mail</label>
<value>email</value>
</numIndex>
<numIndex index="1">
<label>Salesforce (prepared — not active yet)</label>
<value>salesforce</value>
</numIndex>
</items>
<default>email</default>
</config>
</settings.delivery>
<settings.recipient>
<label>Recipient(s)</label>
<description>Comma-separated email addresses.</description>
<displayCond>FIELD:settings.delivery:=:email</displayCond>
<config>
<type>input</type>
<size>50</size>
<eval>trim</eval>
</config>
</settings.recipient>
<settings.subject>
<label>Mail Subject</label>
<displayCond>FIELD:settings.delivery:=:email</displayCond>
<config>
<type>input</type>
<size>50</size>
<eval>trim</eval>
<default>VITEC website: contact request</default>
</config>
</settings.subject>
<settings.debug>
<label>Allow Debug Output</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.debug>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>Request Demo Settings</sheetTitle>
<type>array</type>
<el>
<settings.delivery>
<label>Delivery</label>
<onChange>reload</onChange>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>E-Mail</label>
<value>email</value>
</numIndex>
<numIndex index="1">
<label>Salesforce (prepared — not active yet)</label>
<value>salesforce</value>
</numIndex>
</items>
<default>email</default>
</config>
</settings.delivery>
<settings.recipient>
<label>Recipient(s)</label>
<description>Comma-separated email addresses.</description>
<displayCond>FIELD:settings.delivery:=:email</displayCond>
<config>
<type>input</type>
<size>50</size>
<eval>trim</eval>
</config>
</settings.recipient>
<settings.subject>
<label>Mail Subject</label>
<displayCond>FIELD:settings.delivery:=:email</displayCond>
<config>
<type>input</type>
<size>50</size>
<eval>trim</eval>
<default>VITEC website: demo request</default>
</config>
</settings.subject>
<settings.debug>
<label>Allow Debug Output</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.debug>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<T3DataStructure>
<meta>
<langDisable>1</langDisable>
</meta>
<sheets>
<sDEF>
<ROOT>
<sheetTitle>HelpDesk Access Settings</sheetTitle>
<type>array</type>
<el>
<settings.delivery>
<label>Delivery</label>
<onChange>reload</onChange>
<config>
<type>select</type>
<renderType>selectSingle</renderType>
<items>
<numIndex index="0">
<label>E-Mail</label>
<value>email</value>
</numIndex>
<numIndex index="1">
<label>Salesforce (prepared — not active yet)</label>
<value>salesforce</value>
</numIndex>
</items>
<default>email</default>
</config>
</settings.delivery>
<settings.recipient>
<label>Recipient(s)</label>
<description>Comma-separated email addresses.</description>
<displayCond>FIELD:settings.delivery:=:email</displayCond>
<config>
<type>input</type>
<size>50</size>
<eval>trim</eval>
</config>
</settings.recipient>
<settings.subject>
<label>Mail Subject</label>
<displayCond>FIELD:settings.delivery:=:email</displayCond>
<config>
<type>input</type>
<size>50</size>
<eval>trim</eval>
<default>VITEC website: helpdesk access request</default>
</config>
</settings.subject>
<settings.debug>
<label>Allow Debug Output</label>
<config>
<type>check</type>
<default>0</default>
</config>
</settings.debug>
</el>
</ROOT>
</sDEF>
</sheets>
</T3DataStructure>

View File

@@ -88,4 +88,16 @@ return [
'provider' => SvgIconProvider::class, 'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-modelcard.svg', 'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-modelcard.svg',
], ],
'vitec-plugin-contactform' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-contactform.svg',
],
'vitec-plugin-demoform' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-demoform.svg',
],
'vitec-plugin-helpdeskform' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-helpdeskform.svg',
],
]; ];

View File

@@ -7,6 +7,15 @@ declare(strict_types=1);
*/ */
return [ return [
'frontend' => [ 'frontend' => [
'vitec/form-submission' => [
'target' => \Evomedien\Vitec\Middleware\FormSubmissionMiddleware::class,
'after' => [
'typo3/cms-core/normalized-params-attribute',
],
'before' => [
'typo3/cms-frontend/site',
],
],
'vitec/success-story-path-rewrite' => [ 'vitec/success-story-path-rewrite' => [
'target' => \Evomedien\Vitec\Middleware\SuccessStoryPathRewrite::class, 'target' => \Evomedien\Vitec\Middleware\SuccessStoryPathRewrite::class,
'after' => [ 'after' => [

View File

@@ -161,6 +161,21 @@ tt_content {
} }
} }
vitec_contactform < lib.contentElementWithHeader
vitec_contactform {
fields {
content {
fields {
form = USER
form.userFunc = Evomedien\Vitec\UserFunc\FormsJsonRenderer->render
}
}
}
}
vitec_demoform < tt_content.vitec_contactform
vitec_helpdeskform < tt_content.vitec_contactform
vitec_modelcard < lib.contentElementWithHeader vitec_modelcard < lib.contentElementWithHeader
vitec_modelcard { vitec_modelcard {
fields { fields {

View File

@@ -38,6 +38,37 @@ use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
// Locations: lives in the "VITEC" wizard group, titled "VITEC Locations". // Locations: lives in the "VITEC" wizard group, titled "VITEC Locations".
$GLOBALS['TCA']['tt_content']['columns']['CType']['config']['itemGroups']['vitec'] $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['itemGroups']['vitec']
= 'LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:group.header'; = 'LLL:EXT:vitec/Resources/Private/Language/locallang_containers.xlf:group.header';
// VITEC Forms — own wizard tab for the three form plugins.
$GLOBALS['TCA']['tt_content']['columns']['CType']['config']['itemGroups']['vitec_forms']
= 'VITEC Forms';
ExtensionUtility::registerPlugin(
'Vitec',
'Contactform',
'VITEC Contact Form',
'vitec-plugin-contactform',
'vitec_forms',
'Contact form — email or Salesforce delivery.',
'FILE:EXT:vitec/Configuration/FlexForms/Contactform.xml'
);
ExtensionUtility::registerPlugin(
'Vitec',
'Demoform',
'VITEC Request Demo',
'vitec-plugin-demoform',
'vitec_forms',
'Demo request form — email or Salesforce delivery.',
'FILE:EXT:vitec/Configuration/FlexForms/Demoform.xml'
);
ExtensionUtility::registerPlugin(
'Vitec',
'Helpdeskform',
'VITEC HelpDesk Access',
'vitec-plugin-helpdeskform',
'vitec_forms',
'Request access to the online helpdesk — email or Salesforce delivery.',
'FILE:EXT:vitec/Configuration/FlexForms/Helpdeskform.xml'
);
ExtensionUtility::registerPlugin( ExtensionUtility::registerPlugin(
'Vitec', 'Vitec',
'Modelcard', 'Modelcard',

View File

@@ -164,6 +164,11 @@ return [
'foreign_selector' => 'uid_local', 'foreign_selector' => 'uid_local',
'overrideChildTca' => [ 'overrideChildTca' => [
'columns' => [ 'columns' => [
'crop' => [
'config' => [
'cropVariants' => \Evomedien\Vitec\Tca\CropVariants::firstImage(),
],
],
'uid_local' => [ 'uid_local' => [
'config' => [ 'config' => [
'appearance' => [ 'appearance' => [

View File

@@ -443,6 +443,15 @@ return [
'foreign_match_fields' => [ 'foreign_match_fields' => [
'fieldname' => 'productimage', 'fieldname' => 'productimage',
], ],
'overrideChildTca' => [
'columns' => [
'crop' => [
'config' => [
'cropVariants' => \Evomedien\Vitec\Tca\CropVariants::firstImage(),
],
],
],
],
'appearance' => [ 'appearance' => [
'collapseAll' => true, 'collapseAll' => true,
'levelLinksPosition' => 'top', 'levelLinksPosition' => 'top',

View File

@@ -164,6 +164,11 @@ return [
'foreign_selector' => 'uid_local', 'foreign_selector' => 'uid_local',
'overrideChildTca' => [ 'overrideChildTca' => [
'columns' => [ 'columns' => [
'crop' => [
'config' => [
'cropVariants' => \Evomedien\Vitec\Tca\CropVariants::firstImage(),
],
],
'uid_local' => [ 'uid_local' => [
'config' => [ 'config' => [
'appearance' => [ 'appearance' => [

View File

@@ -117,7 +117,21 @@ return [
// --------------------------------------------------------- List view // --------------------------------------------------------- List view
'card_image' => [ 'card_image' => [
'label' => 'Card Image', 'label' => 'Card Image',
'config' => ['type' => 'file', 'maxitems' => 1, 'allowed' => 'common-image-types'], 'config' => [
'type' => 'file',
'maxitems' => 1,
'allowed' => 'common-image-types',
// Zusatz-Zuschnitte fuer Card-/Large-Card-Darstellung (freier Crop bleibt moeglich).
'overrideChildTca' => [
'columns' => [
'crop' => [
'config' => [
'cropVariants' => \Evomedien\Vitec\Tca\CropVariants::firstImage(),
],
],
],
],
],
], ],
'customer_logo' => [ 'customer_logo' => [
'label' => 'Customer Logo', 'label' => 'Customer Logo',

View File

@@ -0,0 +1,41 @@
<?php
return [
'ctrl' => [
'title' => 'VITEC Form Submission',
'label' => 'form_key',
'label_alt' => 'delivery_status',
'label_alt_force' => true,
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'default_sortby' => 'crdate DESC',
'rootLevel' => 1,
'readOnly' => true,
'iconfile' => 'EXT:vitec/Resources/Public/Icons/vitec-plugin-contactform.svg',
'security' => ['ignorePageTypeRestriction' => true],
],
'types' => [
'1' => ['showitem' => 'form_key, delivery_method, delivery_status, delivery_error, payload'],
],
'columns' => [
'form_key' => [
'label' => 'Form',
'config' => ['type' => 'input', 'readOnly' => true],
],
'payload' => [
'label' => 'Data (JSON)',
'config' => ['type' => 'text', 'rows' => 12, 'readOnly' => true],
],
'delivery_method' => [
'label' => 'Delivery',
'config' => ['type' => 'input', 'readOnly' => true],
],
'delivery_status' => [
'label' => 'Status',
'config' => ['type' => 'input', 'readOnly' => true],
],
'delivery_error' => [
'label' => 'Delivery Error',
'config' => ['type' => 'text', 'rows' => 3, 'readOnly' => true],
],
],
];

View File

@@ -72,3 +72,10 @@ lib.metaMenu {
page.10.fields.mainNavigation =< lib.mainNavigation page.10.fields.mainNavigation =< lib.mainNavigation
page.10.fields.footerMenu =< lib.footerMenu page.10.fields.footerMenu =< lib.footerMenu
page.10.fields.metaMenu =< lib.metaMenu page.10.fields.metaMenu =< lib.metaMenu
# -----------------------------------------------------------------------------
# Favicon / app-icon set (site-wide) — see FaviconsJsonRenderer
# Icon files: public/fileadmin/icons/
# -----------------------------------------------------------------------------
page.10.fields.favicons = USER
page.10.fields.favicons.userFunc = Evomedien\Vitec\UserFunc\FaviconsJsonRenderer->render

View File

@@ -20,3 +20,15 @@
.t3-page-ce.vitec-ce-collapsed .vitec-collapse-toggle typo3-backend-icon { .t3-page-ce.vitec-ce-collapsed .vitec-collapse-toggle typo3-backend-icon {
transform: rotate(180deg); transform: rotate(180deg);
} }
/* Collapsed: the element's own preview header lives in the CE header row —
full width on its own line below the icon/button row. */
.t3-page-ce-header:has(.vitec-preview-header--in-header) {
flex-wrap: wrap;
}
.element-preview-header.vitec-preview-header--in-header {
flex-basis: 100%;
width: 100%;
order: 10;
margin-top: 0.25rem;
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="3" y="7" width="26" height="18" rx="2" fill="#0a3d62"/>
<path d="M4 9l12 9 12-9" stroke="#ff6a00" stroke-width="2.5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 235 B

View File

Before

Width:  |  Height:  |  Size: 367 B

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect x="3" y="6" width="26" height="17" rx="2" fill="#0a3d62"/>
<path d="M13 11l8 4.5-8 4.5z" fill="#ff6a00"/>
<rect x="10" y="25" width="12" height="2.5" rx="1" fill="#9aa2ab"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<path d="M16 4a10 10 0 0 0-10 10v6a3 3 0 0 0 3 3h2v-8H8v-1a8 8 0 1 1 16 0v1h-3v8h2a3 3 0 0 0 3-3v-6A10 10 0 0 0 16 4z" fill="#0a3d62"/>
<path d="M21 23v1.5a2.5 2.5 0 0 1-2.5 2.5H15" stroke="#ff6a00" stroke-width="2" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

View File

Before

Width:  |  Height:  |  Size: 226 B

After

Width:  |  Height:  |  Size: 226 B

View File

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -118,14 +118,70 @@ class VitecContainerCollapse {
if (this.isCollapsed(uid)) { if (this.isCollapsed(uid)) {
ce.classList.add('vitec-ce-collapsed'); ce.classList.add('vitec-ce-collapsed');
} }
this.syncPreviewHeader(ce);
button.addEventListener('click', (event) => { button.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const collapsed = ce.classList.toggle('vitec-ce-collapsed'); const collapsed = ce.classList.toggle('vitec-ce-collapsed');
this.setCollapsed(uid, collapsed); this.setCollapsed(uid, collapsed);
this.syncPreviewHeader(ce);
}); });
} }
/**
* Keep the element's own `.element-preview-header` visible while collapsed:
* it is moved into the CE header row (after `.t3-page-ce-header-right`) and
* moved back to its original spot (placeholder marker) on expand.
*/
syncPreviewHeader(ce) {
const element = ce.querySelector(':scope > .t3-page-ce-element');
if (!element) {
return;
}
const header = element.querySelector(':scope > .t3-page-ce-header');
const body = element.querySelector(':scope > .t3-page-ce-body');
if (!header || !body) {
return;
}
const collapsed = ce.classList.contains('vitec-ce-collapsed');
if (collapsed) {
// Find THIS element's preview header (not one of a nested child CE).
let preview = null;
for (const candidate of body.querySelectorAll('.element-preview-header')) {
if (candidate.closest('.t3-page-ce') === ce) {
preview = candidate;
break;
}
}
if (!preview || header.contains(preview)) {
return;
}
const placeholder = document.createElement('span');
placeholder.className = 'vitec-preview-header-placeholder';
placeholder.hidden = true;
preview.before(placeholder);
preview.classList.add('vitec-preview-header--in-header');
const headerRight = header.querySelector('.t3-page-ce-header-right');
if (headerRight) {
headerRight.insertAdjacentElement('afterend', preview);
} else {
header.appendChild(preview);
}
} else {
const preview = header.querySelector('.element-preview-header.vitec-preview-header--in-header');
const placeholder = body.querySelector('.vitec-preview-header-placeholder');
if (preview && placeholder) {
preview.classList.remove('vitec-preview-header--in-header');
placeholder.replaceWith(preview);
} else if (preview) {
preview.classList.remove('vitec-preview-header--in-header');
body.prepend(preview);
}
}
}
} }
export default new VitecContainerCollapse(); export default new VitecContainerCollapse();

View File

@@ -193,4 +193,38 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1750000000] = [
] ]
); );
ExtensionUtility::configurePlugin(
'Vitec',
'Contactform',
[
\Evomedien\Vitec\Controller\FormController::class => 'list'
],
[
\Evomedien\Vitec\Controller\FormController::class => 'list'
]
);
ExtensionUtility::configurePlugin(
'Vitec',
'Demoform',
[
\Evomedien\Vitec\Controller\FormController::class => 'list'
],
[
\Evomedien\Vitec\Controller\FormController::class => 'list'
]
);
ExtensionUtility::configurePlugin(
'Vitec',
'Helpdeskform',
[
\Evomedien\Vitec\Controller\FormController::class => 'list'
],
[
\Evomedien\Vitec\Controller\FormController::class => 'list'
]
);
})(); })();

View File

@@ -281,3 +281,11 @@ CREATE TABLE tx_vitec_domain_model_customer (
logo int(11) unsigned DEFAULT '0' NOT NULL, logo int(11) unsigned DEFAULT '0' NOT NULL,
emphasize_logo smallint(5) unsigned DEFAULT '0' NOT NULL emphasize_logo smallint(5) unsigned DEFAULT '0' NOT NULL
); );
CREATE TABLE tx_vitec_form_submission (
form_key varchar(30) DEFAULT '' NOT NULL,
payload text,
delivery_method varchar(20) DEFAULT '' NOT NULL,
delivery_status varchar(20) DEFAULT '' NOT NULL,
delivery_error text
);

View File

@@ -1,27 +1,27 @@
RewriteEngine On RewriteEngine On
RewriteBase /_frontend/ RewriteBase /_frontend/
RewriteRule ^index\.html$ - [L] RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L] RewriteRule ^ - [L]
RewriteRule ^ index.html [L] RewriteRule ^ index.html [L]
# ── Cache headers ──────────────────────────────────────────────────────────── # ── Cache headers ────────────────────────────────────────────────────────────
# index.html: never cache — always fetch fresh so the browser picks up # index.html: never cache — always fetch fresh so the browser picks up
# the latest hashed JS/CSS filenames after a deploy. # the latest hashed JS/CSS filenames after a deploy.
<Files "index.html"> <Files "index.html">
Header set Cache-Control "no-store, no-cache, must-revalidate" Header set Cache-Control "no-store, no-cache, must-revalidate"
Header set Pragma "no-cache" Header set Pragma "no-cache"
Header set Expires "0" Header set Expires "0"
</Files> </Files>
# Hashed assets (JS, CSS, fonts, images inside /assets/): # Hashed assets (JS, CSS, fonts, images inside /assets/):
# Vite appends a content hash to every filename, so these are immutable. # Vite appends a content hash to every filename, so these are immutable.
# Cache them for 1 year — a new deploy produces new filenames automatically. # Cache them for 1 year — a new deploy produces new filenames automatically.
<FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$"> <FilesMatch "\.(js|css|woff2?|ttf|eot|otf)$">
Header set Cache-Control "public, max-age=31536000, immutable" Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch> </FilesMatch>

View File

@@ -21,12 +21,12 @@
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="VITEC" /> <meta name="twitter:title" content="VITEC" />
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." /> <meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." />
<link rel="icon" type="image/svg+xml" href="/_frontend/vite.svg" /> <link rel="icon" type="image/svg+xml" href="../fileadmin/icons/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>VITEC</title> <title>VITEC</title>
<script type="module" crossorigin src="/_frontend/assets/index-CM0zIqrs.js"></script> <script type="module" crossorigin src="/_frontend/assets/index-DPL77F5X.js"></script>
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-B6-rfpgz.css"> <link rel="stylesheet" crossorigin href="/_frontend/assets/index-DNSm10MQ.css">
</head> </head>
<body> <body>
<div id="page"></div> <div id="page"></div>