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:
orascheandClaude Fable 5 committed 2026-09-24 14:15:43 +02:00
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',
'description2' => 'c:Product Overview — H2#1:body',
'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',
'keywords' => 'm:primaryKeyword',
];
@@ -372,7 +379,12 @@ final class ProductTextImportController
}
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 = [];
@@ -722,6 +722,7 @@ class Product extends \TYPO3\CMS\Extbase\DomainObject\AbstractEntity
$this->productimage = new ObjectStorage();
$this->heroimage = 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;
}
/**
* 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) {
$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) {
$groups['Page SEO Check'][] = $this->entry('s:' . $header, 'SEO · ' . $header, (string)$value);
@@ -262,6 +266,9 @@ final class ProductXlsxReader
if ($selector === 'x:seotitle') {
return $this->composeSeoTitle($parsed);
}
if ($selector === 'x:seometa') {
return $this->composeSeoMeta($parsed);
}
if (str_starts_with($selector, 's:')) {
$value = $parsed['seo'][substr($selector, 2)] ?? null;
return is_string($value) && $value !== '' ? $value : null;
@@ -310,6 +317,37 @@ final class ProductXlsxReader
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
* with an inner span, centred p - is copied verbatim from what the
@@ -488,6 +488,7 @@ class ProductListJsonRenderer
'ogimage' => $this->getProductOgImage($uid),
'videofile' => $this->getProductVideoFile($uid),
'relatedprodukt' => $this->getRelatedProducts($uid),
'solutions' => $this->getProductSolutions($uid),
];
}
@@ -911,6 +912,48 @@ class ProductListJsonRenderer
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
{
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
@@ -252,6 +252,10 @@ class ProductShowJsonRenderer
'ogimage' => $this->getProductOgImage($uid),
'videofile' => $this->getProductVideoFile($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
@@ -732,6 +736,93 @@ class ProductShowJsonRenderer
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
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)