DEV CommiDEV Committ
This commit is contained in:
65
packages/vitec/Classes/Backend/FormEngine/StructuredDataGeneratorWizard.php
Executable file
65
packages/vitec/Classes/Backend/FormEngine/StructuredDataGeneratorWizard.php
Executable file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Backend\FormEngine;
|
||||
|
||||
use TYPO3\CMS\Backend\Form\AbstractNode;
|
||||
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
|
||||
|
||||
/**
|
||||
* FormEngine fieldWizard rendered below the product `structureddata` field.
|
||||
* Adds a "generate" button that, on click, asks the backend AJAX endpoint to
|
||||
* build the JSON-LD for the current product and writes it into the field.
|
||||
*
|
||||
* Registered as nodeName "structuredDataGenerator" (see ext_localconf.php) and
|
||||
* attached via TCA fieldWizard on tx_vitec_domain_model_product.structureddata.
|
||||
*
|
||||
* The button carries data-vitec-sd-generate (so the self-initializing JS module
|
||||
* binds via event delegation) and data-field-name = the textarea's form name
|
||||
* (itemFormElName — the reliable field reference; itemFormElID is empty in this
|
||||
* context). The JS resolves the textarea via [name="…"] with a DOM fallback.
|
||||
*
|
||||
* The actual generation lives server-side in
|
||||
* {@see \Evomedien\Vitec\Controller\Backend\StructuredDataController}.
|
||||
*/
|
||||
final class StructuredDataGeneratorWizard extends AbstractNode
|
||||
{
|
||||
public function render(): array
|
||||
{
|
||||
$result = $this->initializeResultArray();
|
||||
|
||||
$row = $this->data['databaseRow'] ?? [];
|
||||
$rawUid = $row['uid'] ?? null;
|
||||
$uid = (int)$rawUid;
|
||||
$isNew = !is_numeric($rawUid) || $uid <= 0;
|
||||
|
||||
$itemName = (string)($this->data['parameterArray']['itemFormElName'] ?? '');
|
||||
$buttonId = 'vitec-sd-generate-' . md5($itemName);
|
||||
|
||||
$hint = $isNew
|
||||
? '<span class="text-warning">Bitte das Produkt zuerst speichern, dann steht die Generierung zur Verfügung.</span>'
|
||||
: 'Erzeugt das JSON-LD aus den gespeicherten Produktdaten und überschreibt den Feldinhalt.';
|
||||
|
||||
$html = [];
|
||||
$html[] = '<div class="vitec-sd-generator" style="margin-top:.5rem">';
|
||||
$html[] = sprintf(
|
||||
'<button type="button" class="btn btn-default btn-sm" id="%s" data-vitec-sd-generate="1" data-uid="%d" data-field-name="%s"%s>',
|
||||
htmlspecialchars($buttonId),
|
||||
$uid,
|
||||
htmlspecialchars($itemName),
|
||||
$isNew ? ' disabled' : ''
|
||||
);
|
||||
$html[] = 'Structured Data generieren';
|
||||
$html[] = '</button>';
|
||||
$html[] = '<div class="form-text">' . $hint . '</div>';
|
||||
$html[] = '</div>';
|
||||
|
||||
$result['html'] = implode("\n", $html);
|
||||
$result['javaScriptModules'][] = JavaScriptModuleInstruction::create(
|
||||
'@evomedien/vitec/structured-data-generator.js'
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
262
packages/vitec/Classes/Controller/Backend/StructuredDataController.php
Executable file
262
packages/vitec/Classes/Controller/Backend/StructuredDataController.php
Executable file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Controller\Backend;
|
||||
|
||||
use Evomedien\Vitec\Service\StructuredDataService;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use TYPO3\CMS\Backend\Attribute\AsController;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||
use TYPO3\CMS\Core\Site\SiteFinder;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
|
||||
/**
|
||||
* Backend AJAX endpoint: generate schema.org JSON-LD for a single product and
|
||||
* return it as a string, ready to be written into the product's
|
||||
* `structureddata` field by the FormEngine wizard button.
|
||||
*
|
||||
* Route: vitec_structureddata_generate (see Configuration/Backend/AjaxRoutes.php)
|
||||
*
|
||||
* Generation is based on the *saved* product record (by uid); a new/unsaved
|
||||
* record returns success=false with a hint to save first. The structured data
|
||||
* is built via {@see StructuredDataService} and emitted self-contained (no
|
||||
* cross-document @id references), since the field content may be reused
|
||||
* standalone.
|
||||
*/
|
||||
#[AsController]
|
||||
final class StructuredDataController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StructuredDataService $structuredDataService,
|
||||
) {}
|
||||
|
||||
public function generate(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$uid = (int)($request->getQueryParams()['uid'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Bitte das Produkt zuerst speichern, dann erneut generieren.',
|
||||
]);
|
||||
}
|
||||
|
||||
$product = $this->loadProduct($uid);
|
||||
if ($product === null) {
|
||||
return new JsonResponse([
|
||||
'success' => false,
|
||||
'message' => 'Produkt nicht gefunden.',
|
||||
]);
|
||||
}
|
||||
|
||||
$base = $this->resolveCanonicalBase((int)($product['pid'] ?? 0), $request);
|
||||
|
||||
$data = [
|
||||
'uid' => $uid,
|
||||
'title' => (string)($product['title'] ?? ''),
|
||||
'slug' => (string)($product['slug'] ?? ''),
|
||||
'teaser' => (string)($product['teaser'] ?? ''),
|
||||
'description' => (string)($product['description'] ?? ''),
|
||||
'video' => (string)($product['video'] ?? ''),
|
||||
'categories' => $this->categories($uid),
|
||||
'videofile' => $this->videoFile($uid, $base),
|
||||
];
|
||||
|
||||
$images = $this->imageUrls($uid, $base);
|
||||
|
||||
// Self-contained snippet → no @id reference to the page-level Organization.
|
||||
$productNode = $this->structuredDataService->buildProduct($data, $base, $images, false);
|
||||
$videoNode = $this->structuredDataService->buildVideoObject(
|
||||
$data,
|
||||
$base,
|
||||
$images,
|
||||
(int)($product['crdate'] ?? $product['tstamp'] ?? 0)
|
||||
);
|
||||
|
||||
$nodes = array_values(array_filter([$productNode, $videoNode]));
|
||||
|
||||
// One node → standalone object with @context; multiple → @graph.
|
||||
if (count($nodes) === 1) {
|
||||
$document = array_merge(['@context' => 'https://schema.org'], $nodes[0]);
|
||||
} else {
|
||||
$document = ['@context' => 'https://schema.org', '@graph' => $nodes];
|
||||
}
|
||||
|
||||
$json = (string)json_encode(
|
||||
$document,
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'jsonLd' => $json,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function loadProduct(int $uid): ?array
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('tx_vitec_domain_model_product');
|
||||
|
||||
$row = $queryBuilder
|
||||
->select('*')
|
||||
->from('tx_vitec_domain_model_product')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('deleted', 0)
|
||||
)
|
||||
->executeQuery()
|
||||
->fetchAssociative();
|
||||
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{title:string}>
|
||||
*/
|
||||
private function categories(int $productUid): array
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_category');
|
||||
|
||||
$rows = $queryBuilder
|
||||
->select('c.title')
|
||||
->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 fn ($r) => ['title' => (string)($r['title'] ?? '')], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URLs of the product images (original file public URLs).
|
||||
* Falls back to the OG image when no product images exist.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function imageUrls(int $productUid, string $base): array
|
||||
{
|
||||
$urls = $this->publicUrlsForField($productUid, 'productimage', $base);
|
||||
if ($urls === []) {
|
||||
$urls = $this->publicUrlsForField($productUid, 'ogimage', $base);
|
||||
}
|
||||
return $urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
private function publicUrlsForField(int $productUid, string $fieldName, string $base): array
|
||||
{
|
||||
$rows = $this->fileReferences($productUid, $fieldName);
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
||||
$urls = [];
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
|
||||
$publicUrl = (string)$fileReference->getOriginalFile()->getPublicUrl();
|
||||
if ($publicUrl !== '') {
|
||||
$urls[] = $this->structuredDataService->absUrl($publicUrl, $base);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{url:string}|null
|
||||
*/
|
||||
private function videoFile(int $productUid, string $base): ?array
|
||||
{
|
||||
$rows = $this->fileReferences($productUid, 'videofile');
|
||||
if ($rows === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
||||
try {
|
||||
$fileReference = $resourceFactory->getFileReferenceObject((int)$rows[0]['uid']);
|
||||
$publicUrl = (string)$fileReference->getOriginalFile()->getPublicUrl();
|
||||
if ($publicUrl === '') {
|
||||
return null;
|
||||
}
|
||||
return ['url' => $this->structuredDataService->absUrl($publicUrl, $base)];
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function fileReferences(int $productUid, string $fieldName): array
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_file_reference');
|
||||
|
||||
return $queryBuilder
|
||||
->select('uid')
|
||||
->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($fieldName, ParameterType::STRING)),
|
||||
$queryBuilder->expr()->eq('deleted', 0),
|
||||
$queryBuilder->expr()->eq('hidden', 0)
|
||||
)
|
||||
->orderBy('sorting_foreign', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
}
|
||||
|
||||
private function resolveCanonicalBase(int $pid, ServerRequestInterface $request): string
|
||||
{
|
||||
if ($pid > 0) {
|
||||
try {
|
||||
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pid);
|
||||
$configured = trim((string)($site->getSettings()->get('seo.site.canonicalBase') ?? ''));
|
||||
if ($configured !== '') {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// fall through to request host
|
||||
}
|
||||
}
|
||||
|
||||
$normalizedParams = $request->getAttribute('normalizedParams');
|
||||
if ($normalizedParams !== null) {
|
||||
return rtrim($normalizedParams->getSiteUrl(), '/');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
370
packages/vitec/Classes/Service/StructuredDataService.php
Executable file
370
packages/vitec/Classes/Service/StructuredDataService.php
Executable file
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Service;
|
||||
|
||||
/**
|
||||
* Builds schema.org JSON-LD structures (as PHP arrays) for the headless
|
||||
* frontend. Each builder is pure: it takes primitive input and returns an
|
||||
* array, so it can be unit-tested without TYPO3 runtime state.
|
||||
*
|
||||
* The consuming renderers (PageJsonLdRenderer, ProductShowJsonRenderer) gather
|
||||
* the request/site context and call these builders, then json_encode the
|
||||
* resulting @graph into a `<script type="application/ld+json">` payload that
|
||||
* the JS frontend injects into the page <head>.
|
||||
*
|
||||
* Conventions:
|
||||
* - Absolute URLs everywhere (Google requirement). Use {@see absUrl()}.
|
||||
* - Stable @id anchors per entity so nodes can reference each other across
|
||||
* the different scripts on a page (Organization is the central publisher).
|
||||
*/
|
||||
final class StructuredDataService
|
||||
{
|
||||
public const ORGANIZATION_FRAGMENT = '#organization';
|
||||
public const WEBSITE_FRAGMENT = '#website';
|
||||
|
||||
/**
|
||||
* schema.org/Organization — the central publisher node. Emitted on every
|
||||
* page so its @id can be referenced by WebSite, Article and Product.
|
||||
*
|
||||
* @param array{
|
||||
* name?:string, legalName?:string, base:string, logoUrl?:string,
|
||||
* sameAs?:string[], email?:string, phone?:string,
|
||||
* street?:string, postalCode?:string, locality?:string, country?:string
|
||||
* } $cfg
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function buildOrganization(array $cfg): array
|
||||
{
|
||||
$base = $this->normalizeBase($cfg['base'] ?? '/');
|
||||
|
||||
$org = [
|
||||
'@type' => 'Organization',
|
||||
'@id' => $base . self::ORGANIZATION_FRAGMENT,
|
||||
'name' => (string)($cfg['name'] ?? ''),
|
||||
'url' => $base . '/',
|
||||
];
|
||||
|
||||
if (!empty($cfg['legalName'])) {
|
||||
$org['legalName'] = (string)$cfg['legalName'];
|
||||
}
|
||||
|
||||
if (!empty($cfg['logoUrl'])) {
|
||||
$logo = $this->absUrl((string)$cfg['logoUrl'], $base);
|
||||
$org['logo'] = [
|
||||
'@type' => 'ImageObject',
|
||||
'url' => $logo,
|
||||
];
|
||||
// Google reuses logo as the Organization image fallback.
|
||||
$org['image'] = $logo;
|
||||
}
|
||||
|
||||
$sameAs = array_values(array_filter(array_map('trim', $cfg['sameAs'] ?? [])));
|
||||
if ($sameAs !== []) {
|
||||
$org['sameAs'] = $sameAs;
|
||||
}
|
||||
|
||||
$contact = [];
|
||||
if (!empty($cfg['phone'])) {
|
||||
$contact['telephone'] = (string)$cfg['phone'];
|
||||
}
|
||||
if (!empty($cfg['email'])) {
|
||||
$contact['email'] = (string)$cfg['email'];
|
||||
}
|
||||
if ($contact !== []) {
|
||||
$org['contactPoint'] = array_merge([
|
||||
'@type' => 'ContactPoint',
|
||||
'contactType' => 'customer support',
|
||||
], $contact);
|
||||
}
|
||||
|
||||
$address = array_filter([
|
||||
'streetAddress' => (string)($cfg['street'] ?? ''),
|
||||
'postalCode' => (string)($cfg['postalCode'] ?? ''),
|
||||
'addressLocality' => (string)($cfg['locality'] ?? ''),
|
||||
'addressCountry' => (string)($cfg['country'] ?? ''),
|
||||
], static fn ($v) => $v !== '');
|
||||
if ($address !== []) {
|
||||
$org['address'] = array_merge(['@type' => 'PostalAddress'], $address);
|
||||
}
|
||||
|
||||
return $org;
|
||||
}
|
||||
|
||||
/**
|
||||
* schema.org/WebSite — emitted on the home page. Links to Organization as
|
||||
* publisher.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function buildWebSite(string $base, string $name, bool $hasOrganization): array
|
||||
{
|
||||
$base = $this->normalizeBase($base);
|
||||
|
||||
$site = [
|
||||
'@type' => 'WebSite',
|
||||
'@id' => $base . self::WEBSITE_FRAGMENT,
|
||||
'url' => $base . '/',
|
||||
'name' => $name,
|
||||
];
|
||||
|
||||
if ($hasOrganization) {
|
||||
$site['publisher'] = ['@id' => $base . self::ORGANIZATION_FRAGMENT];
|
||||
}
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* schema.org/BreadcrumbList from an ordered rootline.
|
||||
*
|
||||
* @param list<array{name:string, url:string}> $items absolute or root-relative urls
|
||||
* @return array<string,mixed>|null null when fewer than 2 levels (no useful breadcrumb)
|
||||
*/
|
||||
public function buildBreadcrumbList(array $items, string $base): ?array
|
||||
{
|
||||
$base = $this->normalizeBase($base);
|
||||
|
||||
$listElements = [];
|
||||
$position = 1;
|
||||
foreach ($items as $item) {
|
||||
$name = trim((string)($item['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$element = [
|
||||
'@type' => 'ListItem',
|
||||
'position' => $position,
|
||||
'name' => $name,
|
||||
];
|
||||
$url = (string)($item['url'] ?? '');
|
||||
if ($url !== '') {
|
||||
$element['item'] = $this->absUrl($url, $base);
|
||||
}
|
||||
$listElements[] = $element;
|
||||
$position++;
|
||||
}
|
||||
|
||||
if (count($listElements) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => $listElements,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* schema.org/NewsArticle for a news detail page.
|
||||
*
|
||||
* @param array<string,mixed> $page pages record (title, author, crdate, …)
|
||||
* @param string[] $images absolute image urls (may be empty)
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function buildNewsArticle(array $page, string $base, array $images, bool $hasOrganization): array
|
||||
{
|
||||
$base = $this->normalizeBase($base);
|
||||
|
||||
$headline = trim((string)($page['seo_title'] ?? '')) ?: trim((string)($page['title'] ?? ''));
|
||||
$description = trim((string)($page['description'] ?? '')) ?: trim((string)($page['abstract'] ?? ''));
|
||||
|
||||
$article = [
|
||||
'@type' => 'NewsArticle',
|
||||
'headline' => $headline,
|
||||
'datePublished' => $this->isoDate((int)($page['crdate'] ?? 0)),
|
||||
'dateModified' => $this->isoDate((int)($page['SYS_LASTCHANGED'] ?? $page['tstamp'] ?? 0)),
|
||||
];
|
||||
|
||||
if (!empty($page['slug'])) {
|
||||
$article['mainEntityOfPage'] = [
|
||||
'@type' => 'WebPage',
|
||||
'@id' => $this->absUrl((string)$page['slug'], $base),
|
||||
];
|
||||
}
|
||||
if ($description !== '') {
|
||||
$article['description'] = $description;
|
||||
}
|
||||
if ($images !== []) {
|
||||
$article['image'] = array_values($images);
|
||||
}
|
||||
|
||||
$author = trim((string)($page['author'] ?? ''));
|
||||
if ($author !== '') {
|
||||
$person = ['@type' => 'Person', 'name' => $author];
|
||||
if (!empty($page['author_email'])) {
|
||||
$person['email'] = (string)$page['author_email'];
|
||||
}
|
||||
$article['author'] = $person;
|
||||
}
|
||||
|
||||
if ($hasOrganization) {
|
||||
$article['publisher'] = ['@id' => $base . self::ORGANIZATION_FRAGMENT];
|
||||
}
|
||||
|
||||
return $article;
|
||||
}
|
||||
|
||||
/**
|
||||
* schema.org/Product. VITEC products are B2B AV/broadcast tech without
|
||||
* public pricing, so no `offers` node is emitted (would be invalid empty).
|
||||
*
|
||||
* @param array<string,mixed> $p serialized product (title, description, images, …)
|
||||
* @param string[] $images absolute image urls
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function buildProduct(array $p, string $base, array $images, bool $hasOrganization): array
|
||||
{
|
||||
$base = $this->normalizeBase($base);
|
||||
|
||||
$name = trim((string)($p['title'] ?? ''));
|
||||
$description = $this->plainText((string)($p['teaser'] ?? '')) ?: $this->plainText((string)($p['description'] ?? ''));
|
||||
|
||||
$product = [
|
||||
'@type' => 'Product',
|
||||
'name' => $name,
|
||||
];
|
||||
|
||||
if ($description !== '') {
|
||||
$product['description'] = $description;
|
||||
}
|
||||
if ($images !== []) {
|
||||
$product['image'] = array_values($images);
|
||||
}
|
||||
if (!empty($p['slug'])) {
|
||||
$product['url'] = $this->absUrl('/product/' . (string)$p['slug'], $base);
|
||||
}
|
||||
if (!empty($p['uid'])) {
|
||||
$product['sku'] = 'VITEC-' . (int)$p['uid'];
|
||||
}
|
||||
|
||||
// First category as schema.org category.
|
||||
$categories = $p['categories'] ?? [];
|
||||
if (is_array($categories) && isset($categories[0]['title'])) {
|
||||
$product['category'] = (string)$categories[0]['title'];
|
||||
}
|
||||
|
||||
$product['brand'] = ['@type' => 'Brand', 'name' => 'VITEC'];
|
||||
|
||||
if ($hasOrganization) {
|
||||
$product['manufacturer'] = ['@id' => $base . self::ORGANIZATION_FRAGMENT];
|
||||
}
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* schema.org/VideoObject from a product's video. Supports both an external
|
||||
* embed URL (`video`) and an uploaded FAL file (`videofile`). Returns null
|
||||
* when there is too little data for valid markup (Google requires at least
|
||||
* name + thumbnailUrl + uploadDate).
|
||||
*
|
||||
* @param array<string,mixed> $p serialized product
|
||||
* @param string[] $thumbnails absolute image urls (used as thumbnailUrl)
|
||||
* @param int $uploadTs unix timestamp for uploadDate
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function buildVideoObject(array $p, string $base, array $thumbnails, int $uploadTs): ?array
|
||||
{
|
||||
$base = $this->normalizeBase($base);
|
||||
|
||||
$embedUrl = trim((string)($p['video'] ?? ''));
|
||||
$fileUrl = '';
|
||||
if (is_array($p['videofile'] ?? null) && !empty($p['videofile']['url'])) {
|
||||
$fileUrl = $this->absUrl((string)$p['videofile']['url'], $base);
|
||||
}
|
||||
|
||||
if ($embedUrl === '' && $fileUrl === '') {
|
||||
return null;
|
||||
}
|
||||
if ($thumbnails === []) {
|
||||
// Without a thumbnail Google rejects the VideoObject; skip rather
|
||||
// than emit invalid markup.
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim((string)($p['title'] ?? ''));
|
||||
$description = $this->plainText((string)($p['teaser'] ?? '')) ?: $name;
|
||||
|
||||
$video = [
|
||||
'@type' => 'VideoObject',
|
||||
'name' => $name !== '' ? $name : 'Video',
|
||||
'description' => $description !== '' ? $description : $name,
|
||||
'thumbnailUrl' => array_values($thumbnails),
|
||||
'uploadDate' => $this->isoDate($uploadTs),
|
||||
];
|
||||
|
||||
if ($embedUrl !== '') {
|
||||
$video['embedUrl'] = $embedUrl;
|
||||
}
|
||||
if ($fileUrl !== '') {
|
||||
$video['contentUrl'] = $fileUrl;
|
||||
}
|
||||
|
||||
return $video;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap one or more schema nodes into a single @graph document string.
|
||||
*
|
||||
* @param list<array<string,mixed>|null> $nodes
|
||||
*/
|
||||
public function encodeGraph(array $nodes): string
|
||||
{
|
||||
$nodes = array_values(array_filter($nodes));
|
||||
if ($nodes === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$document = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@graph' => $nodes,
|
||||
];
|
||||
|
||||
return (string)json_encode($document, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a possibly root-relative URL absolute against the canonical base.
|
||||
*/
|
||||
public function absUrl(string $url, string $base): string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('#^https?://#i', $url) === 1) {
|
||||
return $url;
|
||||
}
|
||||
return $this->normalizeBase($base) . '/' . ltrim($url, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing slash and whitespace from the canonical base.
|
||||
*/
|
||||
private function normalizeBase(string $base): string
|
||||
{
|
||||
$base = rtrim(trim($base), '/');
|
||||
return $base;
|
||||
}
|
||||
|
||||
private function isoDate(int $timestamp): string
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return '';
|
||||
}
|
||||
return date('c', $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse HTML/whitespace to a plain single-line string for schema text
|
||||
* fields (descriptions must not contain markup).
|
||||
*/
|
||||
private function plainText(string $value): string
|
||||
{
|
||||
$value = strip_tags($value);
|
||||
$value = preg_replace('/\s+/u', ' ', $value) ?? $value;
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,20 @@ class DownloadcardJsonRenderer
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
$thumbnailUrl = null;
|
||||
try {
|
||||
$fileObj = GeneralUtility::makeInstance(TYPO3CMSCoreResourceResourceFactory::class)
|
||||
->getFileObject((int)$row["file_uid"]);
|
||||
$processed = $fileObj->process(
|
||||
TYPO3CMSCoreResourceProcessedFile::CONTEXT_IMAGEPREVIEW,
|
||||
["width" => 400, "height" => 566]
|
||||
);
|
||||
$thumbnailUrl = $processed->getPublicUrl();
|
||||
} catch (Throwable $e) {
|
||||
// ignore — keep null
|
||||
}
|
||||
|
||||
return [
|
||||
'uid' => (int)$row['uid'],
|
||||
'title' => (string)($row['title'] ?? ''),
|
||||
@@ -284,11 +298,26 @@ class DownloadcardJsonRenderer
|
||||
return null;
|
||||
}
|
||||
|
||||
$thumbnailUrl = null;
|
||||
try {
|
||||
$fileObj = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\ResourceFactory::class)
|
||||
->getFileObject((int)$row['file_uid']);
|
||||
$processed = $fileObj->process(
|
||||
\TYPO3\CMS\Core\Resource\ProcessedFile::CONTEXT_IMAGEPREVIEW,
|
||||
['width' => 400, 'height' => 566]
|
||||
);
|
||||
$thumbnailUrl = $processed->getPublicUrl();
|
||||
} catch (\Throwable $e) {
|
||||
// ignore — keep null
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
'uid' => (int)$row['file_uid'],
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'url' => '/fileadmin' . ($row['identifier'] ?? ''),
|
||||
'size' => (int)($row['size'] ?? 0),
|
||||
'thumbnail' => $thumbnailUrl,
|
||||
'size' => (int)($row['size'] ?? 0),
|
||||
'extension' => (string)($row['extension'] ?? ''),
|
||||
'mimeType' => (string)($row['mime_type'] ?? ''),
|
||||
'title' => (string)($row['title'] ?? ''),
|
||||
|
||||
@@ -212,6 +212,7 @@ class DownloadcardcollectionJsonRenderer
|
||||
);
|
||||
$srcset[] = [
|
||||
'url' => $imageService->getImageUri($variant),
|
||||
'thumbnail' => $thumbnailUrl,
|
||||
'width' => $width,
|
||||
'descriptor' => $width . 'w',
|
||||
];
|
||||
@@ -225,6 +226,7 @@ class DownloadcardcollectionJsonRenderer
|
||||
return [
|
||||
'uid' => (int)$fileRefData['uid'],
|
||||
'url' => $imageService->getImageUri($default),
|
||||
'thumbnail' => $thumbnailUrl,
|
||||
'title' => $fileRefData['title'] ?? '',
|
||||
'alternative' => $fileRefData['alternative'] ?? '',
|
||||
'description' => $fileRefData['description'] ?? '',
|
||||
@@ -297,10 +299,25 @@ class DownloadcardcollectionJsonRenderer
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
$thumbnailUrl = null;
|
||||
try {
|
||||
$fileObj = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\ResourceFactory::class)
|
||||
->getFileObject((int)$row['file_uid']);
|
||||
$processed = $fileObj->process(
|
||||
\TYPO3\CMS\Core\Resource\ProcessedFile::CONTEXT_IMAGEPREVIEW,
|
||||
['width' => 400, 'height' => 566]
|
||||
);
|
||||
$thumbnailUrl = $processed->getPublicUrl();
|
||||
} catch (\Throwable $e) {
|
||||
// ignore — keep null
|
||||
}
|
||||
|
||||
return [
|
||||
'uid' => (int)$row['file_uid'],
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'url' => '/fileadmin' . ($row['identifier'] ?? ''),
|
||||
'thumbnail' => $thumbnailUrl,
|
||||
'size' => (int)($row['size'] ?? 0),
|
||||
'extension' => (string)($row['extension'] ?? ''),
|
||||
'mimeType' => (string)($row['mime_type'] ?? ''),
|
||||
|
||||
228
packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php
Executable file
228
packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php
Executable file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\UserFunc;
|
||||
|
||||
use Evomedien\Vitec\Service\StructuredDataService;
|
||||
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
|
||||
/**
|
||||
* Emits the page-level schema.org @graph (Organization, WebSite,
|
||||
* BreadcrumbList, NewsArticle) as a JSON-LD string for the headless page
|
||||
* object. Wired in setup.typoscript as:
|
||||
*
|
||||
* page.10.fields.jsonLd = USER
|
||||
* page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
|
||||
*
|
||||
* The JS frontend wraps the returned string in
|
||||
* `<script type="application/ld+json">…</script>` inside the page <head>.
|
||||
*
|
||||
* Organization data is pulled from site settings (category "seo"); the
|
||||
* canonical absolute base URL likewise (with a request-host fallback) because
|
||||
* the headless site base is "/".
|
||||
*
|
||||
* Exception-safe: returns '' on any failure, never breaks the page JSON.
|
||||
*/
|
||||
final class PageJsonLdRenderer
|
||||
{
|
||||
/** News-detail backend layouts (see BackendLayoutDataProvider::LAYOUT_MAP). */
|
||||
private const NEWS_DETAIL_LAYOUTS = [13, 14, 15];
|
||||
|
||||
#[AsAllowedCallable]
|
||||
public function render(string $content, array $conf): string
|
||||
{
|
||||
try {
|
||||
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
|
||||
if ($request === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$pageInfo = $request->getAttribute('frontend.page.information');
|
||||
if ($pageInfo === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$pageId = (int)$pageInfo->getId();
|
||||
$pageRecord = $pageInfo->getPageRecord();
|
||||
$rootLine = $pageInfo->getRootLine();
|
||||
|
||||
$site = $request->getAttribute('site');
|
||||
$settings = $site?->getSettings();
|
||||
$base = $this->resolveCanonicalBase($request, $settings);
|
||||
|
||||
$service = GeneralUtility::makeInstance(StructuredDataService::class);
|
||||
|
||||
$orgCfg = $this->organizationConfig($settings, $base);
|
||||
$hasOrganization = ($orgCfg['name'] ?? '') !== '';
|
||||
|
||||
$nodes = [];
|
||||
|
||||
// Organization on every page (anchors @id references).
|
||||
if ($hasOrganization) {
|
||||
$nodes[] = $service->buildOrganization($orgCfg);
|
||||
}
|
||||
|
||||
// WebSite only on the site root / home page.
|
||||
$isHome = $site !== null && $pageId === (int)$site->getRootPageId();
|
||||
if ($isHome) {
|
||||
$siteName = (string)($settings?->get('seo.site.name') ?? '');
|
||||
if ($siteName === '') {
|
||||
$siteName = (string)($orgCfg['name'] ?? '');
|
||||
}
|
||||
$nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization);
|
||||
}
|
||||
|
||||
// BreadcrumbList from the rootline.
|
||||
$breadcrumb = $service->buildBreadcrumbList($this->breadcrumbItems($rootLine), $base);
|
||||
if ($breadcrumb !== null) {
|
||||
$nodes[] = $breadcrumb;
|
||||
}
|
||||
|
||||
// NewsArticle on news-detail pages.
|
||||
if (in_array((int)($pageRecord['layout'] ?? 0), self::NEWS_DETAIL_LAYOUTS, true)) {
|
||||
$nodes[] = $service->buildNewsArticle(
|
||||
$pageRecord,
|
||||
$base,
|
||||
$this->pageMediaUrls($pageId, $base, $service),
|
||||
$hasOrganization
|
||||
);
|
||||
}
|
||||
|
||||
return $service->encodeGraph($nodes);
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical absolute base, e.g. "https://www.vitec.com".
|
||||
* Prefers the explicit site setting, falls back to the request host.
|
||||
*/
|
||||
private function resolveCanonicalBase($request, $settings): string
|
||||
{
|
||||
$configured = trim((string)($settings?->get('seo.site.canonicalBase') ?? ''));
|
||||
if ($configured !== '') {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
$normalizedParams = $request->getAttribute('normalizedParams');
|
||||
if ($normalizedParams !== null) {
|
||||
return rtrim($normalizedParams->getSiteUrl(), '/');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the Organization config from site settings.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function organizationConfig($settings, string $base): array
|
||||
{
|
||||
$get = static fn (string $key): string => trim((string)($settings?->get($key) ?? ''));
|
||||
|
||||
$sameAsRaw = $get('seo.organization.sameAs');
|
||||
$sameAs = $sameAsRaw !== ''
|
||||
? array_filter(array_map('trim', preg_split('/[\r\n,]+/', $sameAsRaw) ?: []))
|
||||
: [];
|
||||
|
||||
return [
|
||||
'name' => $get('seo.organization.name'),
|
||||
'legalName' => $get('seo.organization.legalName'),
|
||||
'base' => $base,
|
||||
'logoUrl' => $get('seo.organization.logoUrl'),
|
||||
'sameAs' => array_values($sameAs),
|
||||
'email' => $get('seo.organization.email'),
|
||||
'phone' => $get('seo.organization.phone'),
|
||||
'street' => $get('seo.organization.street'),
|
||||
'postalCode' => $get('seo.organization.postalCode'),
|
||||
'locality' => $get('seo.organization.locality'),
|
||||
'country' => $get('seo.organization.country'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ordered breadcrumb items (root → current) from the rootline.
|
||||
* Skips spacers, folders and recyclers; uses nav_title with title fallback.
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $rootLine
|
||||
* @return list<array{name:string, url:string}>
|
||||
*/
|
||||
private function breadcrumbItems(array $rootLine): array
|
||||
{
|
||||
$skipDoktypes = [199, 254, 255];
|
||||
$items = [];
|
||||
|
||||
// Rootline is ordered current → root; reverse for breadcrumb order.
|
||||
foreach (array_reverse($rootLine) as $page) {
|
||||
$doktype = (int)($page['doktype'] ?? 1);
|
||||
if (in_array($doktype, $skipDoktypes, true)) {
|
||||
continue;
|
||||
}
|
||||
// Matches the visible headless breadcrumb (HMENU special=rootline),
|
||||
// which keeps nav_hide pages for path integrity.
|
||||
$name = trim((string)($page['nav_title'] ?? '')) ?: trim((string)($page['title'] ?? ''));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'name' => $name,
|
||||
'url' => (string)($page['slug'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URLs of the page's `media` images (used as NewsArticle image).
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function pageMediaUrls(int $pageId, string $base, StructuredDataService $service): array
|
||||
{
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_file_reference');
|
||||
|
||||
$rows = $queryBuilder
|
||||
->select('uid')
|
||||
->from('sys_file_reference')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('uid_foreign', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('tablenames', $queryBuilder->createNamedParameter('pages', ParameterType::STRING)),
|
||||
$queryBuilder->expr()->eq('fieldname', $queryBuilder->createNamedParameter('media', ParameterType::STRING)),
|
||||
$queryBuilder->expr()->eq('deleted', 0),
|
||||
$queryBuilder->expr()->eq('hidden', 0)
|
||||
)
|
||||
->orderBy('sorting_foreign', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
if ($rows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
||||
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
||||
$urls = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$fileReference = $resourceFactory->getFileReferenceObject((int)$row['uid']);
|
||||
$processed = $imageService->applyProcessingInstructions($fileReference, ['width' => 1200]);
|
||||
$urls[] = $service->absUrl($imageService->getImageUri($processed), $base);
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Evomedien\Vitec\UserFunc;
|
||||
|
||||
|
||||
use Evomedien\Vitec\Service\StructuredDataService;
|
||||
use TYPO3\CMS\Core\Attribute\AsAllowedCallable;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Service\FlexFormService;
|
||||
@@ -12,7 +13,7 @@ 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 TYPO3\CMS\Extbase\Service\ImageService;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
|
||||
/**
|
||||
@@ -176,7 +177,7 @@ class ProductShowJsonRenderer
|
||||
{
|
||||
$uid = (int)$product['uid'];
|
||||
|
||||
return [
|
||||
$data = [
|
||||
'uid' => $uid,
|
||||
|
||||
'title' => (string)($product['title'] ?? ''),
|
||||
@@ -215,6 +216,79 @@ class ProductShowJsonRenderer
|
||||
'videofile' => $this->getProductVideoFile($uid),
|
||||
'relatedprodukt' => $this->getRelatedProducts($uid),
|
||||
];
|
||||
|
||||
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
|
||||
// @graph string. The frontend injects it as <script type="application/ld+json">.
|
||||
// Supersedes the manual `structureddata` field above (kept for backwards
|
||||
// compatibility until the frontend has switched over).
|
||||
$data['jsonLd'] = $this->buildJsonLd($product, $data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the product's structured-data @graph from the already-serialized
|
||||
* product array. Exception-safe: returns '' on any failure.
|
||||
*
|
||||
* @param array<string,mixed> $rawProduct raw DB row (for crdate/tstamp)
|
||||
* @param array<string,mixed> $data serialized product
|
||||
*/
|
||||
protected function buildJsonLd(array $rawProduct, array $data): string
|
||||
{
|
||||
try {
|
||||
$service = GeneralUtility::makeInstance(StructuredDataService::class);
|
||||
$base = $this->resolveCanonicalBase();
|
||||
|
||||
$imageUrls = [];
|
||||
foreach (($data['images'] ?? []) as $img) {
|
||||
if (!empty($img['url'])) {
|
||||
$imageUrls[] = $service->absUrl((string)$img['url'], $base);
|
||||
}
|
||||
}
|
||||
if ($imageUrls === [] && !empty($data['ogimage']['url'])) {
|
||||
$imageUrls[] = $service->absUrl((string)$data['ogimage']['url'], $base);
|
||||
}
|
||||
|
||||
$hasOrganization = trim((string)($this->siteSettings()?->get('seo.organization.name') ?? '')) !== '';
|
||||
|
||||
$product = $service->buildProduct($data, $base, $imageUrls, $hasOrganization);
|
||||
|
||||
$uploadTs = (int)($rawProduct['crdate'] ?? $rawProduct['tstamp'] ?? 0);
|
||||
$video = $service->buildVideoObject($data, $base, $imageUrls, $uploadTs);
|
||||
|
||||
return $service->encodeGraph([$product, $video]);
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Site settings of the current request, or null outside a site context.
|
||||
*/
|
||||
protected function siteSettings(): ?\TYPO3\CMS\Core\Site\Entity\SiteSettings
|
||||
{
|
||||
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
|
||||
return $request?->getAttribute('site')?->getSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical absolute base (e.g. "https://www.vitec.com"). Prefers the
|
||||
* explicit site setting, falls back to the request host.
|
||||
*/
|
||||
protected function resolveCanonicalBase(): string
|
||||
{
|
||||
$configured = trim((string)($this->siteSettings()?->get('seo.site.canonicalBase') ?? ''));
|
||||
if ($configured !== '') {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
$request = $GLOBALS['TYPO3_REQUEST'] ?? null;
|
||||
$normalizedParams = $request?->getAttribute('normalizedParams');
|
||||
if ($normalizedParams !== null) {
|
||||
return rtrim($normalizedParams->getSiteUrl(), '/');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function getProductImages(int $productUid): array
|
||||
|
||||
19
packages/vitec/Configuration/Backend/AjaxRoutes.php
Executable file
19
packages/vitec/Configuration/Backend/AjaxRoutes.php
Executable file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Evomedien\Vitec\Controller\Backend\StructuredDataController;
|
||||
|
||||
/**
|
||||
* Backend AJAX routes for the VITEC extension.
|
||||
*
|
||||
* vitec_structureddata_generate — generate schema.org JSON-LD for a product;
|
||||
* called by the FormEngine "Structured Data generieren" button. The URL is
|
||||
* available to JS via TYPO3.settings.ajaxUrls['vitec_structureddata_generate'].
|
||||
*/
|
||||
return [
|
||||
'vitec_structureddata_generate' => [
|
||||
'path' => '/vitec/structureddata/generate',
|
||||
'target' => StructuredDataController::class . '::generate',
|
||||
],
|
||||
];
|
||||
52
packages/vitec/Configuration/FlexForms/Downloadcard.xml
Normal file → Executable file
52
packages/vitec/Configuration/FlexForms/Downloadcard.xml
Normal file → Executable file
@@ -45,38 +45,26 @@
|
||||
</config>
|
||||
</settings.download>
|
||||
<settings.layout>
|
||||
<label>Layout</label>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<items>
|
||||
<numIndex index="0">
|
||||
<label>
|
||||
Default Product List
|
||||
</label>
|
||||
<value>0</value>
|
||||
</numIndex>
|
||||
<numIndex index="1">
|
||||
<label>
|
||||
Variation 1 Product List
|
||||
</label>
|
||||
<value>1</value>
|
||||
</numIndex>
|
||||
<numIndex index="2">
|
||||
<label>
|
||||
Variation 2 Product List
|
||||
</label>
|
||||
<value>2</value>
|
||||
</numIndex>
|
||||
<numIndex index="3">
|
||||
<label>
|
||||
Variation 3 Product List
|
||||
</label>
|
||||
<value>3</value>
|
||||
</numIndex>
|
||||
</items>
|
||||
</config>
|
||||
</settings.layout>
|
||||
<label>Layout</label>
|
||||
<config>
|
||||
<type>select</type>
|
||||
<renderType>selectSingle</renderType>
|
||||
<items>
|
||||
<numIndex index="0">
|
||||
<label>Datasheet Layout</label>
|
||||
<value>datasheet</value>
|
||||
</numIndex>
|
||||
<numIndex index="1">
|
||||
<label>Brochure Layout</label>
|
||||
<value>brochure</value>
|
||||
</numIndex>
|
||||
<numIndex index="2">
|
||||
<label>Neutral Layout</label>
|
||||
<value>neutral</value>
|
||||
</numIndex>
|
||||
</items>
|
||||
</config>
|
||||
</settings.layout>
|
||||
<settings.magstyle>
|
||||
<label>Magazine Style layout</label>
|
||||
<config>
|
||||
|
||||
16
packages/vitec/Configuration/JavaScriptModules.php
Executable file
16
packages/vitec/Configuration/JavaScriptModules.php
Executable file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Importmap registration for VITEC backend JavaScript (ES modules).
|
||||
* Maps the bare specifier prefix "@evomedien/vitec/" to the extension's
|
||||
* public JavaScript directory, so modules can be loaded via
|
||||
* JavaScriptModuleInstruction::create('@evomedien/vitec/<file>.js').
|
||||
*/
|
||||
return [
|
||||
'dependencies' => ['backend', 'core'],
|
||||
'imports' => [
|
||||
'@evomedien/vitec/' => 'EXT:vitec/Resources/Public/Javascript/',
|
||||
],
|
||||
];
|
||||
@@ -42,3 +42,86 @@ settings:
|
||||
category: menu
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured data / schema.org (consumed by StructuredDataService)
|
||||
# ---------------------------------------------------------------------------
|
||||
seo.site.canonicalBase:
|
||||
label: 'Canonical base URL'
|
||||
description: 'Absolute public base URL of the live site WITHOUT trailing slash, e.g. https://www.vitec.com. Used to build absolute URLs in JSON-LD. Falls back to the request host if empty.'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.site.name:
|
||||
label: 'Website name (schema.org WebSite)'
|
||||
description: 'Display name for the schema.org WebSite node on the home page. Falls back to the organization name if empty.'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.name:
|
||||
label: 'Organization name'
|
||||
description: 'Public organization name for schema.org/Organization. Leave empty to disable Organization output entirely.'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.legalName:
|
||||
label: 'Organization legal name'
|
||||
description: 'Full legal entity name, e.g. "VITEC GmbH".'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.logoUrl:
|
||||
label: 'Organization logo URL'
|
||||
description: 'URL of the organization logo (absolute, or root-relative — will be made absolute). Recommended for Google.'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.sameAs:
|
||||
label: 'Social / sameAs URLs'
|
||||
description: 'Comma- or newline-separated list of official profile URLs (LinkedIn, YouTube, X, …) for schema.org sameAs.'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.email:
|
||||
label: 'Contact email'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.phone:
|
||||
label: 'Contact phone'
|
||||
description: 'International format, e.g. +49 89 1234567.'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.street:
|
||||
label: 'Address — street'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.postalCode:
|
||||
label: 'Address — postal code'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.locality:
|
||||
label: 'Address — city'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
seo.organization.country:
|
||||
label: 'Address — country (ISO 3166-1 alpha-2)'
|
||||
description: 'Two-letter country code, e.g. DE.'
|
||||
category: seo
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
@@ -134,3 +134,13 @@ tt_content {
|
||||
|
||||
# Include menu (navigation) JSON definitions
|
||||
@import 'EXT:vitec/Configuration/TypoScript/Headless/vitec_menus.typoscript'
|
||||
|
||||
# =============================================================================
|
||||
# Page-level structured data (schema.org JSON-LD @graph)
|
||||
#
|
||||
# Organization + WebSite + BreadcrumbList + NewsArticle, auto-generated server
|
||||
# side from page/site data. The headless frontend wraps the returned string in
|
||||
# <script type="application/ld+json"> inside the page <head>.
|
||||
# =============================================================================
|
||||
page.10.fields.jsonLd = USER
|
||||
page.10.fields.jsonLd.userFunc = Evomedien\Vitec\UserFunc\PageJsonLdRenderer->render
|
||||
|
||||
@@ -29,7 +29,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
]
|
||||
))
|
||||
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-cols-25-25-25-25.svg')
|
||||
->setGroup('vitec')
|
||||
->setGroup('vitec_custom_components')
|
||||
->setSaveAndCloseInNewContentElementWizard(true)
|
||||
);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
]
|
||||
))
|
||||
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-cols-33-33-33.svg')
|
||||
->setGroup('vitec')
|
||||
->setGroup('vitec_custom_components')
|
||||
->setSaveAndCloseInNewContentElementWizard(true)
|
||||
);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
]
|
||||
))
|
||||
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-cols-33-66.svg')
|
||||
->setGroup('vitec')
|
||||
->setGroup('vitec_custom_components')
|
||||
->setSaveAndCloseInNewContentElementWizard(true)
|
||||
);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
]
|
||||
))
|
||||
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-cols-50-50.svg')
|
||||
->setGroup('vitec')
|
||||
->setGroup('vitec_custom_components')
|
||||
->setSaveAndCloseInNewContentElementWizard(true)
|
||||
);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
]
|
||||
))
|
||||
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-cols-66-33.svg')
|
||||
->setGroup('vitec')
|
||||
->setGroup('vitec_custom_components')
|
||||
->setSaveAndCloseInNewContentElementWizard(true)
|
||||
);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
]
|
||||
))
|
||||
->setIcon('EXT:vitec/Resources/Public/Icons/vitec-container.svg')
|
||||
->setGroup('vitec')
|
||||
->setGroup('vitec_custom_components')
|
||||
->setSaveAndCloseInNewContentElementWizard(true)
|
||||
);
|
||||
|
||||
|
||||
@@ -192,9 +192,14 @@ return [
|
||||
'type' => 'text',
|
||||
'cols' => 40,
|
||||
'rows' => 15,
|
||||
'eval' => 'trim'
|
||||
'eval' => 'trim',
|
||||
'fieldWizard' => [
|
||||
'structuredDataGenerator' => [
|
||||
'renderType' => 'structuredDataGenerator',
|
||||
],
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
'keywords' => [
|
||||
'exclude' => true,
|
||||
'label' => 'Keywords',
|
||||
|
||||
8
packages/vitec/Configuration/page.tsconfig
Normal file → Executable file
8
packages/vitec/Configuration/page.tsconfig
Normal file → Executable file
@@ -91,6 +91,10 @@ mod {
|
||||
|
||||
}
|
||||
}
|
||||
wizards.newContentElement.wizardItems.vitec_custom_components {
|
||||
header = Custom Components
|
||||
show = *
|
||||
}
|
||||
}
|
||||
|
||||
############################################################
|
||||
@@ -99,10 +103,12 @@ mod {
|
||||
|
||||
mod.web_layout.tt_content.preview {
|
||||
simplecard = EXT:vitec/Resources/Private/Templates/Simplecard/Backendpreview.html
|
||||
vitec_downloadcard = EXT:vitec/Resources/Private/Templates/Downloadcard/Backendpreview.html
|
||||
vitec_downloadcardcollection = EXT:vitec/Resources/Private/Templates/Downloadcardcollection/Backendpreview.html
|
||||
# vitec_usecaseshow = EXT:vitec/Resources/Private/Templates/Usecaseshow/Backendpreview.html
|
||||
# ^ Pfad anpassen wenn das Template existiert, dann Kommentar entfernen
|
||||
}
|
||||
|
||||
# Hinweis: Content-Blocks-Previews werden automatisch aus
|
||||
# packages/vitec/ContentBlocks/ContentElements/<n>/templates/backend-preview.html geladen.
|
||||
# Keine separate Registrierung nötig.
|
||||
# Keine separate Registrierung nötig.
|
||||
|
||||
23
packages/vitec/ContentBlocks/Basics/ButtonStyle.yaml
Executable file
23
packages/vitec/ContentBlocks/Basics/ButtonStyle.yaml
Executable file
@@ -0,0 +1,23 @@
|
||||
identifier: Vitec/ButtonStyle
|
||||
fields:
|
||||
- identifier: button_style
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
prefixField: false
|
||||
default: orange
|
||||
label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.label'
|
||||
items:
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.items.orange.label'
|
||||
value: orange
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.items.blue.label'
|
||||
value: blue
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.items.graphite.label'
|
||||
value: graphite
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.items.midnight.label'
|
||||
value: midnight
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.items.light.label'
|
||||
value: light
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.items.dark.label'
|
||||
value: dark
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:button_style.items.text_only.label'
|
||||
value: text_only
|
||||
23
packages/vitec/ContentBlocks/Basics/SecondaryButtonStyle.yaml
Executable file
23
packages/vitec/ContentBlocks/Basics/SecondaryButtonStyle.yaml
Executable file
@@ -0,0 +1,23 @@
|
||||
identifier: Vitec/SecondaryButtonStyle
|
||||
fields:
|
||||
- identifier: secondary_button_style
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
prefixField: false
|
||||
default: graphite
|
||||
label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.label'
|
||||
items:
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.items.orange.label'
|
||||
value: orange
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.items.blue.label'
|
||||
value: blue
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.items.graphite.label'
|
||||
value: graphite
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.items.midnight.label'
|
||||
value: midnight
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.items.light.label'
|
||||
value: light
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.items.dark.label'
|
||||
value: dark
|
||||
- label: 'LLL:EXT:vitec/Resources/Private/Language/locallang_buttons.xlf:secondary_button_style.items.text_only.label'
|
||||
value: text_only
|
||||
@@ -1,5 +1,5 @@
|
||||
name: vitec/card
|
||||
group: vitec
|
||||
group: vitec_custom_components
|
||||
prefixFields: true
|
||||
prefixType: vendor
|
||||
|
||||
@@ -61,6 +61,12 @@ fields:
|
||||
type: Text
|
||||
max: 30
|
||||
|
||||
- identifier: Vitec/ButtonStyle
|
||||
type: Basic
|
||||
|
||||
- identifier: Vitec/SecondaryButtonStyle
|
||||
type: Basic
|
||||
|
||||
- identifier: card_link
|
||||
type: Link
|
||||
allowedTypes:
|
||||
|
||||
8
packages/vitec/ContentBlocks/ContentElements/cta-banner/config.yaml
Normal file → Executable file
8
packages/vitec/ContentBlocks/ContentElements/cta-banner/config.yaml
Normal file → Executable file
@@ -1,5 +1,5 @@
|
||||
name: vitec/cta-banner
|
||||
group: vitec
|
||||
group: vitec_custom_components
|
||||
prefixFields: true
|
||||
prefixType: vendor
|
||||
|
||||
@@ -39,6 +39,12 @@ fields:
|
||||
type: Text
|
||||
max: 30
|
||||
|
||||
- identifier: Vitec/ButtonStyle
|
||||
type: Basic
|
||||
|
||||
- identifier: Vitec/SecondaryButtonStyle
|
||||
type: Basic
|
||||
|
||||
- identifier: layout_variant
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
|
||||
97
packages/vitec/ContentBlocks/ContentElements/hero-section/config.yaml
Normal file → Executable file
97
packages/vitec/ContentBlocks/ContentElements/hero-section/config.yaml
Normal file → Executable file
@@ -1,5 +1,5 @@
|
||||
name: vitec/herosection
|
||||
group: vitec
|
||||
group: vitec_custom_components
|
||||
prefixFields: true
|
||||
prefixType: vendor
|
||||
|
||||
@@ -12,6 +12,15 @@ fields:
|
||||
useExistingField: true
|
||||
required: true
|
||||
|
||||
- identifier: header_layout
|
||||
useExistingField: true
|
||||
|
||||
- identifier: header_position
|
||||
useExistingField: true
|
||||
|
||||
- identifier: header_link
|
||||
useExistingField: true
|
||||
|
||||
- identifier: subheader
|
||||
useExistingField: true
|
||||
|
||||
@@ -32,6 +41,17 @@ fields:
|
||||
default: 'Learn more'
|
||||
max: 30
|
||||
|
||||
- identifier: Vitec/ButtonStyle
|
||||
type: Basic
|
||||
|
||||
# --- Image ---
|
||||
- identifier: hero_bgimage
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
extendedPalette: true
|
||||
|
||||
- identifier: hero_image
|
||||
type: File
|
||||
minitems: 0
|
||||
@@ -39,22 +59,71 @@ fields:
|
||||
allowed: common-image-types
|
||||
extendedPalette: true
|
||||
|
||||
- identifier: background_variant
|
||||
- identifier: hero_image_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
default: none
|
||||
default: center
|
||||
items:
|
||||
- label: None
|
||||
value: none
|
||||
- label: Orange
|
||||
value: orange
|
||||
- label: Blue
|
||||
value: blue
|
||||
- label: Graphite
|
||||
value: graphite
|
||||
- label: Midnight
|
||||
value: midnight
|
||||
- label: Left
|
||||
value: left
|
||||
- label: Center
|
||||
value: center
|
||||
- label: Right
|
||||
value: right
|
||||
- label: Fullwidth
|
||||
value: fullwidth
|
||||
|
||||
# --- Video ---
|
||||
- identifier: hero_video
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: hero_video_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
default: center
|
||||
items:
|
||||
- label: Left
|
||||
value: left
|
||||
- label: Center
|
||||
value: center
|
||||
- label: Right
|
||||
value: right
|
||||
- label: Fullwidth
|
||||
value: fullwidth
|
||||
|
||||
- identifier: hero_video_autoplay
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
- identifier: hero_video_loop
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
- identifier: hero_video_muted
|
||||
type: Checkbox
|
||||
default: 1
|
||||
|
||||
- identifier: hero_layout_variant
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
default: fullscreen
|
||||
items:
|
||||
- label: Fullscreen
|
||||
value: fullscreen
|
||||
- label: Medium
|
||||
value: md
|
||||
- label: Small
|
||||
value: sm
|
||||
- label: Extra Small
|
||||
value: xs
|
||||
|
||||
- identifier: show_logo_wall
|
||||
type: Checkbox
|
||||
default: 0
|
||||
default: 0
|
||||
|
||||
- identifier: debug
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
75
packages/vitec/ContentBlocks/ContentElements/hero-section/language/labels.xlf
Normal file → Executable file
75
packages/vitec/ContentBlocks/ContentElements/hero-section/language/labels.xlf
Normal file → Executable file
@@ -30,13 +30,78 @@
|
||||
<source>Hero Image</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="background_variant.label">
|
||||
<source>Background Variant</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="show_logo_wall.label">
|
||||
<source>Show Logo Wall below</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_layout_variant.label">
|
||||
<source>Hero Layout Variant</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_layout_variant.items.fullscreen.label">
|
||||
<source>Fullscreen</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_layout_variant.items.md.label">
|
||||
<source>Medium</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_layout_variant.items.sm.label">
|
||||
<source>Small</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_layout_variant.items.xs.label">
|
||||
<source>Extra Small</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="debug.label">
|
||||
<source>Allow Debug Output</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_image_alignment.label">
|
||||
<source>Image Alignment</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_image_alignment.items.left.label">
|
||||
<source>Left</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_image_alignment.items.center.label">
|
||||
<source>Center</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_image_alignment.items.right.label">
|
||||
<source>Right</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_image_alignment.items.fullwidth.label">
|
||||
<source>Fullwidth</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video.label">
|
||||
<source>Hero Video</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video.description">
|
||||
<source>Upload an MP4/WebM/OGV/MOV/M4V file, or pick one from the file list.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_alignment.label">
|
||||
<source>Video Alignment</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_alignment.items.left.label">
|
||||
<source>Left</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_alignment.items.center.label">
|
||||
<source>Center</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_alignment.items.right.label">
|
||||
<source>Right</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_alignment.items.fullwidth.label">
|
||||
<source>Fullwidth</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_autoplay.label">
|
||||
<source>Autoplay</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_loop.label">
|
||||
<source>Loop</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_video_muted.label">
|
||||
<source>Muted</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_bgimage.label">
|
||||
<source>Background Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hero_bgimage.description">
|
||||
<source>Optional background image placed behind the hero content.</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<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"/>
|
||||
<text x="3.4" y="6.6" font-family="serif" font-weight="bold" font-size="6" fill="#000">¶</text>
|
||||
<rect x="3" y="8" width="9" height="1" rx="0.2" fill="#000" opacity="0.4"/>
|
||||
<rect x="3" y="9.6" width="7.5" height="1" rx="0.2" fill="#000" opacity="0.35"/>
|
||||
<rect x="3" y="11.2" width="4" height="1.6" rx="0.3" fill="#000" opacity="0.55"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
117
packages/vitec/ContentBlocks/ContentElements/intro-paragraph/config.yaml
Executable file
117
packages/vitec/ContentBlocks/ContentElements/intro-paragraph/config.yaml
Executable file
@@ -0,0 +1,117 @@
|
||||
name: vitec/intro-paragraph
|
||||
group: vitec_custom_components
|
||||
prefixFields: true
|
||||
prefixType: vendor
|
||||
|
||||
fields:
|
||||
- identifier: header
|
||||
useExistingField: true
|
||||
required: true
|
||||
|
||||
- identifier: header_layout
|
||||
useExistingField: true
|
||||
|
||||
- identifier: header_position
|
||||
useExistingField: true
|
||||
|
||||
- identifier: header_link
|
||||
useExistingField: true
|
||||
|
||||
- identifier: subheader
|
||||
useExistingField: true
|
||||
|
||||
- identifier: bodytext
|
||||
useExistingField: true
|
||||
enableRichtext: true
|
||||
|
||||
- identifier: button_text
|
||||
type: Text
|
||||
max: 40
|
||||
|
||||
- identifier: button_link
|
||||
type: Link
|
||||
allowedTypes:
|
||||
- page
|
||||
- url
|
||||
- file
|
||||
- email
|
||||
|
||||
- identifier: Vitec/ButtonStyle
|
||||
type: Basic
|
||||
|
||||
# --- Image ---
|
||||
- identifier: intro_image
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
extendedPalette: true
|
||||
|
||||
- identifier: intro_image_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
default: center
|
||||
items:
|
||||
- label: Left
|
||||
value: left
|
||||
- label: Center
|
||||
value: center
|
||||
- label: Right
|
||||
value: right
|
||||
- label: Fullwidth
|
||||
value: fullwidth
|
||||
|
||||
# --- Video ---
|
||||
- identifier: intro_video
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: intro_video_alignment
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
default: center
|
||||
items:
|
||||
- label: Left
|
||||
value: left
|
||||
- label: Center
|
||||
value: center
|
||||
- label: Right
|
||||
value: right
|
||||
- label: Fullwidth
|
||||
value: fullwidth
|
||||
|
||||
- identifier: intro_video_autoplay
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
- identifier: intro_video_loop
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
- identifier: intro_video_muted
|
||||
type: Checkbox
|
||||
default: 1
|
||||
|
||||
- identifier: intro_layout_variant
|
||||
type: Select
|
||||
renderType: selectSingle
|
||||
default: variant_1
|
||||
items:
|
||||
- label: Variant 1
|
||||
value: variant_1
|
||||
- label: Variant 2
|
||||
value: variant_2
|
||||
- label: Variant 3
|
||||
value: variant_3
|
||||
- label: Variant 4
|
||||
value: variant_4
|
||||
|
||||
- identifier: fullwidth
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
- identifier: debug
|
||||
type: Checkbox
|
||||
default: 0
|
||||
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xliff version="1.2">
|
||||
<file source-language="en" datatype="plaintext" original="messages">
|
||||
<body>
|
||||
<trans-unit id="title">
|
||||
<source>VITEC · Intro Paragraph</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="description">
|
||||
<source>Headline + RTE text + optional CTA button. Multiple layout variants and VITEC button colour styles.</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="button_text.label">
|
||||
<source>Button Text</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_link.label">
|
||||
<source>Button Link</source>
|
||||
</trans-unit>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- button_style -->
|
||||
<!-- ============================================================== -->
|
||||
<trans-unit id="button_style.label">
|
||||
<source>Button Style</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.orange.label">
|
||||
<source>Orange</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.blue.label">
|
||||
<source>Blue</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.graphite.label">
|
||||
<source>Graphite</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.midnight.label">
|
||||
<source>Midnight</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.light.label">
|
||||
<source>Light</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.dark.label">
|
||||
<source>Dark</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.text_only.label">
|
||||
<source>Only Text (no button background)</source>
|
||||
</trans-unit>
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- layout_variant -->
|
||||
<!-- ============================================================== -->
|
||||
<trans-unit id="intro_layout_variant.label">
|
||||
<source>Layout Variant</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="intro_layout_variant.items.variant_1.label">
|
||||
<source>Variant 1</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="intro_layout_variant.items.variant_2.label">
|
||||
<source>Variant 2</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="intro_layout_variant.items.variant_3.label">
|
||||
<source>Variant 3</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="intro_layout_variant.items.variant_4.label">
|
||||
<source>Variant 4</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="fullwidth.label">
|
||||
<source>Render full-width (edge to edge)</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="debug.label">
|
||||
<source>Allow Debug Output</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="debug.description">
|
||||
<source>When enabled, extra debug fields may be included in the JSON output for this element.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="intro_image.label">
|
||||
<source>Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="intro_image_alignment.label">
|
||||
<source>Image Alignment</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="intro_image_alignment.items.left.label"><source>Left</source></trans-unit>
|
||||
<trans-unit id="intro_image_alignment.items.center.label"><source>Center</source></trans-unit>
|
||||
<trans-unit id="intro_image_alignment.items.right.label"><source>Right</source></trans-unit>
|
||||
<trans-unit id="intro_image_alignment.items.fullwidth.label"><source>Fullwidth</source></trans-unit>
|
||||
<trans-unit id="intro_video.label"><source>Video</source></trans-unit>
|
||||
<trans-unit id="intro_video.description"><source>Upload an MP4/WebM/OGV/MOV/M4V file, or pick one from the file list.</source></trans-unit>
|
||||
<trans-unit id="intro_video_alignment.label"><source>Video Alignment</source></trans-unit>
|
||||
<trans-unit id="intro_video_alignment.items.left.label"><source>Left</source></trans-unit>
|
||||
<trans-unit id="intro_video_alignment.items.center.label"><source>Center</source></trans-unit>
|
||||
<trans-unit id="intro_video_alignment.items.right.label"><source>Right</source></trans-unit>
|
||||
<trans-unit id="intro_video_alignment.items.fullwidth.label"><source>Fullwidth</source></trans-unit>
|
||||
<trans-unit id="intro_video_autoplay.label"><source>Autoplay</source></trans-unit>
|
||||
<trans-unit id="intro_video_loop.label"><source>Loop</source></trans-unit>
|
||||
<trans-unit id="intro_video_muted.label"><source>Muted</source></trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
@@ -0,0 +1,76 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<f:layout name="Preview"/>
|
||||
<f:section name="Content">
|
||||
|
||||
<f:asset.css identifier="vitec-backend-preview" href="EXT:vitec/Resources/Public/Css/backend-preview.css"/>
|
||||
|
||||
<div class="vitec-preview">
|
||||
<div class="vitec-preview__body">
|
||||
<div class="vitec-preview__label">VITEC · Intro Paragraph</div>
|
||||
|
||||
<h3 class="vitec-preview__headline">
|
||||
<f:if condition="{data.header}">
|
||||
<f:then>{data.header}</f:then>
|
||||
<f:else>
|
||||
<em style="color:#c00;">⚠ Headline fehlt</em>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</h3>
|
||||
|
||||
<f:if condition="{data.subheader}">
|
||||
<div class="vitec-preview__subline">{data.subheader}</div>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.bodytext}">
|
||||
<div class="vitec-preview__body-text">
|
||||
<f:format.stripTags>{data.bodytext}</f:format.stripTags>
|
||||
</div>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.button_text}">
|
||||
<div class="vitec-preview__ctas">
|
||||
<span class="vitec-preview__cta-btn vitec-preview__cta-btn--{data.button_style}">
|
||||
{data.button_text} →
|
||||
</span>
|
||||
</div>
|
||||
</f:if>
|
||||
</div>
|
||||
|
||||
<f:comment>Settings sidebar</f:comment>
|
||||
<div class="vitec-preview__settings">
|
||||
|
||||
<span class="vitec-badge">
|
||||
<f:switch expression="{data.intro_layout_variant}">
|
||||
<f:case value="variant_1">▤ Variant 1</f:case>
|
||||
<f:case value="variant_2">▥ Variant 2</f:case>
|
||||
<f:case value="variant_3">▤ Variant 3</f:case>
|
||||
<f:case value="variant_4">▥ Variant 4</f:case>
|
||||
<f:defaultCase>{data.intro_layout_variant}</f:defaultCase>
|
||||
</f:switch>
|
||||
</span>
|
||||
|
||||
<f:if condition="{data.button_text}">
|
||||
<span class="vitec-badge">
|
||||
<span class="vitec-badge__dot vitec-badge__dot--{data.button_style}"></span>
|
||||
Button: {data.button_style}
|
||||
</span>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.fullwidth}">
|
||||
<span class="vitec-badge">↔ Full width</span>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.button_text} && {data.button_link.url} == ''">
|
||||
<span class="vitec-badge vitec-badge--warning">⚠ Button-Text ohne Link</span>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.debug}">
|
||||
<span class="vitec-badge vitec-badge--warning">⚙ Debug ON</span>
|
||||
</f:if>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</f:section>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<!-- Headless mode: JSON is built by nb-headless-content-blocks -->
|
||||
</html>
|
||||
4
packages/vitec/ContentBlocks/ContentElements/video/assets/icon.svg
Executable file
4
packages/vitec/ContentBlocks/ContentElements/video/assets/icon.svg
Executable file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<rect x="1.5" y="3" width="13" height="10" rx="1.4" fill="none" stroke="#000" stroke-width="1.2"/>
|
||||
<polygon points="6.5,5.8 11,8 6.5,10.2" fill="#000" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
46
packages/vitec/ContentBlocks/ContentElements/video/config.yaml
Executable file
46
packages/vitec/ContentBlocks/ContentElements/video/config.yaml
Executable file
@@ -0,0 +1,46 @@
|
||||
name: vitec/video
|
||||
group: vitec_custom_components
|
||||
prefixFields: true
|
||||
prefixType: vendor
|
||||
|
||||
fields:
|
||||
- identifier: header
|
||||
useExistingField: true
|
||||
required: true
|
||||
|
||||
- identifier: header_layout
|
||||
useExistingField: true
|
||||
|
||||
- identifier: header_position
|
||||
useExistingField: true
|
||||
|
||||
- identifier: header_link
|
||||
useExistingField: true
|
||||
|
||||
- identifier: subheader
|
||||
useExistingField: true
|
||||
|
||||
- identifier: youtube_code
|
||||
type: Text
|
||||
max: 50
|
||||
description: 'YouTube video ID (e.g. "dQw4w9WgXcQ") OR full URL. Leave empty to use Custom Video instead.'
|
||||
|
||||
- identifier: poster
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: common-image-types
|
||||
|
||||
- identifier: custom_video
|
||||
type: File
|
||||
minitems: 0
|
||||
maxitems: 1
|
||||
allowed: mp4,webm,ogv,mov,m4v
|
||||
|
||||
- identifier: hide_controls
|
||||
type: Checkbox
|
||||
default: 0
|
||||
|
||||
- identifier: debug
|
||||
type: Checkbox
|
||||
default: 0
|
||||
45
packages/vitec/ContentBlocks/ContentElements/video/language/labels.xlf
Executable file
45
packages/vitec/ContentBlocks/ContentElements/video/language/labels.xlf
Executable file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xliff version="1.2">
|
||||
<file source-language="en" datatype="plaintext" original="messages">
|
||||
<body>
|
||||
<trans-unit id="title">
|
||||
<source>VITEC · Video</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="description">
|
||||
<source>Embedded video — YouTube code or uploaded video file, with optional poster image.</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="youtube_code.label">
|
||||
<source>YouTube Code</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="youtube_code.description">
|
||||
<source>YouTube video ID (e.g. "dQw4w9WgXcQ") OR full URL. Leave empty to use Custom Video instead.</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="poster.label">
|
||||
<source>Poster Image</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="poster.description">
|
||||
<source>Thumbnail / poster shown before the video plays.</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="custom_video.label">
|
||||
<source>Custom Video</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="custom_video.description">
|
||||
<source>Upload an MP4/WebM/OGV/MOV/M4V file, or pick one from the file list.</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="hide_controls.label">
|
||||
<source>Hide Player Controls</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="hide_controls.description">
|
||||
<source>When enabled, the video player UI (play/pause, scrub bar, volume) is hidden. Useful for autoplaying background loops.</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="debug.label">
|
||||
<source>Allow Debug Output</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
@@ -0,0 +1,78 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<f:layout name="Preview"/>
|
||||
<f:section name="Content">
|
||||
|
||||
<f:asset.css identifier="vitec-backend-preview" href="EXT:vitec/Resources/Public/Css/backend-preview.css"/>
|
||||
|
||||
<div class="vitec-preview">
|
||||
|
||||
<f:comment>Thumbnail: poster image, then default placeholder</f:comment>
|
||||
<f:if condition="{data.poster.0}">
|
||||
<f:then>
|
||||
<f:image image="{data.poster.0}"
|
||||
class="vitec-preview__thumb vitec-preview__thumb--video"
|
||||
width="200c"
|
||||
height="120c"
|
||||
alt="Video poster preview"/>
|
||||
</f:then>
|
||||
<f:else>
|
||||
<div class="vitec-preview__thumb-placeholder">▶</div>
|
||||
</f:else>
|
||||
</f:if>
|
||||
|
||||
<div class="vitec-preview__body">
|
||||
<div class="vitec-preview__label">VITEC · Video</div>
|
||||
|
||||
<h3 class="vitec-preview__headline">
|
||||
<f:if condition="{data.header}">
|
||||
<f:then>{data.header}</f:then>
|
||||
<f:else>
|
||||
<em style="color:#c00;">⚠ Headline fehlt</em>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</h3>
|
||||
|
||||
<f:if condition="{data.subheader}">
|
||||
<div class="vitec-preview__subline">{data.subheader}</div>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.youtube_code}">
|
||||
<div class="vitec-preview__body-text">
|
||||
<strong>YouTube:</strong> {data.youtube_code}
|
||||
</div>
|
||||
</f:if>
|
||||
</div>
|
||||
|
||||
<f:comment>Settings sidebar</f:comment>
|
||||
<div class="vitec-preview__settings">
|
||||
|
||||
<f:if condition="{data.youtube_code}">
|
||||
<span class="vitec-badge">▶ YouTube</span>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.custom_video.0}">
|
||||
<span class="vitec-badge">🎬 Custom Video</span>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="{data.poster.0}">
|
||||
<span class="vitec-badge">🖼 Poster set</span>
|
||||
</f:if>
|
||||
|
||||
<f:if condition="!{data.youtube_code} && !{data.custom_video.0}">
|
||||
<span class="vitec-badge vitec-badge--warning">⚠ Kein Video-Source</span>
|
||||
</f:if>
|
||||
<f:if condition="{data.hide_controls}">
|
||||
<span class="vitec-badge">⏸ Controls hidden</span>
|
||||
</f:if>
|
||||
|
||||
|
||||
<f:if condition="{data.debug}">
|
||||
<span class="vitec-badge vitec-badge--warning">⚙ Debug ON</span>
|
||||
</f:if>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</f:section>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
<!-- Headless mode: JSON is built by nb-headless-content-blocks -->
|
||||
</html>
|
||||
59
packages/vitec/Resources/Private/Language/locallang_buttons.xlf
Executable file
59
packages/vitec/Resources/Private/Language/locallang_buttons.xlf
Executable file
@@ -0,0 +1,59 @@
|
||||
<?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_buttons.xlf" product-name="vitec">
|
||||
<header/>
|
||||
<body>
|
||||
<!-- Primary / single button -->
|
||||
<trans-unit id="button_style.label" resname="button_style.label">
|
||||
<source>Button Style</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.orange.label" resname="button_style.items.orange.label">
|
||||
<source>Orange</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.blue.label" resname="button_style.items.blue.label">
|
||||
<source>Blue</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.graphite.label" resname="button_style.items.graphite.label">
|
||||
<source>Graphite</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.midnight.label" resname="button_style.items.midnight.label">
|
||||
<source>Midnight</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.light.label" resname="button_style.items.light.label">
|
||||
<source>Light</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.dark.label" resname="button_style.items.dark.label">
|
||||
<source>Dark</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="button_style.items.text_only.label" resname="button_style.items.text_only.label">
|
||||
<source>Only Text (no button background)</source>
|
||||
</trans-unit>
|
||||
|
||||
<!-- Secondary button -->
|
||||
<trans-unit id="secondary_button_style.label" resname="secondary_button_style.label">
|
||||
<source>Secondary Button Style</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="secondary_button_style.items.orange.label" resname="secondary_button_style.items.orange.label">
|
||||
<source>Orange</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="secondary_button_style.items.blue.label" resname="secondary_button_style.items.blue.label">
|
||||
<source>Blue</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="secondary_button_style.items.graphite.label" resname="secondary_button_style.items.graphite.label">
|
||||
<source>Graphite</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="secondary_button_style.items.midnight.label" resname="secondary_button_style.items.midnight.label">
|
||||
<source>Midnight</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="secondary_button_style.items.light.label" resname="secondary_button_style.items.light.label">
|
||||
<source>Light</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="secondary_button_style.items.dark.label" resname="secondary_button_style.items.dark.label">
|
||||
<source>Dark</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="secondary_button_style.items.text_only.label" resname="secondary_button_style.items.text_only.label">
|
||||
<source>Only Text (no button background)</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
@@ -6,6 +6,9 @@
|
||||
<trans-unit id="group.header" resname="group.header">
|
||||
<source>VITEC</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="group.custom_components.header" resname="group.custom_components.header">
|
||||
<source>Custom Components</source>
|
||||
</trans-unit>
|
||||
|
||||
<trans-unit id="section.heading" resname="section.heading">
|
||||
<source>Section Heading</source>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Product",
|
||||
"name": "Executive Anvil",
|
||||
"description": "Sleeker than ACME's Classic Anvil, the Executive Anvil is perfect for the business traveler looking for something to drop from a height.",
|
||||
"review": {
|
||||
"@type": "Review",
|
||||
"reviewRating": {
|
||||
"@type": "Rating",
|
||||
"ratingValue": 4,
|
||||
"bestRating": 5
|
||||
},
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Fred Benson"
|
||||
}
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": 4.4,
|
||||
"reviewCount": 89
|
||||
}
|
||||
}
|
||||
</script>
|
||||
39
packages/vitec/Resources/Private/Templates/Downloadcard/Backendpreview.html
Executable file
39
packages/vitec/Resources/Private/Templates/Downloadcard/Backendpreview.html
Executable file
@@ -0,0 +1,39 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
|
||||
<f:asset.css identifier="vitec-backend-preview" href="EXT:vitec/Resources/Public/Css/backend-preview.css"/>
|
||||
|
||||
<div class="vitec-preview">
|
||||
|
||||
<div class="vitec-preview__thumb-placeholder">⬇</div>
|
||||
|
||||
<div class="vitec-preview__body">
|
||||
<div class="vitec-preview__label">VITEC · Download Card</div>
|
||||
|
||||
<h3 class="vitec-preview__headline">
|
||||
<f:if condition="{data.header}">
|
||||
<f:then>{data.header}</f:then>
|
||||
<f:else>
|
||||
<em style="color:#888;">Download Card (kein Header)</em>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</h3>
|
||||
|
||||
<div class="vitec-preview__body-text">
|
||||
<small>
|
||||
Konfiguration im FlexForm:
|
||||
<strong>Produkt</strong> oder <strong>Download</strong> wählen,
|
||||
Layout-Variante setzen.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vitec-preview__settings">
|
||||
<span class="vitec-badge">⬇ Single Download / Product</span>
|
||||
<f:if condition="{data.pi_flexform}">
|
||||
<span class="vitec-badge">⚙ FlexForm configured</span>
|
||||
</f:if>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
|
||||
|
||||
<f:asset.css identifier="vitec-backend-preview" href="EXT:vitec/Resources/Public/Css/backend-preview.css"/>
|
||||
|
||||
<div class="vitec-preview">
|
||||
|
||||
<div class="vitec-preview__thumb-placeholder">⬇⬇</div>
|
||||
|
||||
<div class="vitec-preview__body">
|
||||
<div class="vitec-preview__label">VITEC · Download Card Collection</div>
|
||||
|
||||
<h3 class="vitec-preview__headline">
|
||||
<f:if condition="{data.header}">
|
||||
<f:then>{data.header}</f:then>
|
||||
<f:else>
|
||||
<em style="color:#888;">Download Card Collection (kein Header)</em>
|
||||
</f:else>
|
||||
</f:if>
|
||||
</h3>
|
||||
|
||||
<div class="vitec-preview__body-text">
|
||||
<small>
|
||||
Multi-Download-Auswahl mit optionalem Header-Image.
|
||||
Konfiguration im FlexForm.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vitec-preview__settings">
|
||||
<span class="vitec-badge">⬇⬇ Multi-Download</span>
|
||||
<f:if condition="{data.pi_flexform}">
|
||||
<span class="vitec-badge">⚙ FlexForm configured</span>
|
||||
</f:if>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</html>
|
||||
6
packages/vitec/Resources/Public/Css/backend-preview.css
Normal file → Executable file
6
packages/vitec/Resources/Public/Css/backend-preview.css
Normal file → Executable file
@@ -56,6 +56,8 @@
|
||||
/* Content-Slot */
|
||||
.vitec-preview__body {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.vitec-preview__label {
|
||||
@@ -106,7 +108,7 @@
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-end;
|
||||
min-width: 120px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.vitec-badge {
|
||||
@@ -193,7 +195,7 @@
|
||||
}
|
||||
|
||||
/* Responsive Fallback */
|
||||
@media (max-width: 680px) {
|
||||
@media (max-width: 900px) {
|
||||
.vitec-preview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
91
packages/vitec/Resources/Public/Javascript/structured-data-generator.js
Executable file
91
packages/vitec/Resources/Public/Javascript/structured-data-generator.js
Executable file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* VITEC – Structured Data generator button (FormEngine fieldWizard).
|
||||
* EXT:vitec/Resources/Public/Javascript/structured-data-generator.js
|
||||
*
|
||||
* Self-initializing: on load it installs a single delegated click handler for
|
||||
* any button matching [data-vitec-sd-generate]. This avoids relying on the
|
||||
* JavaScriptModuleInstruction ->instance() payload (which is unreliable for
|
||||
* fieldWizards) and also works for fields added to the DOM later.
|
||||
*
|
||||
* On click it calls the backend AJAX endpoint for the product uid and writes
|
||||
* the returned JSON-LD string into the associated `structureddata` textarea.
|
||||
* The textarea is resolved by its form name (data-field-name) with a fallback
|
||||
* to the surrounding FormEngine field-item container.
|
||||
*/
|
||||
import DocumentService from "@typo3/core/document-service.js";
|
||||
import AjaxRequest from "@typo3/core/ajax/ajax-request.js";
|
||||
import Notification from "@typo3/backend/notification.js";
|
||||
|
||||
const SELECTOR = "[data-vitec-sd-generate]";
|
||||
|
||||
function findField(button) {
|
||||
const name = button.dataset.fieldName;
|
||||
if (name) {
|
||||
const byName = document.querySelector('[name="' + name + '"]');
|
||||
if (byName !== null) {
|
||||
return byName;
|
||||
}
|
||||
}
|
||||
const item = button.closest(".t3js-formengine-field-item");
|
||||
if (item !== null) {
|
||||
return item.querySelector("textarea, input[type=text], input:not([type])");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function generate(button) {
|
||||
const field = findField(button);
|
||||
if (!field) {
|
||||
Notification.error("Fehler", "Das Structured-Data-Feld wurde nicht gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
const ajaxUrl = TYPO3.settings.ajaxUrls["vitec_structureddata_generate"];
|
||||
if (!ajaxUrl) {
|
||||
Notification.error("Fehler", "AJAX-Route nicht registriert.");
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await new AjaxRequest(ajaxUrl)
|
||||
.withQueryArguments({ uid: button.dataset.uid })
|
||||
.get();
|
||||
const data = await response.resolve();
|
||||
|
||||
if (data.success) {
|
||||
field.value = data.jsonLd;
|
||||
field.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
Notification.success(
|
||||
"Structured Data",
|
||||
"JSON-LD wurde generiert und in das Feld geschrieben."
|
||||
);
|
||||
} else {
|
||||
Notification.warning("Hinweis", data.message || "Generierung nicht möglich.");
|
||||
}
|
||||
} catch (error) {
|
||||
Notification.error("Fehler", "Die Generierung ist fehlgeschlagen.");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
// Guard against binding more than once (the module instruction may be
|
||||
// emitted per field instance, but the module itself is a singleton).
|
||||
if (document.body.dataset.vitecSdBound === "1") {
|
||||
return;
|
||||
}
|
||||
document.body.dataset.vitecSdBound = "1";
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(SELECTOR);
|
||||
if (button === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
generate(button);
|
||||
});
|
||||
}
|
||||
|
||||
DocumentService.ready().then(init);
|
||||
@@ -28,6 +28,14 @@ $GLOBALS['TYPO3_CONF_VARS']['SYS']['Objects'][\TYPO3\CMS\Backend\View\BackendLay
|
||||
// Register custom VITEC CKEditor RTE preset
|
||||
$GLOBALS['TYPO3_CONF_VARS']['RTE']['Presets']['vitec'] = 'EXT:vitec/Configuration/RTE/Vitec.yaml';
|
||||
|
||||
// FormEngine fieldWizard: "Structured Data generieren" button below the
|
||||
// product structureddata field (see TCA + StructuredDataGeneratorWizard).
|
||||
$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1750000000] = [
|
||||
'nodeName' => 'structuredDataGenerator',
|
||||
'priority' => 40,
|
||||
'class' => \Evomedien\Vitec\Backend\FormEngine\StructuredDataGeneratorWizard::class,
|
||||
];
|
||||
|
||||
(static function () {
|
||||
// Register plugins
|
||||
ExtensionUtility::configurePlugin(
|
||||
|
||||
Reference in New Issue
Block a user