, 11 pages): * * B1 vitec_marketintro: numbered placeholder image placeholders/pages/-02.png * when the element has no image yet; learnmore_target "/contact" when empty. * B2 vitec_customerlogos ("Trusted by"): the 2026-10-23 page import left a * HIDDEN shell after the sub-market cards - it is unhidden and gets the * settings of the home page element (#758: layout, customer selection). * A page without a shell gets a copy of #758 instead. * B3 vitec_usecaselist ("Case studies" shell, hidden as well): unhidden as * layout testimonials. Stories = what an editor already picked on the * shell, else the union of the market's sub-market testimonial lists, * else the most common sub-market list. No shell: a new element after * the logos, i.e. before the CTA banner. * B5 the lead paragraph in the hero slot (vitec_introparagraph, colPos 1) * is HIDDEN, not deleted (decision aehm 12.11.: copy raw material for Joe). * * Sub-market pages (children of the 11): * B4 vitec_marketinsights: cta_target /success-stories -> /insights/news, * label "Explore success stories" -> "Explore our insights" (placeholder). * * Idempotent: elements that already exist are left alone, filled fields are * never overwritten. Everything goes through DataHandler (history, refindex, * SSR auto-purge), still run cache:flush + vitec:ssr-purge/-warm afterwards - * the command prints the exact lines. * * vendor/bin/typo3 vitec:markets-figma-elements # dry run (default) * vendor/bin/typo3 vitec:markets-figma-elements --apply # writes */ #[AsCommand( name: 'vitec:markets-figma-elements', description: 'Create the Figma structure elements on the 11 main market pages (0124-FE, dry-run by default)' )] final class MarketsFigmaElementsCommand extends Command { private const MARKET_SLUGS = [ '/markets/broadcast-and-telecoms', '/markets/corporation', '/markets/critical-national-infrastructure', '/markets/defense-and-security', '/markets/education', '/markets/government', '/markets/hospitality', '/markets/industrial', '/markets/public-safety', '/markets/sports-and-entertainment', '/markets/transport', ]; private const LOGO_TEMPLATE_UID = 758; private const STORY_SINGLE_PID = 26; private const CONTACT_TARGET = '/contact'; private const LEARNMORE_LABEL = 'Talk to our team'; private const OLD_INSIGHTS_TARGET = '/success-stories'; private const NEW_INSIGHTS_TARGET = '/insights/news'; private const OLD_INSIGHTS_LABEL = 'Explore success stories'; private const NEW_INSIGHTS_LABEL = 'Explore our insights'; private const TESTIMONIALS_HEADER = 'Testimonials from the Field'; private const GENERIC_PLACEHOLDER = 'placeholders/placeholder-grey.png'; private Connection $db; private ResourceStorage $storage; private OutputInterface $output; private bool $apply = false; /** @var array */ private array $counts = [ 'images' => 0, 'targets' => 0, 'labels' => 0, 'logos' => 0, 'testimonials' => 0, 'hiddenLeads' => 0, 'insightsTargets' => 0, 'insightsLabels' => 0, 'warnings' => 0, ]; protected function configure(): void { $this->addOption('apply', null, InputOption::VALUE_NONE, 'Write the changes (default: dry run)'); } protected function execute(InputInterface $input, OutputInterface $output): int { Bootstrap::initializeBackendAuthentication(); $this->output = $output; $this->apply = (bool)$input->getOption('apply'); $this->db = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content'); $this->storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage(); $output->writeln($this->apply ? 'APPLY - writing via DataHandler' : 'DRY RUN - nothing is written (--apply to write)'); $output->writeln(''); // --- template element for the logo band ------------------------------------ $template = $this->db->fetchAssociative( 'SELECT uid, pid, CType, header, hidden, deleted FROM tt_content WHERE uid = ?', [self::LOGO_TEMPLATE_UID] ); if (!$template || $template['CType'] !== 'vitec_customerlogos' || (int)$template['deleted'] === 1) { $output->writeln(sprintf('Logo template #%d is not a live vitec_customerlogos element.', self::LOGO_TEMPLATE_UID)); return Command::FAILURE; } $templateSettings = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [self::LOGO_TEMPLATE_UID])); $output->writeln(sprintf( 'Logo template: #%d "%s" (page %d) - layout %s, customers %s, onlySelected %s', $template['uid'], $template['header'], $template['pid'], $templateSettings['layout'] ?? '-', ($templateSettings['customers'] ?? '') !== '' ? $templateSettings['customers'] : '(all)', $templateSettings['onlySelected'] ?? '0' )); // --- the 11 market pages ------------------------------------------------------- $markets = []; foreach (self::MARKET_SLUGS as $slug) { $page = $this->db->fetchAssociative( 'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND sys_language_uid = 0 AND slug = ?', [$slug] ); if (!$page) { $this->warn("page $slug NOT FOUND - skipped"); continue; } $markets[(int)$page['uid']] = $page; } if ($markets === []) { return Command::FAILURE; } $marketUids = array_keys($markets); $in = implode(',', array_map('intval', $marketUids)); // --- sub-market testimonial lists (story selection for B3) ---------------------- $flexFormService = GeneralUtility::makeInstance(FlexFormService::class); $storiesByMarket = []; $listFrequency = []; $rows = $this->db->fetchAllAssociative( "SELECT c.uid, c.pid, p.pid AS market, c.pi_flexform FROM tt_content c JOIN pages p ON p.uid = c.pid WHERE c.CType = 'vitec_usecaselist' AND c.deleted = 0 AND c.hidden = 0 AND c.sys_language_uid = 0 AND p.deleted = 0 AND p.pid IN ($in) ORDER BY p.sorting, c.sorting" ); foreach ($rows as $row) { $settings = $this->flexSettings((string)$row['pi_flexform']); $uids = array_values(array_filter(array_map('intval', explode(',', (string)($settings['usecases'] ?? ''))))); if ($output->isVerbose()) { $output->writeln(sprintf(' list #%d (page %d, market %d): layout=%s usecases=%d', $row['uid'], $row['pid'], $row['market'], $settings['layout'] ?? '-', count($uids))); } if (($settings['layout'] ?? '') !== 'testimonials' || $uids === []) { continue; } $market = (int)$row['market']; foreach ($uids as $uid) { if (!in_array($uid, $storiesByMarket[$market] ?? [], true)) { $storiesByMarket[$market][] = $uid; } } $key = implode(',', $uids); $listFrequency[$key] = ($listFrequency[$key] ?? 0) + 1; } arsort($listFrequency); $defaultStories = $listFrequency === [] ? [] : array_map('intval', explode(',', (string)array_key_first($listFrequency))); $output->writeln(sprintf( 'Sub-market testimonial lists: %d element(s) on %d market(s); default list (most common, %dx): %s', count($rows), count($storiesByMarket), $listFrequency === [] ? 0 : reset($listFrequency), implode(',', $defaultStories) )); $output->writeln(''); $purgePaths = []; // ===================================================================== // main market pages // ===================================================================== foreach ($markets as $pid => $page) { $output->writeln(sprintf('== %s (pid %d) "%s"', $page['slug'], $pid, $page['title'])); $purgePaths[] = (string)$page['slug']; $elements = $this->db->fetchAllAssociative( 'SELECT uid, CType, colPos, sorting, hidden, header, tx_container_parent, vitec_learnmore_label, vitec_learnmore_target FROM tt_content WHERE pid = ? AND deleted = 0 AND sys_language_uid = 0 ORDER BY colPos, sorting, uid', [$pid] ); foreach ($elements as $el) { $output->writeln(sprintf( ' col %-3d %5d #%-6d %-22s %s%s', $el['colPos'], $el['sorting'], $el['uid'], $el['CType'], (int)$el['hidden'] === 1 ? '[hidden] ' : '', $this->short((string)$el['header']) )); } $topLevel = array_values(array_filter($elements, static fn(array $e): bool => (int)$e['tx_container_parent'] === 0)); $live = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['hidden'] === 0)); $col0 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 0)); $col1 = array_values(array_filter($live, static fn(array $e): bool => (int)$e['colPos'] === 1)); $col0All = array_values(array_filter($topLevel, static fn(array $e): bool => (int)$e['colPos'] === 0)); $datamap = ['tt_content' => [], 'sys_file_reference' => []]; // --- B1: intro image + learn-more target -------------------------------- $intro = $this->first($col0, 'vitec_marketintro'); if ($intro === null) { $this->warn('no live vitec_marketintro in colPos 0 - B1 skipped'); } else { $introUid = (int)$intro['uid']; $refs = (int)$this->db->fetchOne( "SELECT COUNT(*) FROM sys_file_reference WHERE deleted = 0 AND tablenames = 'tt_content' AND fieldname = 'vitec_image' AND uid_foreign = ?", [$introUid] ); if ($refs > 0) { $output->writeln(sprintf(' B1 intro #%d already has %d image(s) - unchanged', $introUid, $refs)); } else { $identifier = 'placeholders/pages/' . $pid . '-02.png'; if (!$this->storage->hasFile($identifier)) { $this->warn("$identifier missing - using generic " . self::GENERIC_PLACEHOLDER); $identifier = self::GENERIC_PLACEHOLDER; } if (!$this->storage->hasFile($identifier)) { $this->warn("$identifier missing as well - no image attached"); } else { $file = $this->storage->getFile($identifier); $refId = 'NEWimg' . $pid; $datamap['sys_file_reference'][$refId] = [ 'pid' => $pid, 'table_local' => 'sys_file', 'uid_local' => $file->getUid(), 'tablenames' => 'tt_content', 'fieldname' => 'vitec_image', 'uid_foreign' => $introUid, ]; $datamap['tt_content'][$introUid]['vitec_image'] = $refId; $output->writeln(sprintf(' B1 intro #%d: attach %s (sys_file %d)', $introUid, $identifier, $file->getUid())); $this->counts['images']++; } } if (trim((string)$intro['vitec_learnmore_target']) === '') { $datamap['tt_content'][$introUid]['vitec_learnmore_target'] = self::CONTACT_TARGET; $output->writeln(sprintf(' B1 intro #%d: learnmore_target "" -> %s', $introUid, self::CONTACT_TARGET)); $this->counts['targets']++; if (trim((string)$intro['vitec_learnmore_label']) === '') { $datamap['tt_content'][$introUid]['vitec_learnmore_label'] = self::LEARNMORE_LABEL; $output->writeln(sprintf(' B1 intro #%d: learnmore_label "" -> %s', $introUid, self::LEARNMORE_LABEL)); $this->counts['labels']++; } } else { $output->writeln(sprintf(' B1 intro #%d: learnmore_target already "%s" - unchanged', $introUid, $intro['vitec_learnmore_target'])); } } // --- B5: hide the lead paragraph in the hero slot -------------------------- $leads = array_values(array_filter($col1, static fn(array $e): bool => $e['CType'] === 'vitec_introparagraph')); if (count($leads) !== 1) { $this->warn(sprintf('%d live vitec_introparagraph in colPos 1 (expected 1)%s', count($leads), $leads === [] ? ' - B5 skipped' : ' - hiding all of them')); } foreach ($leads as $lead) { $datamap['tt_content'][(int)$lead['uid']]['hidden'] = 1; $output->writeln(sprintf(' B5 lead #%d "%s": hide', $lead['uid'], $this->short((string)$lead['header']))); $this->counts['hiddenLeads']++; } // --- anchor: last sub-market card after the "Explore" intro paragraph ----- $anchorUid = 0; $anchorLabel = ''; $exploreIdx = null; foreach ($col0 as $i => $el) { if ($el['CType'] === 'vitec_introparagraph') { $exploreIdx = $i; break; } } if ($exploreIdx !== null) { $anchorUid = (int)$col0[$exploreIdx]['uid']; $anchorLabel = 'intro paragraph #' . $anchorUid; for ($i = $exploreIdx + 1; $i < count($col0) && $col0[$i]['CType'] === 'vitec_card'; $i++) { $anchorUid = (int)$col0[$i]['uid']; $anchorLabel = 'card #' . $anchorUid . ' "' . $this->short((string)$col0[$i]['header']) . '"'; } } elseif ($intro !== null) { $anchorUid = (int)$intro['uid']; $anchorLabel = 'market intro #' . $anchorUid; } // the 2026-10-23 page import left HIDDEN shells for both bands - reuse them $logosShell = $this->first($col0All, 'vitec_customerlogos'); $testiShell = $this->first($col0All, 'vitec_usecaselist'); $hasLogos = $logosShell !== null && (int)$logosShell['hidden'] === 0; $hasTesti = $testiShell !== null && (int)$testiShell['hidden'] === 0; $hasCta = $this->first($col0, 'vitec_ctabanner') !== null; if (!$hasCta) { $this->warn('no vitec_ctabanner in colPos 0 - the new bands will end the page'); } // --- B2 (shell): unhide the "Trusted by" shell with the home page settings ----- if ($logosShell !== null && !$hasLogos) { $logosUid = (int)$logosShell['uid']; $datamap['tt_content'][$logosUid] = [ 'hidden' => 0, 'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [ 'settings.layout' => ['vDEF' => (string)($templateSettings['layout'] ?? 'marquee')], 'settings.customers' => ['vDEF' => (string)($templateSettings['customers'] ?? '')], 'settings.onlySelected' => ['vDEF' => (string)($templateSettings['onlySelected'] ?? '0')], ]]]], ]; $output->writeln(sprintf(' B2 unhide logo shell #%d "%s", settings as #%d', $logosUid, $this->short((string)$logosShell['header']), self::LOGO_TEMPLATE_UID)); $this->counts['logos']++; } // --- B3: testimonials ------------------------------------------------------------- // Selection: explicit lists of the market's sub-markets when they have // any; today all 24 sub-market sliders have an EMPTY selection, which // the plugin renders as "all stories" - the main pages do the same. $stories = $storiesByMarket[$pid] ?? []; $storySource = 'own sub-markets'; if ($stories === []) { $stories = $defaultStories; $storySource = 'most common sub-market list'; } if ($stories === []) { $storySource = 'empty selection = all stories, like the sub-market pages'; } if ($hasTesti) { $output->writeln(' B3 vitec_usecaselist already on the page - unchanged'); } elseif ($testiShell !== null) { $testiUid = (int)$testiShell['uid']; $current = $this->flexSettings((string)$this->db->fetchOne('SELECT pi_flexform FROM tt_content WHERE uid = ?', [$testiUid])); $currentStories = array_values(array_filter(array_map('intval', explode(',', (string)($current['usecases'] ?? ''))))); if ($currentStories !== []) { $stories = $currentStories; $storySource = 'already selected on the shell'; } { $datamap['tt_content'][$testiUid] = [ 'hidden' => 0, 'pi_flexform' => ['data' => ['sDEF' => ['lDEF' => [ 'settings.layout' => ['vDEF' => 'testimonials'], 'settings.usecases' => ['vDEF' => implode(',', $stories)], 'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID], 'settings.showtoolbar' => ['vDEF' => '0'], ]]]], ]; $output->writeln(sprintf(' B3 unhide shell #%d "%s" as testimonials (%s%s)', $testiUid, $this->short((string)$testiShell['header']), $storySource, $stories === [] ? '' : ': ' . implode(',', $stories))); $this->counts['testimonials']++; } } elseif ($anchorUid === 0) { $this->warn('no anchor element found - B2/B3 skipped'); } else { $datamap['tt_content']['NEWtesti' . $pid] = [ 'pid' => -$anchorUid, 'colPos' => 0, 'sys_language_uid' => 0, 'CType' => 'vitec_usecaselist', 'header' => self::TESTIMONIALS_HEADER, 'pi_flexform' => [ 'data' => ['sDEF' => ['lDEF' => [ 'settings.layout' => ['vDEF' => 'testimonials'], 'settings.usecases' => ['vDEF' => implode(',', $stories)], 'settings.singlePid' => ['vDEF' => (string)self::STORY_SINGLE_PID], 'settings.showtoolbar' => ['vDEF' => '0'], ]]], ], ]; $output->writeln(sprintf(' B3 new vitec_usecaselist (testimonials, %s%s) after %s', $storySource, $stories === [] ? '' : ': ' . implode(',', $stories), $anchorLabel)); $this->counts['testimonials']++; } // --- write datamap ------------------------------------------------------------ $this->runDataHandler($datamap, []); // --- B2 (no shell): logo band copied from #758, after the anchor (= before testimonials) if ($hasLogos) { $output->writeln(' B2 vitec_customerlogos already visible on the page - unchanged'); } elseif ($logosShell !== null) { // handled above (shell unhidden in the datamap) } elseif ($anchorUid > 0) { $cmdmap = ['tt_content' => [self::LOGO_TEMPLATE_UID => ['copy' => [ 'action' => 'paste', 'target' => -$anchorUid, 'update' => ['colPos' => 0], ]]]]; $output->writeln(sprintf(' B2 copy logo band #%d after %s', self::LOGO_TEMPLATE_UID, $anchorLabel)); $this->counts['logos']++; $this->runDataHandler([], $cmdmap); } $output->writeln(''); } // ===================================================================== // sub-market pages: insights CTA -> news // ===================================================================== $output->writeln('== sub-market pages: vitec_marketinsights CTA'); $insights = $this->db->fetchAllAssociative( "SELECT c.uid, c.pid, c.hidden, c.vitec_cta_label, c.vitec_cta_target, p.slug, p.hidden AS page_hidden FROM tt_content c JOIN pages p ON p.uid = c.pid WHERE c.CType = 'vitec_marketinsights' AND c.deleted = 0 AND c.sys_language_uid = 0 AND p.deleted = 0 AND p.pid IN ($in) ORDER BY p.slug" ); $datamap = ['tt_content' => []]; foreach ($insights as $row) { $uid = (int)$row['uid']; $target = trim((string)$row['vitec_cta_target']); $label = trim((string)$row['vitec_cta_label']); $changes = []; if ($target === self::OLD_INSIGHTS_TARGET || $target === '') { $datamap['tt_content'][$uid]['vitec_cta_target'] = self::NEW_INSIGHTS_TARGET; $changes[] = sprintf('target "%s" -> %s', $target, self::NEW_INSIGHTS_TARGET); $this->counts['insightsTargets']++; if ($label === self::OLD_INSIGHTS_LABEL || $label === '') { $datamap['tt_content'][$uid]['vitec_cta_label'] = self::NEW_INSIGHTS_LABEL; $changes[] = sprintf('label "%s" -> %s', $label, self::NEW_INSIGHTS_LABEL); $this->counts['insightsLabels']++; } else { $changes[] = sprintf('label "%s" kept (not the standard text)', $label); } $purgePaths[] = (string)$row['slug']; } elseif ($target === self::NEW_INSIGHTS_TARGET) { $changes[] = 'already ' . self::NEW_INSIGHTS_TARGET; } else { $changes[] = sprintf('target "%s" is individual - unchanged', $target); } $output->writeln(sprintf(' #%-6d %-60s %s%s', $uid, $row['slug'], (int)$row['hidden'] === 1 || (int)$row['page_hidden'] === 1 ? '[hidden] ' : '', implode('; ', $changes))); } $this->runDataHandler($datamap, []); $output->writeln(''); // ===================================================================== $output->writeln(sprintf( '%s: %d intro image(s), %d learnmore target(s) (+%d label), %d logo band(s), %d testimonial slider(s), %d lead paragraph(s) hidden, %d insights target(s) (+%d label); %d warning(s).', $this->apply ? 'DONE' : 'DRY RUN - would write', $this->counts['images'], $this->counts['targets'], $this->counts['labels'], $this->counts['logos'], $this->counts['testimonials'], $this->counts['hiddenLeads'], $this->counts['insightsTargets'], $this->counts['insightsLabels'], $this->counts['warnings'] )); $purgePaths = array_values(array_unique($purgePaths)); $output->writeln('Afterwards: vendor/bin/typo3 cache:flush && vendor/bin/typo3 vitec:ssr-purge ' . implode(' ', $purgePaths) . ' && vendor/bin/typo3 vitec:ssr-warm ' . implode(' ', $purgePaths)); return Command::SUCCESS; } /** @param array>> $datamap */ private function runDataHandler(array $datamap, array $cmdmap): void { $datamap = array_filter($datamap); if (!$this->apply || ($datamap === [] && $cmdmap === [])) { return; } $dataHandler = GeneralUtility::makeInstance(DataHandler::class); $dataHandler->start($datamap, $cmdmap); if ($datamap !== []) { $dataHandler->process_datamap(); } if ($cmdmap !== []) { $dataHandler->process_cmdmap(); } if ($dataHandler->errorLog !== []) { $this->warn('DataHandler: ' . implode(' | ', $dataHandler->errorLog)); } foreach ($dataHandler->substNEWwithIDs as $new => $uid) { $this->output->writeln(sprintf(' written: %s -> #%d', $new, $uid)); } foreach ($dataHandler->copyMappingArray_merged['tt_content'] ?? [] as $from => $to) { $this->output->writeln(sprintf(' copied: #%d -> #%d', $from, $to)); } } /** @return array flat "settings" sheet of a pi_flexform XML (empty when none) */ private function flexSettings(string $xml): array { if (trim($xml) === '') { return []; } $settings = GeneralUtility::makeInstance(FlexFormService::class)->convertFlexFormContentToArray($xml)['settings'] ?? []; return is_array($settings) ? array_map(static fn($v): string => is_scalar($v) ? (string)$v : '', $settings) : []; } /** @param array> $elements */ private function first(array $elements, string $cType): ?array { foreach ($elements as $el) { if ($el['CType'] === $cType) { return $el; } } return null; } private function warn(string $message): void { $this->counts['warnings']++; $this->output->writeln(' WARN ' . $message . ''); } private function short(string $text): string { $text = trim((string)preg_replace('/\s+/', ' ', strip_tags($text))); return mb_strlen($text) > 60 ? mb_substr($text, 0, 57) . '...' : $text; } }