Product texts: mapping completed; product model: solutions + resources
Two work packages, one deploy.
Workbook field mapping completed (2026-09-18)
- highlights <- g:Technical Highlight (new spec-row component of the
full agency delivery; composeGroup() is generic, no change needed)
- applications <- g:Why Choose Card (benefit cards as one block)
- seometa <- new composed source x:seometa (Introduction body trimmed
at a word boundary to ~155 chars - the SEO sheet ships no meta
description, a trimmed intro beats an empty tag)
- teaser stays the hero one-liner, settling the varchar(255) question
- fix: "Save mapping" now merges over the stored mapping instead of
overwriting it - an empty dropdown no longer wipes a stored selector
Product model: solutions relation and Resources tab (2026-09-24)
- closes the gaps the full workbook walkthrough exposed (rows the
import deliberately skips because the model had no place for them)
- solutions: MM multi select to tx_vitec_domain_model_solution
(tx_vitec_product_solution_mm, relatedprodukt pattern), emitted by
both product renderers as solution cards in the shared shape
- Resources tab: inline child table tx_vitec_product_resource - per
entry a type (documentation/support/datasheet), editable card title
and text, an icon (free text with a value-picker seed set) and one
link (page/url/file/record via the link wizard, so the Download tab
works too). Deliberately no Extbase model: only the UserFunc path
reads the rows.
- JSON `resources` (detail payload only, like heroimage): list envelope
{layout, items[]}; a card whose link is empty or unresolvable is not
emitted; layout switches to carousel above three cards
- spec raised to v1.18 (7.15 member table, revision row; the footer had
been stuck at v1.15 since 1.16/1.17)
Requires on the server:
vendor/bin/typo3 database:updateschema "*.add,*.change"
vendor/bin/typo3 cache:flush
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a7ea387a0
commit
3df66b1353
9 files changed
+394
-4
No files matched your search
@@ -95,6 +95,13 @@ final class ProductTextImportController
|
|||||||
'description' => 'c:Product Introduction#1:body',
|
'description' => 'c:Product Introduction#1:body',
|
||||||
'description2' => 'c:Product Overview — H2#1:body',
|
'description2' => 'c:Product Overview — H2#1:body',
|
||||||
'capabilities' => 'g:Key Capability Group',
|
'capabilities' => 'g:Key Capability Group',
|
||||||
|
// Decisions 2026-09-18 (field walkthrough completed): the new
|
||||||
|
// 'Technical Highlight' spec rows and the Why-Choose benefit cards
|
||||||
|
// each compose into one block; meta description is trimmed from the
|
||||||
|
// intro because the SEO sheet still ships none.
|
||||||
|
'highlights' => 'g:Technical Highlight',
|
||||||
|
'applications' => 'g:Why Choose Card',
|
||||||
|
'seometa' => 'x:seometa',
|
||||||
'textrelatedproducts' => 'p:Related Products — H2#1',
|
'textrelatedproducts' => 'p:Related Products — H2#1',
|
||||||
'keywords' => 'm:primaryKeyword',
|
'keywords' => 'm:primaryKeyword',
|
||||||
];
|
];
|
||||||
@@ -372,7 +379,12 @@ final class ProductTextImportController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($body['saveMapping'])) {
|
if (!empty($body['saveMapping'])) {
|
||||||
$this->mappingRepository->save(self::MAPPING_KEY, array_filter($map), '-');
|
// Merge instead of overwrite: a field whose dropdown was empty on
|
||||||
|
// THIS submit keeps its stored selector (this is how `teaser` got
|
||||||
|
// lost once). Clearing a mapping on purpose means picking another
|
||||||
|
// source, not leaving the select blank.
|
||||||
|
$stored = $this->mappingRepository->load(self::MAPPING_KEY)['mapping'] ?? [];
|
||||||
|
$this->mappingRepository->save(self::MAPPING_KEY, array_filter($map) + $stored, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
$summary = [];
|
$summary = [];
|
||||||
|
|||||||
@@ -722,6 +722,7 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
|||||||
$this->productimage = new ObjectStorage();
|
$this->productimage = new ObjectStorage();
|
||||||
$this->heroimage = new ObjectStorage();
|
$this->heroimage = new ObjectStorage();
|
||||||
$this->downloads = new ObjectStorage();
|
$this->downloads = new ObjectStorage();
|
||||||
|
$this->solutions = new ObjectStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1070,6 +1071,32 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
|
|||||||
$this->textrelatedproducts = $textrelatedproducts;
|
$this->textrelatedproducts = $textrelatedproducts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solutions this product belongs to (MM). No companion property for the
|
||||||
|
* `resources` child table on purpose: it has no Extbase model - the
|
||||||
|
* headless renderers read tx_vitec_product_resource directly and the
|
||||||
|
* Extbase path never touches it.
|
||||||
|
*
|
||||||
|
* @var ObjectStorage<\Evomedien\Vitec\Domain\Model\Solution>
|
||||||
|
*/
|
||||||
|
protected $solutions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ObjectStorage<\Evomedien\Vitec\Domain\Model\Solution>
|
||||||
|
*/
|
||||||
|
public function getSolutions(): ObjectStorage
|
||||||
|
{
|
||||||
|
return $this->solutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param ObjectStorage<\Evomedien\Vitec\Domain\Model\Solution> $solutions
|
||||||
|
*/
|
||||||
|
public function setSolutions(ObjectStorage $solutions): void
|
||||||
|
{
|
||||||
|
$this->solutions = $solutions;
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- */
|
/* --------------------------------------------------------------------- */
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -227,6 +227,10 @@ final class ProductXlsxReader
|
|||||||
if ($seoTitle !== null) {
|
if ($seoTitle !== null) {
|
||||||
$groups['Composed'][] = $this->entry('x:seotitle', 'Hero title — Intro heading (SEO title)', $seoTitle);
|
$groups['Composed'][] = $this->entry('x:seotitle', 'Hero title — Intro heading (SEO title)', $seoTitle);
|
||||||
}
|
}
|
||||||
|
$seoMeta = $this->composeSeoMeta($parsed);
|
||||||
|
if ($seoMeta !== null) {
|
||||||
|
$groups['Composed'][] = $this->entry('x:seometa', 'Introduction body, trimmed to ~155 chars (meta description)', $seoMeta);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($parsed['seo'] ?? [] as $header => $value) {
|
foreach ($parsed['seo'] ?? [] as $header => $value) {
|
||||||
$groups['Page SEO Check'][] = $this->entry('s:' . $header, 'SEO · ' . $header, (string)$value);
|
$groups['Page SEO Check'][] = $this->entry('s:' . $header, 'SEO · ' . $header, (string)$value);
|
||||||
@@ -262,6 +266,9 @@ final class ProductXlsxReader
|
|||||||
if ($selector === 'x:seotitle') {
|
if ($selector === 'x:seotitle') {
|
||||||
return $this->composeSeoTitle($parsed);
|
return $this->composeSeoTitle($parsed);
|
||||||
}
|
}
|
||||||
|
if ($selector === 'x:seometa') {
|
||||||
|
return $this->composeSeoMeta($parsed);
|
||||||
|
}
|
||||||
if (str_starts_with($selector, 's:')) {
|
if (str_starts_with($selector, 's:')) {
|
||||||
$value = $parsed['seo'][substr($selector, 2)] ?? null;
|
$value = $parsed['seo'][substr($selector, 2)] ?? null;
|
||||||
return is_string($value) && $value !== '' ? $value : null;
|
return is_string($value) && $value !== '' ? $value : null;
|
||||||
@@ -310,6 +317,37 @@ final class ProductXlsxReader
|
|||||||
return $hero . ' — ' . $intro;
|
return $hero . ' — ' . $intro;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meta description from the Product Introduction body: plain text,
|
||||||
|
* whitespace collapsed, cut at a word boundary to the customary ~155
|
||||||
|
* characters with an ellipsis. Decision 2026-09-18: composed here because
|
||||||
|
* the SEO sheet still ships no meta description at all - a trimmed intro
|
||||||
|
* beats an empty tag at go-live, and the editor can always overwrite.
|
||||||
|
*/
|
||||||
|
private function composeSeoMeta(array $parsed): ?string
|
||||||
|
{
|
||||||
|
foreach ($parsed['components'] ?? [] as $component) {
|
||||||
|
if ((string)($component['component'] ?? '') === 'Product Introduction'
|
||||||
|
&& (int)($component['occurrence'] ?? 1) === 1
|
||||||
|
) {
|
||||||
|
$text = trim((string)preg_replace('/\s+/u', ' ', strip_tags((string)($component['body'] ?? ''))));
|
||||||
|
if ($text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mb_strlen($text) <= 155) {
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
$cut = mb_substr($text, 0, 152);
|
||||||
|
$lastSpace = mb_strrpos($cut, ' ');
|
||||||
|
if ($lastSpace !== false && $lastSpace > 100) {
|
||||||
|
$cut = mb_substr($cut, 0, $lastSpace);
|
||||||
|
}
|
||||||
|
return rtrim($cut, " ,;:") . ' …';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One component row as a heading/paragraph pair. The markup - centred h3
|
* One component row as a heading/paragraph pair. The markup - centred h3
|
||||||
* with an inner span, centred p - is copied verbatim from what the
|
* with an inner span, centred p - is copied verbatim from what the
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ class ProductListJsonRenderer
|
|||||||
'ogimage' => $this->getProductOgImage($uid),
|
'ogimage' => $this->getProductOgImage($uid),
|
||||||
'videofile' => $this->getProductVideoFile($uid),
|
'videofile' => $this->getProductVideoFile($uid),
|
||||||
'relatedprodukt' => $this->getRelatedProducts($uid),
|
'relatedprodukt' => $this->getRelatedProducts($uid),
|
||||||
|
'solutions' => $this->getProductSolutions($uid),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +912,48 @@ class ProductListJsonRenderer
|
|||||||
return $fallbackFiles;
|
return $fallbackFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solutions linked to this product (tx_vitec_product_solution_mm, editor
|
||||||
|
* order) in the shared solution card shape - the same fields the solution
|
||||||
|
* list emits, so the front end reuses its card component. detailUrl is
|
||||||
|
* null while a solution has no detail page (9.12 contract).
|
||||||
|
*/
|
||||||
|
protected function getProductSolutions(int $productUid): array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_vitec_domain_model_solution');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('s.*')
|
||||||
|
->from('tx_vitec_domain_model_solution', 's')
|
||||||
|
->join('s', 'tx_vitec_product_solution_mm', 'mm', 'mm.uid_foreign = s.uid')
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, \Doctrine\DBAL\ParameterType::INTEGER)),
|
||||||
|
$queryBuilder->expr()->eq('s.deleted', 0),
|
||||||
|
$queryBuilder->expr()->eq('s.hidden', 0)
|
||||||
|
)
|
||||||
|
->orderBy('mm.sorting', 'ASC')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
|
||||||
|
$serializer = GeneralUtility::makeInstance(\Evomedien\Vitec\Service\UsecaseSerializer::class);
|
||||||
|
$result = [];
|
||||||
|
foreach ($rows as $solutionRow) {
|
||||||
|
$solutionUid = (int)$solutionRow['uid'];
|
||||||
|
$result[] = [
|
||||||
|
'uid' => $solutionUid,
|
||||||
|
'title' => (string)($solutionRow['title'] ?? ''),
|
||||||
|
'slug' => (string)($solutionRow['slug'] ?? ''),
|
||||||
|
'subtitle' => (string)($solutionRow['subtitle'] ?? ''),
|
||||||
|
'teaser' => (string)($solutionRow['teaser'] ?? ''),
|
||||||
|
'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''),
|
||||||
|
'detailUrl' => \Evomedien\Vitec\Service\LinkResolver::pageUrl((int)($solutionRow['detail_page'] ?? 0)),
|
||||||
|
'image' => $serializer->image($solutionUid, 'image', 'tx_vitec_domain_model_solution', true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getProductDownloads(int $productUid): array
|
protected function getProductDownloads(int $productUid): array
|
||||||
{
|
{
|
||||||
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||||
|
|||||||
@@ -252,6 +252,10 @@ class ProductShowJsonRenderer
|
|||||||
'ogimage' => $this->getProductOgImage($uid),
|
'ogimage' => $this->getProductOgImage($uid),
|
||||||
'videofile' => $this->getProductVideoFile($uid),
|
'videofile' => $this->getProductVideoFile($uid),
|
||||||
'relatedprodukt' => $this->getRelatedProducts($uid),
|
'relatedprodukt' => $this->getRelatedProducts($uid),
|
||||||
|
'solutions' => $this->getProductSolutions($uid),
|
||||||
|
// Detail-only like heroimage: resource cards are detail-page
|
||||||
|
// furniture a list never shows.
|
||||||
|
'resources' => $this->getProductResources($uid),
|
||||||
];
|
];
|
||||||
|
|
||||||
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
|
// schema.org JSON-LD (Product + optional VideoObject) as a ready-to-emit
|
||||||
@@ -732,6 +736,93 @@ class ProductShowJsonRenderer
|
|||||||
return $fallbackFiles;
|
return $fallbackFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solutions linked to this product (tx_vitec_product_solution_mm, editor
|
||||||
|
* order) in the shared solution card shape - the same fields the solution
|
||||||
|
* list emits, so the front end reuses its card component. detailUrl is
|
||||||
|
* null while a solution has no detail page (9.12 contract).
|
||||||
|
*/
|
||||||
|
protected function getProductSolutions(int $productUid): array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_vitec_domain_model_solution');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('s.*')
|
||||||
|
->from('tx_vitec_domain_model_solution', 's')
|
||||||
|
->join('s', 'tx_vitec_product_solution_mm', 'mm', 'mm.uid_foreign = s.uid')
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('mm.uid_local', $queryBuilder->createNamedParameter($productUid, \Doctrine\DBAL\ParameterType::INTEGER)),
|
||||||
|
$queryBuilder->expr()->eq('s.deleted', 0),
|
||||||
|
$queryBuilder->expr()->eq('s.hidden', 0)
|
||||||
|
)
|
||||||
|
->orderBy('mm.sorting', 'ASC')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
|
||||||
|
$serializer = GeneralUtility::makeInstance(\Evomedien\Vitec\Service\UsecaseSerializer::class);
|
||||||
|
$result = [];
|
||||||
|
foreach ($rows as $solutionRow) {
|
||||||
|
$solutionUid = (int)$solutionRow['uid'];
|
||||||
|
$result[] = [
|
||||||
|
'uid' => $solutionUid,
|
||||||
|
'title' => (string)($solutionRow['title'] ?? ''),
|
||||||
|
'slug' => (string)($solutionRow['slug'] ?? ''),
|
||||||
|
'subtitle' => (string)($solutionRow['subtitle'] ?? ''),
|
||||||
|
'teaser' => (string)($solutionRow['teaser'] ?? ''),
|
||||||
|
'description' => \Evomedien\Vitec\Service\RteResolver::html($solutionRow['description'] ?? ''),
|
||||||
|
'detailUrl' => \Evomedien\Vitec\Service\LinkResolver::pageUrl((int)($solutionRow['detail_page'] ?? 0)),
|
||||||
|
'image' => $serializer->image($solutionUid, 'image', 'tx_vitec_domain_model_solution', true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource cards from the product's Resources tab (child table
|
||||||
|
* tx_vitec_product_resource): documentation / support / datasheet
|
||||||
|
* entries, each an editable card (title, text, icon) around one link -
|
||||||
|
* page, external URL, file or download record, resolved per 9.12. A card
|
||||||
|
* whose link is empty or unresolvable is dropped (decision 2026-09-24).
|
||||||
|
* List envelope: `layout` switches to `carousel` above three cards.
|
||||||
|
*/
|
||||||
|
protected function getProductResources(int $productUid): array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
|
||||||
|
->getQueryBuilderForTable('tx_vitec_product_resource');
|
||||||
|
$rows = $queryBuilder
|
||||||
|
->select('*')
|
||||||
|
->from('tx_vitec_product_resource')
|
||||||
|
->where(
|
||||||
|
$queryBuilder->expr()->eq('product', $queryBuilder->createNamedParameter($productUid, \Doctrine\DBAL\ParameterType::INTEGER)),
|
||||||
|
$queryBuilder->expr()->eq('deleted', 0),
|
||||||
|
$queryBuilder->expr()->eq('hidden', 0)
|
||||||
|
)
|
||||||
|
->orderBy('sorting', 'ASC')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($rows as $resourceRow) {
|
||||||
|
$url = \Evomedien\Vitec\Service\LinkResolver::typolinkUrl((string)($resourceRow['link'] ?? ''));
|
||||||
|
if ($url === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'type' => (string)($resourceRow['restype'] ?? ''),
|
||||||
|
'title' => (string)($resourceRow['title'] ?? ''),
|
||||||
|
'text' => (string)($resourceRow['cardtext'] ?? ''),
|
||||||
|
'icon' => (string)($resourceRow['icon'] ?? ''),
|
||||||
|
'url' => $url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'layout' => count($items) > 3 ? 'carousel' : 'grid',
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected function getProductDownloads(int $productUid): array
|
protected function getProductDownloads(int $productUid): array
|
||||||
{
|
{
|
||||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
'types' => [
|
'types' => [
|
||||||
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, downloads, portfolio, showdatapath, textrelatedproducts, relatedprodukt,
|
'1' => ['showitem' => 'title, subtitle, slug, teaser, description, description2, applications, capabilities, highlights, contentelement, contentelementcta, downloads, portfolio, showdatapath, textrelatedproducts, relatedprodukt, solutions,
|
||||||
|
--div--;Resources, resources,
|
||||||
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
|
--div--;SEO, seotitle, urltitle, seometa, keywords, structureddata,
|
||||||
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
|
--div--;Images and Videos, heroimage, productimage, ogimage, video, videofile,
|
||||||
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
|
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:categories, categories,
|
||||||
@@ -431,6 +432,37 @@ return [
|
|||||||
'enableMultiSelectFilterTextfield' => true, // Optional: Adds a search box for filtering
|
'enableMultiSelectFilterTextfield' => true, // Optional: Adds a search box for filtering
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'solutions' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Related Solutions',
|
||||||
|
'description' => 'Solutions this product belongs to - emitted as solution cards',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'select',
|
||||||
|
'renderType' => 'selectMultipleSideBySide',
|
||||||
|
'foreign_table' => 'tx_vitec_domain_model_solution',
|
||||||
|
'MM' => 'tx_vitec_product_solution_mm',
|
||||||
|
'size' => 10,
|
||||||
|
'maxitems' => 9999,
|
||||||
|
'minitems' => 0,
|
||||||
|
'enableMultiSelectFilterTextfield' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'resources' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Resources',
|
||||||
|
'description' => 'Resource cards (documentation, support, datasheet) on the product detail page. A card without a link is not emitted.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'inline',
|
||||||
|
'foreign_table' => 'tx_vitec_product_resource',
|
||||||
|
'foreign_field' => 'product',
|
||||||
|
'foreign_sortby' => 'sorting',
|
||||||
|
'appearance' => [
|
||||||
|
'collapseAll' => true,
|
||||||
|
'levelLinksPosition' => 'top',
|
||||||
|
'useSortable' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
'productimage' => [
|
'productimage' => [
|
||||||
'exclude' => true,
|
'exclude' => true,
|
||||||
'label' => 'Product Image(s)',
|
'label' => 'Product Image(s)',
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource cards on the product's Resources tab (decision 2026-09-24):
|
||||||
|
* documentation / support / datasheet entries, several per type, each an
|
||||||
|
* editable card (title, text, icon) around one link. Child table of
|
||||||
|
* tx_vitec_domain_model_product via the `resources` inline field - edited
|
||||||
|
* only there (hideTable), no Extbase model on purpose: the headless
|
||||||
|
* renderers query the rows directly, the Extbase path never touches them.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'ctrl' => [
|
||||||
|
'title' => 'Product Resource',
|
||||||
|
'label' => 'title',
|
||||||
|
'label_alt' => 'restype',
|
||||||
|
'label_alt_force' => true,
|
||||||
|
'tstamp' => 'tstamp',
|
||||||
|
'crdate' => 'crdate',
|
||||||
|
'delete' => 'deleted',
|
||||||
|
'sortby' => 'sorting',
|
||||||
|
'hideTable' => true,
|
||||||
|
'enablecolumns' => [
|
||||||
|
'disabled' => 'hidden',
|
||||||
|
],
|
||||||
|
'iconfile' => 'EXT:vitec/Resources/Public/Icons/tx_vitec_domain_model_download.gif',
|
||||||
|
'security' => [
|
||||||
|
'ignorePageTypeRestriction' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'types' => [
|
||||||
|
'1' => ['showitem' => 'restype, title, cardtext, icon, link, hidden'],
|
||||||
|
],
|
||||||
|
'columns' => [
|
||||||
|
'hidden' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.visible',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'check',
|
||||||
|
'renderType' => 'checkboxToggle',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'label' => '',
|
||||||
|
'invertStateDisplay' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'product' => [
|
||||||
|
'config' => [
|
||||||
|
'type' => 'passthrough',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'restype' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Type',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'select',
|
||||||
|
'renderType' => 'selectSingle',
|
||||||
|
'items' => [
|
||||||
|
['label' => 'Product Documentation', 'value' => 'documentation'],
|
||||||
|
['label' => 'Technical Support', 'value' => 'support'],
|
||||||
|
['label' => 'Datasheet', 'value' => 'datasheet'],
|
||||||
|
],
|
||||||
|
'default' => 'documentation',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'title' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Card title',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'input',
|
||||||
|
'size' => 40,
|
||||||
|
'eval' => 'trim',
|
||||||
|
'default' => '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'cardtext' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Card text',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'cols' => 40,
|
||||||
|
'rows' => 3,
|
||||||
|
'eval' => 'trim',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'icon' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Icon',
|
||||||
|
'description' => 'Icon identifier as used by the front end. Free text; the picker offers the seed set.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'input',
|
||||||
|
'size' => 20,
|
||||||
|
'eval' => 'trim',
|
||||||
|
'default' => '',
|
||||||
|
'valuePicker' => [
|
||||||
|
'items' => [
|
||||||
|
['label' => 'Document', 'value' => 'document'],
|
||||||
|
['label' => 'Support', 'value' => 'support'],
|
||||||
|
['label' => 'Datasheet', 'value' => 'datasheet'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'link' => [
|
||||||
|
'exclude' => true,
|
||||||
|
'label' => 'Link',
|
||||||
|
'description' => 'Page, external URL, file or a Download record (link browser tab "Download"). A card without a link is not emitted to the front end.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'link',
|
||||||
|
'allowedTypes' => ['page', 'url', 'file', 'record'],
|
||||||
|
'size' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
| 1.15 | 2026‑09‑11 | **Corpus change (additive, index side).** Download documents now carry the extracted file text: Solr 10 has no ExtractingRequestHandler, so a dedicated Apache Tika container on the Solr VPS (Caddy route `/tika/*`, own basic-auth credential) extracts PDF/Office contents during indexing (`TikaDownloadContentIndexer` on `BeforeDocumentIsProcessedForIndexingEvent`, fail-soft per 9.5, cached in `var/tika-cache` keyed on path+size+mtime). Datasheet specifications are now searchable ("625i", "genlock"). No interface change. |
|
| 1.15 | 2026‑09‑11 | **Corpus change (additive, index side).** Download documents now carry the extracted file text: Solr 10 has no ExtractingRequestHandler, so a dedicated Apache Tika container on the Solr VPS (Caddy route `/tika/*`, own basic-auth credential) extracts PDF/Office contents during indexing (`TikaDownloadContentIndexer` on `BeforeDocumentIsProcessedForIndexingEvent`, fail-soft per 9.5, cached in `var/tika-cache` keyed on path+size+mtime). Datasheet specifications are now searchable ("625i", "genlock"). No interface change. |
|
||||||
| 1.16 | 2026‑09‑11 | **New plugin** `vitec_productfinder` (`ProductFinderJsonRenderer`, payload key `productfinder`): the product category hierarchy on its own, as the data source for the filter selects above the product lists (7.15.3, 8). No change to `vitec_productlist` — filtering happens client side over the products already delivered, keyed on the category uids this payload carries. |
|
| 1.16 | 2026‑09‑11 | **New plugin** `vitec_productfinder` (`ProductFinderJsonRenderer`, payload key `productfinder`): the product category hierarchy on its own, as the data source for the filter selects above the product lists (7.15.3, 8). No change to `vitec_productlist` — filtering happens client side over the products already delivered, keyed on the category uids this payload carries. |
|
||||||
| 1.17 | 2026‑09‑11 | **Interface change (additive).** The product list payload gained `successStories` (7.15.2.1): stories an editor attaches to a `sys_category` (tab *Options*, with its own layout), emitted in the list envelope beside `products`. Each card carries `forCategories` — the product categories it came from — so the front end can hide it with the same `?cat=`/`?subcat=` filter it applies to the products. Story links reuse the 7.5 rule, with the detail page taken from the element or the new site setting `vitec.storyDetailPid`. |
|
| 1.17 | 2026‑09‑11 | **Interface change (additive).** The product list payload gained `successStories` (7.15.2.1): stories an editor attaches to a `sys_category` (tab *Options*, with its own layout), emitted in the list envelope beside `products`. Each card carries `forCategories` — the product categories it came from — so the front end can hide it with the same `?cat=`/`?subcat=` filter it applies to the products. Story links reuse the 7.5 rule, with the detail page taken from the element or the new site setting `vitec.storyDetailPid`. |
|
||||||
|
| 1.18 | 2026‑09‑24 | **Interface change (additive).** Products gained `solutions` — an MM relation to solutions, emitted as solution cards in both product payloads — and a **Resources tab**: child table `tx_vitec_product_resource` with documentation / support / datasheet cards (editable title, text and icon around one link, resolved per 9.12; cards without a resolvable link are not emitted), delivered detail‑only as the `resources` list envelope whose `layout` switches to `carousel` above three cards (7.15). Editorial: the footer had been stuck at v1.15 since 1.16/1.17 (2026‑09‑11). |
|
||||||
|
|
||||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||||
@@ -897,12 +898,14 @@ Produced by `ProductShowJsonRenderer::serializeProduct()`. Members:
|
|||||||
| `downloads` | array | download records incl. the resolved `file` |
|
| `downloads` | array | download records incl. the resolved `file` |
|
||||||
| `ogimage`, `videofile` | object \| null | FAL singletons |
|
| `ogimage`, `videofile` | object \| null | FAL singletons |
|
||||||
| `relatedprodukt` | array | card shape `{uid, title, slug, subtitle, teaser, description, link, images}`, backend order |
|
| `relatedprodukt` | array | card shape `{uid, title, slug, subtitle, teaser, description, link, images}`, backend order |
|
||||||
|
| `solutions` | array | solution card shape `{uid, title, slug, subtitle, teaser, description, detailUrl, image}` via `tx_vitec_product_solution_mm`, backend order |
|
||||||
|
| `resources` | object | list envelope `{layout, items[]}` of hand‑written resource cards: `type` (`documentation\|support\|datasheet`), `title`, `text`, `icon`, resolved `url` (9.12); a card without a resolvable link is dropped; `layout` is `carousel` above three cards — **detail payload only** |
|
||||||
| `jsonLd` | string | ready‑to‑emit schema.org `@graph` (6.6), detail only |
|
| `jsonLd` | string | ready‑to‑emit schema.org `@graph` (6.6), detail only |
|
||||||
|
|
||||||
#### 7.15.2 List payload (`vitec_productlist`, key `products`)
|
#### 7.15.2 List payload (`vitec_productlist`, key `products`)
|
||||||
|
|
||||||
List envelope per 7.3 (`layout`, `showToolbar`, `products[]`) — since v1.9. Each item
|
List envelope per 7.3 (`layout`, `showToolbar`, `products[]`) — since v1.9. Each item
|
||||||
carries the member set of 7.15.1 **except** `heroimage` and `jsonLd`. `heroimage` is
|
carries the member set of 7.15.1 **except** `heroimage`, `resources` and `jsonLd`. `heroimage` is
|
||||||
deliberately detail‑only: one further FAL query per product would buy data a list
|
deliberately detail‑only: one further FAL query per product would buy data a list
|
||||||
rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note).
|
rarely shows. The `layout` vocabulary remains `0`–`3` (see the v1.9 note).
|
||||||
|
|
||||||
@@ -1469,4 +1472,4 @@ remediation.
|
|||||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||||
|
|
||||||
*End of document EVO‑VITEC‑HL‑001 v1.15.*
|
*End of document EVO‑VITEC‑HL‑001 v1.18.*
|
||||||
@@ -58,6 +58,8 @@ CREATE TABLE tx_vitec_domain_model_product (
|
|||||||
capabilities text,
|
capabilities text,
|
||||||
portfolio varchar(1024) DEFAULT '' NOT NULL,
|
portfolio varchar(1024) DEFAULT '' NOT NULL,
|
||||||
textrelatedproducts text,
|
textrelatedproducts text,
|
||||||
|
solutions int(11) DEFAULT '0' NOT NULL,
|
||||||
|
resources int(11) DEFAULT '0' NOT NULL,
|
||||||
KEY parent (pid)
|
KEY parent (pid)
|
||||||
);
|
);
|
||||||
CREATE TABLE tx_vitec_domain_model_download (
|
CREATE TABLE tx_vitec_domain_model_download (
|
||||||
@@ -97,6 +99,32 @@ CREATE TABLE tx_vitec_product_related_mm (
|
|||||||
PRIMARY KEY (uid_local, uid_foreign)
|
PRIMARY KEY (uid_local, uid_foreign)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tx_vitec_product_solution_mm (
|
||||||
|
uid_local INT(11) NOT NULL,
|
||||||
|
uid_foreign INT(11) NOT NULL,
|
||||||
|
sorting INT(11) DEFAULT '0' NOT NULL,
|
||||||
|
PRIMARY KEY (uid_local, uid_foreign)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tx_vitec_product_resource (
|
||||||
|
uid int(11) NOT NULL auto_increment,
|
||||||
|
pid int(11) DEFAULT '0' NOT NULL,
|
||||||
|
tstamp int(11) DEFAULT '0' NOT NULL,
|
||||||
|
crdate int(11) DEFAULT '0' NOT NULL,
|
||||||
|
deleted tinyint(4) DEFAULT '0' NOT NULL,
|
||||||
|
hidden tinyint(4) DEFAULT '0' NOT NULL,
|
||||||
|
sorting int(11) DEFAULT '0' NOT NULL,
|
||||||
|
product int(11) unsigned DEFAULT '0' NOT NULL,
|
||||||
|
restype varchar(32) DEFAULT 'documentation' NOT NULL,
|
||||||
|
title varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
cardtext text,
|
||||||
|
icon varchar(64) DEFAULT '' NOT NULL,
|
||||||
|
link varchar(1024) DEFAULT '' NOT NULL,
|
||||||
|
PRIMARY KEY (uid),
|
||||||
|
KEY parent (pid),
|
||||||
|
KEY product (product)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE tx_vitec_domain_model_usecase (
|
CREATE TABLE tx_vitec_domain_model_usecase (
|
||||||
title varchar(255) DEFAULT '' NOT NULL,
|
title varchar(255) DEFAULT '' NOT NULL,
|
||||||
slug varchar(255) DEFAULT '' NOT NULL,
|
slug varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
|||||||
Reference in new issue
Block a user