Fill the /products portfolio page from the agency workbook

- new vitec:build-product-portfolio (dry-run by default): hero, nine
  category sections (intro paragraphs with the exact sitemap H2s and
  Learn-more buttons onto the new category pages), 38 product cards
  with record-first link resolution (30 resolved; the 8 open record
  gaps stay reported), a Case Studies & Events banner (midnight,
  success stories / events), ONE vitec_faq accordion whose four
  questions are created as vitec_faq_items collection children
  (field names probed against the schema at runtime), and the orange
  prefooter banner (contact / solutions); full page properties and
  page layout 0 -> 1 "Index Page"
- diagnosis first (new read-only migrations/check_products_page.php
  and check_products_page2.php): the dry run reported zero legacy
  elements although the payload rendered ~40 - every own element of
  page 447 was already soft-deleted, the visible rendering came from
  the "Collection Product Infos" compose page. Building in place was
  therefore safe; unconsumed visible legacy would have been hidden,
  not deleted
- side finding, left open on purpose: six old-taxonomy category stubs
  (#451-454, #456, #458) still sit visible under /products with the
  family pages beneath them - hide/move/redirect is an editorial
  decision queued for Olli
- applied and verified live (hero, 9 category buttons, 30 card links,
  4 FAQ items, both banner CTA pairs, meta)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-27 14:12:39 +01:00
1 parent e2d4367d7a
commit c6147d020e
3 files changed
+497

No files matched your search

@@ -0,0 +1,408 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\Command;
use Evomedien\Vitec\Import\ProductFamilyAliases;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Fills the /products PORTFOLIO page from the agency workbook
* "Product_Portfolio_Page_Content_US_English.xlsx" (staged in
* migrations/product_categories_xlsx_in/portfolio/). Companion of
* vitec:import-product-category-pages - same reader shape, same card
* resolution, one fixed page.
*
* Structure per workbook: hero, nine category sections (H2 + intro +
* "Learn more" to the category page, then the product cards), a Case
* Studies & Events banner (two CTAs), one vitec_faq accordion (four
* items as collection children) and the pre-footer banner (Talk to an
* Expert -> /contact, Explore Solutions -> /solutions).
*
* The page is hand-built today (textpic/productlist/containers): every
* pre-existing visible top-level element that this run does not reuse is
* HIDDEN, not deleted - re-runs reuse the elements this command created
* (slot pattern) and leave the hidden legacy untouched.
*
* vendor/bin/typo3 vitec:build-product-portfolio
* vendor/bin/typo3 vitec:build-product-portfolio --apply
*/
#[AsCommand(
name: 'vitec:build-product-portfolio',
description: 'Fill the /products portfolio page from the agency workbook (dry-run by default)'
)]
final class BuildProductPortfolioCommand extends Command
{
private const COLPOS_HERO = 1;
private const COLPOS_MAIN = 0;
private const COLPOS_PREFOOTER = 3;
private const DEFAULT_FILE = 'migrations/product_categories_xlsx_in/portfolio/Product_Portfolio_Page_Content_US_English.xlsx';
/** @var array<string,array<string|int,array<string,mixed>>> */
private array $datamap = [];
/** @var array<int,string[]> */
private array $sequence = [];
/** @var array<string,int[]> */
private array $slotCursor = [];
/** @var array<int,bool> pre-existing uids this run reuses */
private array $consumed = [];
private int $newCounter = 0;
protected function configure(): void
{
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Workbook path', self::DEFAULT_FILE);
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$apply = (bool)$input->getOption('apply');
$file = (string)$input->getOption('file');
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
$pages = $pool->getConnectionForTable('pages');
$content = $pool->getConnectionForTable('tt_content');
$root = $pages->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/products'");
if (!$root || !is_file($file)) {
$output->writeln('<error>' . (!$root ? '/products not found.' : $file . ' missing.') . '</error>');
return Command::FAILURE;
}
$pageUid = (int)$root['uid'];
// the page sits on layout 0 (Default, main only) - the portfolio
// needs hero/main/preFooter: 1 "Index Page" (diagnosis 2026-10-27:
// the page's own content was all deleted, the rendered payload came
// from the Collection Product Infos compose mechanics)
$this->datamap['pages'][$pageUid]['layout'] = 1;
[$meta, $components] = $this->readSheet($file);
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /products #' . $pageUid . ' · ' . count($components) . ' component(s)');
$categoryPages = [];
foreach ($pages->fetchAllAssociative(
'SELECT uid, slug FROM pages WHERE deleted = 0 AND pid = ? AND doktype = 1 AND sys_language_uid IN (0,-1)', [$pageUid]
) as $row) {
$categoryPages[$this->norm((string)basename((string)$row['slug']))] = (int)$row['uid'];
}
// --- hero ---------------------------------------------------------------------
$hero = $this->firstComponent($components, 'hero');
if ($hero !== null) {
$id = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_herosection');
$this->setFields($id, [
'header' => (string)$hero['heading'], 'header_layout' => 1,
'bodytext' => $this->p((string)$hero['body']),
'vitec_cta_label' => (string)$hero['cta'],
]);
$output->writeln(' hero: "' . $hero['heading'] . '"');
}
// --- main: categories, cards, case studies, faq --------------------------------
$faqItems = [];
foreach ($components as $component) {
$kind = mb_strtolower($component['component']);
if (str_starts_with($kind, 'hero')) {
continue;
}
if (str_starts_with($kind, 'product category')) {
$fields = [
'header' => (string)$component['heading'],
'bodytext' => $this->p((string)$component['body']),
];
$categoryUid = $categoryPages[$this->norm((string)$component['heading'])] ?? 0;
if ($categoryUid > 0) {
$fields['vitec_button_text'] = (string)$component['cta'] !== '' ? (string)$component['cta'] : 'Learn more';
$fields['vitec_button_link'] = 't3://page?uid=' . $categoryUid;
$output->writeln(' category "' . $component['heading'] . '" -> page #' . $categoryUid);
} else {
$output->writeln(' <comment>category "' . $component['heading'] . '": NO category page matched - button left empty</comment>');
}
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph');
$this->setFields($id, $fields);
continue;
}
if (str_starts_with($kind, 'product card')) {
$cardTitle = (string)$component['heading'];
$fields = [
'vitec_layout_variant' => 'text_only',
'header' => $cardTitle,
'bodytext' => $this->p((string)$component['cta']),
'vitec_primary_cta_label' => 'Learn more',
];
$product = $this->resolveProduct($cardTitle);
if ($product !== null) {
$fields['vitec_primary_cta'] = '/product/' . $product['slug'];
} else {
$output->writeln(' <comment>product card "' . $cardTitle . '": NO record resolved - link left empty</comment>');
}
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card');
$this->setFields($id, $fields);
continue;
}
if (str_starts_with($kind, 'case studies')) {
$id = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_ctabanner');
$this->setFields($id, [
'header' => (string)$component['heading'],
'bodytext' => $this->p((string)$component['body']),
'vitec_primary_cta_label' => 'View Success Stories',
'vitec_primary_cta' => $this->pageLink('/success-stories'),
'vitec_secondary_cta_label' => 'View Events',
'vitec_secondary_cta' => $this->pageLink('/insights/events'),
'vitec_layout_variant' => 'centered',
'vitec_background_variant' => 'midnight',
]);
$output->writeln(' case studies & events banner -> /success-stories + /insights/events');
continue;
}
if (str_starts_with($kind, 'faq')) {
$faqItems[] = ['question' => (string)$component['heading'], 'answer' => (string)$component['body']];
continue;
}
if (str_starts_with($kind, 'pre-footer')) {
$id = $this->slotNext($pageUid, self::COLPOS_PREFOOTER, 'vitec_ctabanner');
$this->setFields($id, [
'header' => (string)$component['heading'],
'bodytext' => $this->p((string)$component['body']),
'vitec_primary_cta_label' => 'Talk to an Expert',
'vitec_primary_cta' => $this->pageLink('/contact'),
'vitec_secondary_cta_label' => 'Explore Solutions',
'vitec_secondary_cta' => $this->pageLink('/solutions'),
'vitec_layout_variant' => 'centered',
'vitec_background_variant' => 'orange',
]);
$output->writeln(' prefooter -> /contact + /solutions');
continue;
}
}
// faq accordion with its collection children
if ($faqItems !== []) {
$faqId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_faq');
$questionField = $this->faqColumn($pool, 'question');
$answerField = $this->faqColumn($pool, 'answer');
$childIds = [];
$existingChildren = [];
if (!str_starts_with($faqId, 'NEW')) {
$existingChildren = array_map('intval', $pool->getConnectionForTable('vitec_faq_items')->fetchFirstColumn(
'SELECT uid FROM vitec_faq_items WHERE deleted = 0 AND foreign_table_parent_uid = ? ORDER BY sorting', [(int)$faqId]
));
}
foreach ($faqItems as $item) {
$childId = array_shift($existingChildren);
$childKey = $childId !== null ? (string)$childId : $this->newId();
$this->datamap['vitec_faq_items'][$childKey] = array_filter([
'pid' => $childId !== null ? null : $pageUid,
$questionField => $item['question'],
$answerField => $this->p($item['answer']),
], static fn($v): bool => $v !== null);
$childIds[] = $childKey;
}
$this->setFields($faqId, [
'header' => 'Frequently asked questions',
'vitec_faq_items' => implode(',', $childIds),
'vitec_first_item_open' => 1,
]);
$output->writeln(' faq accordion: ' . count($faqItems) . ' item(s) (fields ' . $questionField . '/' . $answerField . ')');
}
// --- hide the unconsumed legacy elements ----------------------------------------
$legacy = $content->fetchAllAssociative(
'SELECT uid, CType, header FROM tt_content WHERE deleted = 0 AND hidden = 0 AND pid = ? AND tx_container_parent = 0 AND colPos >= 0 ORDER BY sorting',
[$pageUid]
);
$hidden = 0;
foreach ($legacy as $row) {
if (isset($this->consumed[(int)$row['uid']])) {
continue;
}
$this->datamap['tt_content'][(string)(int)$row['uid']]['hidden'] = 1;
$hidden++;
}
$output->writeln(' legacy: ' . $hidden . ' visible element(s) HIDDEN (kept for reference), ' . count($this->consumed) . ' reused');
// --- page properties --------------------------------------------------------------
$heroBody = trim((string)($hero['body'] ?? ''));
$seoTitle = 'Products | VITEC';
foreach ([
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
'description' => $heroBody, 'og_description' => $heroBody, 'twitter_description' => $heroBody,
'abstract' => $heroBody, 'keywords' => (string)($meta['keyword'] ?? ''),
'canonical_link' => 't3://page?uid=' . $pageUid,
] as $field => $value) {
$this->datamap['pages'][$pageUid][$field] = $value;
}
$output->writeln(' page meta: seo/og/twitter + abstract + keywords ("' . ($meta['keyword'] ?? '') . '")');
if (!$apply) {
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
return Command::SUCCESS;
}
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($this->datamap, []);
$dataHandler->process_datamap();
if ($dataHandler->errorLog !== []) {
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
return Command::FAILURE;
}
$this->applySequence($content, $dataHandler->substNEWwithIDs);
$output->writeln('<info>Done. Flush the frontend cache.</info>');
return Command::SUCCESS;
}
// ---- helpers (pattern of vitec:import-product-category-pages) ---------------------
private function faqColumn(ConnectionPool $pool, string $base): string
{
$conn = $pool->getConnectionForTable('vitec_faq_items');
$columns = array_column($conn->fetchAllAssociative('SHOW COLUMNS FROM vitec_faq_items'), 'Field');
return in_array($base, $columns, true) ? $base : 'vitec_' . $base;
}
/** @return array<string,mixed>|null */
private function resolveProduct(string $cardTitle): ?array
{
$conn = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tx_vitec_domain_model_product');
$rows = $conn->fetchAllAssociative('SELECT uid, title, slug FROM tx_vitec_domain_model_product WHERE deleted = 0');
$full = $this->norm($cardTitle);
$stripped = $this->norm((string)preg_replace('/\s*\(.*$/', '', $cardTitle));
foreach ([$full, $stripped] as $needle) {
foreach ($rows as $row) {
if ($this->norm((string)$row['title']) === $needle) {
return $row;
}
}
}
$alias = ProductFamilyAliases::resolve($stripped);
if ($alias !== null) {
return $alias;
}
$matches = array_values(array_filter($rows, fn(array $row): bool => str_starts_with($this->norm((string)$row['slug']), $stripped)));
return count($matches) === 1 ? $matches[0] : null;
}
private function pageLink(string $slug): string
{
$row = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages')
->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ?', [$slug]);
return $row ? 't3://page?uid=' . (int)$row['uid'] : '';
}
/** @return array{0:array<string,string>,1:array<int,array{component:string,heading:string,body:string,cta:string}>} */
private function readSheet(string $file): array
{
$spreadsheet = IOFactory::load($file);
$sheet = $spreadsheet->getSheet(0);
$rows = $sheet->toArray(null, false, false, false);
$spreadsheet->disconnectWorksheets();
$meta = [];
$components = [];
$inTable = false;
foreach ($rows as $row) {
$a = trim((string)($row[0] ?? ''));
$b = trim((string)($row[1] ?? ''));
if (!$inTable) {
if (mb_strtolower($a) === 'primary keyword') {
$meta['keyword'] = $b;
}
if (mb_strtolower($a) === 'order') {
$inTable = true;
}
continue;
}
if ($b === '') {
continue;
}
$components[] = [
'component' => $b,
'heading' => trim((string)($row[2] ?? '')),
'body' => trim((string)($row[3] ?? '')),
'cta' => trim((string)($row[4] ?? '')),
];
}
return [$meta, $components];
}
/** @param array<int,array<string,string>> $components */
private function firstComponent(array $components, string $needle): ?array
{
foreach ($components as $component) {
if (str_contains(mb_strtolower($component['component']), $needle)) {
return $component;
}
}
return null;
}
private function slotNext(int $pageUid, int $colPos, string $ctype): string
{
$key = $colPos . '/' . $ctype;
if (!isset($this->slotCursor[$key])) {
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
$this->slotCursor[$key] = array_map('intval', $connection->fetchFirstColumn(
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? AND tx_container_parent = 0 ORDER BY sorting',
[$pageUid, $colPos, $ctype]
));
}
$uid = array_shift($this->slotCursor[$key]);
if ($uid !== null) {
$this->consumed[$uid] = true;
$this->sequence[$colPos][] = (string)$uid;
$this->datamap['tt_content'][(string)$uid]['hidden'] = 0;
return (string)$uid;
}
$id = $this->newId();
$this->datamap['tt_content'][$id] = ['pid' => $pageUid, 'colPos' => $colPos, 'CType' => $ctype];
$this->sequence[$colPos][] = $id;
return $id;
}
/** @param array<string,mixed> $fields */
private function setFields(string $id, array $fields): void
{
foreach ($fields as $field => $value) {
$this->datamap['tt_content'][$id][$field] = $value;
}
}
/** @param array<string,int> $substNEWwithIDs */
private function applySequence($connection, array $substNEWwithIDs): void
{
foreach ($this->sequence as $ids) {
$sorting = 0;
foreach ($ids as $id) {
$uid = str_starts_with($id, 'NEW') ? (int)($substNEWwithIDs[$id] ?? 0) : (int)$id;
if ($uid > 0) {
$sorting += 256;
$connection->update('tt_content', ['sorting' => $sorting], ['uid' => $uid]);
}
}
}
}
private function p(string $text): string
{
$text = trim($text);
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
}
private function norm(string $value): string
{
$value = mb_strtolower($value);
$value = str_replace(['visualization', 'center'], ['visualisation', 'centre'], $value);
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], $value));
}
private function newId(): string
{
return 'NEWPF' . (++$this->newCounter);
}
}