Fill the /markets main page from "Markets Hero Page.xlsx"
- new vitec:import-markets-main (dry-run by default): one page with fixed approved copy, so the workbook content is embedded in the command instead of parsed at runtime; every target is resolved at runtime (pages by slug, elements by CType/colPos/container, market records by normalised title) and the command aborts on an unexpected structure - the page already had the workbook structure (template vitec__2), so this updates the hero, the 50/50 markets intro (CTA -> /solutions) and the overview header in place - the marketlist flexform selection moves from the old 9-record set (which still included the dropped "Security & Surveillance") to the 11 main-market records in workbook order - the 11 market card bodies land in the market records' teaser field (all were empty; the marketlist cards render from the records - frontend briefing asks for teaser on the cards) - the old single-button prefooter container is hidden, replaced by a vitec_ctabanner (centered/orange, as on the 11 market hero pages) with both workbook CTAs: "Talk to our team" -> /contact and "Explore solutions" -> /solutions; explicit sorting after DataHandler - page properties per the established set: seo/og/twitter title and description, canonical, abstract, curated keywords - applied and verified live in the JSON (cards, order, teasers, CTA targets, meta tags); no spec bump - no new JSON keys involved Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46049bf30d
commit
c4ec979f78
1 file changed
+285
@@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
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 /markets main page from the agency workbook "Markets Hero
|
||||||
|
* Page.xlsx" (Dropbox delivery 2026-10-23, status "Ready for review").
|
||||||
|
* One page, fixed copy - the content is embedded below instead of parsing
|
||||||
|
* the workbook at runtime (same idea as BackfillRedirectsCommand).
|
||||||
|
*
|
||||||
|
* The page already has the workbook's structure (template vitec__2):
|
||||||
|
* hero (vitec_herosection, colPos 1)
|
||||||
|
* markets intro 50/50 (vitec_introparagraph, colPos 0)
|
||||||
|
* overview container (vitec_container: header + vitec_marketlist)
|
||||||
|
* prefooter container (vitec_container: vitec_introparagraph)
|
||||||
|
* so this is a CONTENT update plus three structural touches:
|
||||||
|
* - the marketlist flexform selection moves from the old 9-record set
|
||||||
|
* (incl. the dropped "Security & Surveillance") to the 11 main-market
|
||||||
|
* records in workbook order
|
||||||
|
* - the old prefooter container is HIDDEN and a vitec_ctabanner with the
|
||||||
|
* two workbook CTAs (contact / solutions) takes its place - the same
|
||||||
|
* centered/orange prefooter the 11 market hero pages use
|
||||||
|
* - the 11 market card bodies land in the market RECORDS' teaser field
|
||||||
|
* (the marketlist cards render from the records)
|
||||||
|
* Page properties follow the established set (og/twitter/canonical/
|
||||||
|
* abstract/keywords).
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:import-markets-main
|
||||||
|
* vendor/bin/typo3 vitec:import-markets-main --apply
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:import-markets-main',
|
||||||
|
description: 'Fill the /markets main page from "Markets Hero Page.xlsx" (dry-run by default)'
|
||||||
|
)]
|
||||||
|
final class ImportMarketsMainPageCommand extends Command
|
||||||
|
{
|
||||||
|
private const HERO_H1 = 'Video solutions for every operational environment';
|
||||||
|
private const HERO_COPY = 'From passenger communications and live production to critical infrastructure and command centers, every environment depends on information reaching the right people at the right time. VITEC brings together secure video, visualization and communications technologies to help organizations maintain visibility, communicate clearly and operate with confidence.';
|
||||||
|
private const HERO_CTA = 'Explore our markets';
|
||||||
|
|
||||||
|
private const INTRO_H2 = 'Built around the way your industry operates';
|
||||||
|
private const INTRO_COPY = 'Different industries place different demands on video and visual information. Some need to engage audiences across thousands of screens. Others need operators to see critical data, coordinate teams and respond quickly. VITEC combines video distribution, streaming, visualization and display technologies to support these distinct operational needs across commercial, public-sector and mission-critical environments.';
|
||||||
|
private const INTRO_CTA = 'Explore solutions';
|
||||||
|
|
||||||
|
private const OVERVIEW_H2 = 'Explore our markets';
|
||||||
|
|
||||||
|
/** workbook order; titles match the record taxonomy per norm() */
|
||||||
|
private const CARDS = [
|
||||||
|
'Hospitality' => 'Create connected guest experiences with IPTV, digital signage and video distribution that support entertainment, communications and consistent service across hospitality environments.',
|
||||||
|
'Transport' => 'Keep passengers informed and operations visible with real-time information, video distribution and visualization across airports, stations and complex transport environments.',
|
||||||
|
'Sports & Entertainment' => 'Connect fans, guests and operators with live video, digital signage, data visualization and control-room technology across venues, events and entertainment environments.',
|
||||||
|
'Broadcast & Telecoms' => 'Support contribution, production, distribution and network operations with reliable video workflows designed for demanding broadcast, media and telecom environments.',
|
||||||
|
'Education' => 'Deliver live and on-demand video, campus communications and digital information across learning spaces, shared facilities and student accommodation.',
|
||||||
|
'Corporation' => 'Improve communication and operational awareness across workplaces with IPTV, digital signage, video distribution, dashboards and control-room visualization.',
|
||||||
|
'Industrial' => 'Give industrial teams clearer oversight of sites, processes and operational data through centralized monitoring, visualization and secure video distribution.',
|
||||||
|
'Critical National Infrastructure' => 'Support resilient infrastructure operations with real-time monitoring, secure communications and visualization that help teams maintain awareness across critical systems and assets.',
|
||||||
|
'Government' => 'Enable secure communications, public information delivery and coordinated operations across government environments with reliable video, signage and control-room solutions.',
|
||||||
|
'Public Safety' => 'Bring live video, operational data and incident information into a shared view to support situational awareness, coordination and faster response.',
|
||||||
|
'Defense & Security' => 'Deliver secure, low-latency video and mission-critical visualization for ISR, command and control, tactical distribution and high-security operational environments.',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const PREFOOTER_H2 = 'Find the right approach for your environment';
|
||||||
|
private const PREFOOTER_COPY = 'Your operational priorities determine the technology you need. Talk to VITEC about your environment, workflows and users, and we can help identify the video, visualization and communications approach that fits your requirements.';
|
||||||
|
private const PREFOOTER_PRIMARY = 'Talk to our team';
|
||||||
|
private const PREFOOTER_SECONDARY = 'Explore solutions';
|
||||||
|
|
||||||
|
private const SEO_TITLE = 'Video Solutions by Market | VITEC';
|
||||||
|
private const SEO_DESCRIPTION = 'Explore VITEC video, visualization, streaming and communications solutions for transport, broadcast, industry, government, public safety and more.';
|
||||||
|
/** Curated page keywords (Claude, 2026-10-26), same convention as the hero pages. */
|
||||||
|
private const KEYWORDS = 'video solutions by market, IPTV, digital signage, video distribution, visualization, control room, broadcast, transport, government, public safety, defense, critical infrastructure';
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$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');
|
||||||
|
$pool = GeneralUtility::makeInstance(ConnectionPool::class);
|
||||||
|
$pages = $pool->getConnectionForTable('pages');
|
||||||
|
$content = $pool->getConnectionForTable('tt_content');
|
||||||
|
|
||||||
|
$marketsUid = $this->pageUid($pages, '/markets');
|
||||||
|
$solutionsUid = $this->pageUid($pages, '/solutions');
|
||||||
|
$contactUid = $this->pageUid($pages, '/contact');
|
||||||
|
if (!$marketsUid || !$solutionsUid || !$contactUid) {
|
||||||
|
$output->writeln(sprintf('<error>Page lookup failed: /markets=%d /solutions=%d /contact=%d</error>', $marketsUid, $solutionsUid, $contactUid));
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . sprintf(' · /markets #%d · /solutions #%d · /contact #%d', $marketsUid, $solutionsUid, $contactUid));
|
||||||
|
$datamap = [];
|
||||||
|
|
||||||
|
// --- resolve the existing elements ---------------------------------------
|
||||||
|
$hero = $this->one($content, $marketsUid, "CType = 'vitec_herosection' AND colPos = 1", 'hero');
|
||||||
|
$intro = $this->one($content, $marketsUid, "CType = 'vitec_introparagraph' AND colPos = 0 AND tx_container_parent = 0", 'markets intro');
|
||||||
|
$containers = $content->fetchAllAssociative(
|
||||||
|
"SELECT uid, hidden, sorting FROM tt_content WHERE deleted = 0 AND sys_language_uid IN (0,-1) AND pid = ? AND CType = 'vitec_container' AND colPos = 0 AND tx_container_parent = 0 ORDER BY sorting",
|
||||||
|
[$marketsUid]
|
||||||
|
);
|
||||||
|
if ($hero === null || $intro === null || count($containers) !== 2) {
|
||||||
|
$output->writeln('<error>Unexpected page structure (' . count($containers) . ' top-level containers) - aborting.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$overviewContainer = $prefooterContainer = null;
|
||||||
|
$overviewHeader = $marketlist = null;
|
||||||
|
foreach ($containers as $container) {
|
||||||
|
$children = $content->fetchAllAssociative(
|
||||||
|
'SELECT uid, CType, header, hidden, sorting FROM tt_content WHERE deleted = 0 AND tx_container_parent = ? ORDER BY sorting',
|
||||||
|
[(int)$container['uid']]
|
||||||
|
);
|
||||||
|
foreach ($children as $child) {
|
||||||
|
if ($child['CType'] === 'vitec_marketlist') {
|
||||||
|
$overviewContainer = $container;
|
||||||
|
$marketlist = $child;
|
||||||
|
}
|
||||||
|
if ($child['CType'] === 'header') {
|
||||||
|
$overviewHeader = $child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($overviewContainer === null || (int)$container['uid'] !== (int)$overviewContainer['uid']) {
|
||||||
|
$prefooterContainer = $container;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($overviewContainer === null || $prefooterContainer === null || $overviewHeader === null || $marketlist === null) {
|
||||||
|
$output->writeln('<error>Could not identify overview/prefooter containers - aborting.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hero -----------------------------------------------------------------
|
||||||
|
$datamap['tt_content'][(int)$hero['uid']] = [
|
||||||
|
'header' => self::HERO_H1,
|
||||||
|
'bodytext' => $this->p(self::HERO_COPY),
|
||||||
|
'vitec_cta_label' => self::HERO_CTA,
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' ~ hero #%d: H1 "%s" + copy + CTA label "%s" (anchor target kept)', (int)$hero['uid'], self::HERO_H1, self::HERO_CTA));
|
||||||
|
|
||||||
|
// --- markets intro 50/50 ----------------------------------------------------
|
||||||
|
$datamap['tt_content'][(int)$intro['uid']] = [
|
||||||
|
'header' => self::INTRO_H2,
|
||||||
|
'bodytext' => $this->p(self::INTRO_COPY),
|
||||||
|
'vitec_button_text' => self::INTRO_CTA,
|
||||||
|
'vitec_button_link' => 't3://page?uid=' . $solutionsUid,
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' ~ intro #%d: H2 "%s" + copy + CTA "%s" -> /solutions', (int)$intro['uid'], self::INTRO_H2, self::INTRO_CTA));
|
||||||
|
|
||||||
|
// --- overview header + marketlist selection --------------------------------
|
||||||
|
$datamap['tt_content'][(int)$overviewHeader['uid']] = ['header' => self::OVERVIEW_H2];
|
||||||
|
$output->writeln(sprintf(' ~ overview header #%d: "%s" -> "%s"', (int)$overviewHeader['uid'], (string)$overviewHeader['header'], self::OVERVIEW_H2));
|
||||||
|
|
||||||
|
$markets = $pool->getConnectionForTable('tx_vitec_domain_model_market')->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, teaser FROM tx_vitec_domain_model_market WHERE deleted = 0 AND sys_language_uid IN (0,-1)'
|
||||||
|
);
|
||||||
|
$selection = [];
|
||||||
|
$missing = [];
|
||||||
|
foreach (self::CARDS as $title => $body) {
|
||||||
|
$record = null;
|
||||||
|
foreach ($markets as $row) {
|
||||||
|
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
||||||
|
$record = $row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($record === null) {
|
||||||
|
$missing[] = $title;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$selection[] = (int)$record['uid'];
|
||||||
|
$oldTeaser = trim((string)$record['teaser']);
|
||||||
|
if ($oldTeaser !== $body) {
|
||||||
|
$datamap['tx_vitec_domain_model_market'][(int)$record['uid']] = ['teaser' => $body];
|
||||||
|
$output->writeln(sprintf(' ~ market record #%d "%s": teaser %s', (int)$record['uid'], (string)$record['title'],
|
||||||
|
$oldTeaser === '' ? 'set (was empty)' : 'OVERWRITTEN ("' . mb_substr($oldTeaser, 0, 50) . '...")'));
|
||||||
|
} else {
|
||||||
|
$output->writeln(sprintf(' = market record #%d "%s": teaser already current', (int)$record['uid'], (string)$record['title']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($missing !== []) {
|
||||||
|
$output->writeln('<error>No market record for: ' . implode(', ', $missing) . ' - aborting.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$datamap['tt_content'][(int)$marketlist['uid']]['pi_flexform'] = [
|
||||||
|
'data' => ['sDEF' => ['lDEF' => ['settings.markets' => ['vDEF' => implode(',', $selection)]]]],
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' ~ marketlist #%d: selection -> 11 records in workbook order (%s)', (int)$marketlist['uid'], implode(',', $selection)));
|
||||||
|
|
||||||
|
// --- prefooter: hide the old container, add the two-CTA banner -------------
|
||||||
|
if ((int)$prefooterContainer['hidden'] !== 1) {
|
||||||
|
$datamap['tt_content'][(int)$prefooterContainer['uid']]['hidden'] = 1;
|
||||||
|
$output->writeln(sprintf(' ~ old prefooter container #%d: HIDDEN (kept for reference)', (int)$prefooterContainer['uid']));
|
||||||
|
}
|
||||||
|
$datamap['tt_content']['NEWPREFOOTER'] = [
|
||||||
|
'pid' => $marketsUid,
|
||||||
|
'CType' => 'vitec_ctabanner',
|
||||||
|
'colPos' => 0,
|
||||||
|
'header' => self::PREFOOTER_H2,
|
||||||
|
'bodytext' => $this->p(self::PREFOOTER_COPY),
|
||||||
|
'vitec_primary_cta' => 't3://page?uid=' . $contactUid,
|
||||||
|
'vitec_primary_cta_label' => self::PREFOOTER_PRIMARY,
|
||||||
|
'vitec_secondary_cta' => 't3://page?uid=' . $solutionsUid,
|
||||||
|
'vitec_secondary_cta_label' => self::PREFOOTER_SECONDARY,
|
||||||
|
'vitec_layout_variant' => 'centered',
|
||||||
|
'vitec_background_variant' => 'orange',
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' + prefooter vitec_ctabanner (centered/orange, as on the hero pages): "%s" -> /contact, "%s" -> /solutions',
|
||||||
|
self::PREFOOTER_PRIMARY, self::PREFOOTER_SECONDARY));
|
||||||
|
|
||||||
|
// --- page properties --------------------------------------------------------
|
||||||
|
$datamap['pages'][$marketsUid] = [
|
||||||
|
'seo_title' => self::SEO_TITLE,
|
||||||
|
'og_title' => self::SEO_TITLE,
|
||||||
|
'twitter_title' => self::SEO_TITLE,
|
||||||
|
'description' => self::SEO_DESCRIPTION,
|
||||||
|
'og_description' => self::SEO_DESCRIPTION,
|
||||||
|
'twitter_description' => self::SEO_DESCRIPTION,
|
||||||
|
'abstract' => self::SEO_DESCRIPTION,
|
||||||
|
'canonical_link' => 't3://page?uid=' . $marketsUid,
|
||||||
|
'keywords' => self::KEYWORDS,
|
||||||
|
];
|
||||||
|
$output->writeln(' ~ page meta: seo/og/twitter title + description, canonical, abstract, keywords');
|
||||||
|
|
||||||
|
if (!$apply) {
|
||||||
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($datamap, []);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
$output->writeln('<error>DataHandler: ' . implode(' | ', $dataHandler->errorLog) . '</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
// DataHandler puts NEW records at the top - move the banner to the end,
|
||||||
|
// right where the hidden old prefooter container sits.
|
||||||
|
$newUid = (int)($dataHandler->substNEWwithIDs['NEWPREFOOTER'] ?? 0);
|
||||||
|
if ($newUid > 0) {
|
||||||
|
$content->update('tt_content', ['sorting' => (int)$prefooterContainer['sorting'] + 8], ['uid' => $newUid]);
|
||||||
|
$output->writeln(sprintf('<info>Done. Banner #%d sorted after the old container. Flush the frontend cache.</info>', $newUid));
|
||||||
|
} else {
|
||||||
|
$output->writeln('<error>New prefooter banner uid not resolved.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pageUid($pages, string $slug): int
|
||||||
|
{
|
||||||
|
$row = $pages->fetchAssociative('SELECT uid FROM pages WHERE deleted = 0 AND slug = ? AND sys_language_uid IN (0,-1)', [$slug]);
|
||||||
|
return (int)($row['uid'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null exactly one matching element, else null (reported) */
|
||||||
|
private function one($content, int $pid, string $where, string $label): ?array
|
||||||
|
{
|
||||||
|
$rows = $content->fetchAllAssociative(
|
||||||
|
'SELECT uid, header, hidden, sorting FROM tt_content WHERE deleted = 0 AND sys_language_uid IN (0,-1) AND pid = ? AND ' . $where,
|
||||||
|
[$pid]
|
||||||
|
);
|
||||||
|
return count($rows) === 1 ? $rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function p(string $text): string
|
||||||
|
{
|
||||||
|
return '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function norm(string $value): string
|
||||||
|
{
|
||||||
|
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user