From c6147d020eb1ca24da1acd14d84b26b13a19a93e Mon Sep 17 00:00:00 2001 From: Oliver Rasche Date: Tue, 27 Oct 2026 14:12:39 +0100 Subject: [PATCH] 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 --- migrations/check_products_page.php | 46 ++ migrations/check_products_page2.php | 43 ++ .../Command/BuildProductPortfolioCommand.php | 408 ++++++++++++++++++ 3 files changed, 497 insertions(+) create mode 100644 migrations/check_products_page.php create mode 100644 migrations/check_products_page2.php create mode 100644 packages/vitec/Classes/Command/BuildProductPortfolioCommand.php diff --git a/migrations/check_products_page.php b/migrations/check_products_page.php new file mode 100644 index 0000000..8372173 --- /dev/null +++ b/migrations/check_products_page.php @@ -0,0 +1,46 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +echo "=== page 447 ===\n"; +$p = $pdo->query('SELECT uid, title, slug, doktype, layout, content_from_pid, l10n_parent, sys_language_uid FROM pages WHERE uid = 447')->fetch(); +print_r($p); + +echo "\n=== tt_content on pid 447 (any state) ===\n"; +foreach ($pdo->query('SELECT colPos, CType, COUNT(*) c, SUM(hidden) hid, SUM(deleted) del FROM tt_content WHERE pid = 447 GROUP BY colPos, CType ORDER BY colPos') as $r) { + printf(" colPos %-4s %-24s n=%-3d hidden=%d deleted=%d\n", $r['colPos'], $r['CType'], $r['c'], $r['hid'], $r['del']); +} + +echo "\n=== sample payload elements ===\n"; +foreach ([10331, 10368, 10306] as $uid) { + $r = $pdo->query("SELECT uid, pid, CType, colPos, tx_container_parent, hidden, deleted, sys_language_uid FROM tt_content WHERE uid = $uid")->fetch(); + print_r($r); +} diff --git a/migrations/check_products_page2.php b/migrations/check_products_page2.php new file mode 100644 index 0000000..6621cb5 --- /dev/null +++ b/migrations/check_products_page2.php @@ -0,0 +1,43 @@ + PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +echo "=== pages with slug like /products% (incl. deleted/hidden) ===\n"; +foreach ($pdo->query("SELECT uid, pid, title, slug, doktype, layout, hidden, deleted, sorting FROM pages WHERE slug LIKE '/products%' ORDER BY uid") as $r) { + printf(" #%-4d pid=%-4d dok=%d lay=%-2d hid=%d del=%d sort=%-5d %-42s %s\n", + $r['uid'], $r['pid'], $r['doktype'], $r['layout'], $r['hidden'], $r['deleted'], $r['sorting'], $r['slug'], $r['title']); +} + +echo "\n=== page 448 ===\n"; +print_r($pdo->query('SELECT uid, pid, title, slug, doktype, layout, hidden, deleted, content_from_pid FROM pages WHERE uid = 448')->fetch()); + +echo "\n=== visible tt_content on pid 448 (top level) ===\n"; +foreach ($pdo->query('SELECT colPos, CType, COUNT(*) c FROM tt_content WHERE pid = 448 AND deleted = 0 AND hidden = 0 AND tx_container_parent = 0 GROUP BY colPos, CType ORDER BY colPos') as $r) { + printf(" colPos %-4s %-24s n=%d\n", $r['colPos'], $r['CType'], $r['c']); +} diff --git a/packages/vitec/Classes/Command/BuildProductPortfolioCommand.php b/packages/vitec/Classes/Command/BuildProductPortfolioCommand.php new file mode 100644 index 0000000..f49c4a4 --- /dev/null +++ b/packages/vitec/Classes/Command/BuildProductPortfolioCommand.php @@ -0,0 +1,408 @@ + /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>> */ + private array $datamap = []; + /** @var array */ + private array $sequence = []; + /** @var array */ + private array $slotCursor = []; + /** @var array 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('' . (!$root ? '/products not found.' : $file . ' missing.') . ''); + 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(' category "' . $component['heading'] . '": NO category page matched - button left empty'); + } + $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(' product card "' . $cardTitle . '": NO record resolved - link left empty'); + } + $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('DataHandler: ' . implode(' | ', $dataHandler->errorLog) . ''); + return Command::FAILURE; + } + $this->applySequence($content, $dataHandler->substNEWwithIDs); + $output->writeln('Done. Flush the frontend cache.'); + 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|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,1:array} */ + 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> $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 $fields */ + private function setFields(string $id, array $fields): void + { + foreach ($fields as $field => $value) { + $this->datamap['tt_content'][$id][$field] = $value; + } + } + + /** @param array $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 === '' ? '' : '

' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '

'; + } + + 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); + } +}