Markets branch: 11-market tree, sub-market import, hero pipeline
Structure per the agency agreement (Joe/Christina, confirmed 2026-10-23): /markets -> eleven market hero pages -> their sub-market pages; the use-case level stays sections on the sub-market pages. - new vitec:align-market-pages: renames 8 main market pages onto the agency names (slug change -> EXT:redirects), creates the 3 new markets, hides the dropped "Security & Surveillance" (records/stories untouched), reports unknown children. A child-slug pass realigns every child to <parent-slug>/<slugified-title> (DataHandler does not cascade slug changes; this also repaired the third embedded-slash slug, rtcc-/-rtic). --publish unhides the tree (dev convenience; element shells stay hidden) - sub-market import: all 24 agency workbooks applied (4 existing pages, 20 created) - their Meta sheets were clones of Corporate Environments (record_uid 32) and were repaired beforehand (market/sub-market set, record_uid 0; the record taxonomy alignment is a separate step) - SubmarketWorkbookImporter: page-properties parity (og/twitter titles and descriptions, canonical, abstract from the intro copy) and a placeholder upgrade pass - pages created in the same run only get the generic grey (their uid is still NEW), the re-run swaps it for the numbered per-page placeholder - new market hero pipeline: frontend layout 23 "Market Hero Page" (zones hero=colPos1, main=colPos0), Import\MarketHeroPageImporter + vitec:import-market-hero-workbooks (inbox migrations/markethero_xlsx_in, gitignored). Hero H1 travels on the introparagraph header (markethero has no headline field), the hero CTA anchors #submarkets, sub-market cards link their child page via t3://page, customerlogos and usecaselist ship hidden until filled editorially, prefooter ctabanner; full page-properties set with 11 curated keyword sets - applied end to end and live-verified (layout 23 heroes with linked cards, layout 21 sub pages with all six colPos groups, clean slugs) - spec v1.25 Open: market RECORD taxonomy (story badges / Solr facets still use the old 9 mains), featured product "ISR Video Mission Commander" has no record, the /markets main page workbook (template 2) is a separate flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
115952a132
commit
303defe24d
9 files changed
+870
-5
No files matched your search
@@ -135,3 +135,4 @@ migrations/submarkets_xlsx_in/*
|
|||||||
migrations/subsolutions_xlsx_in/*
|
migrations/subsolutions_xlsx_in/*
|
||||||
migrations/solutiongroups_xlsx_in/*
|
migrations/solutiongroups_xlsx_in/*
|
||||||
migrations/product_images_in/
|
migrations/product_images_in/
|
||||||
|
migrations/markethero_xlsx_in/
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aligns the MARKET PAGE TREE below /markets with Joe's agreed structure
|
||||||
|
* (sitemap tracker + Dropbox delivery, decision Olli 2026-10-23):
|
||||||
|
*
|
||||||
|
* /markets -> exactly 11 market hero pages -> their sub-market pages
|
||||||
|
* (the use-case level stays SECTIONS on the sub-market pages, 14.10.)
|
||||||
|
*
|
||||||
|
* What it does (dry-run by default):
|
||||||
|
* - renames 8 of our 9 main market pages onto Joe's market names, slug
|
||||||
|
* included (the slug change makes EXT:redirects create the redirect)
|
||||||
|
* - creates the 3 new markets HIDDEN (Critical National Infrastructure,
|
||||||
|
* Government, Public Safety)
|
||||||
|
* - hides "Security & Surveillance" (market dropped by the agency - its
|
||||||
|
* themes live in the solutions branch; records and stories untouched)
|
||||||
|
* - reports every other child it does not recognise (e.g. the stray
|
||||||
|
* "Hospitality & Leisure" page) for an editorial decision
|
||||||
|
*
|
||||||
|
* DELIBERATELY NOT here: the market RECORD taxonomy (9 mains drive the
|
||||||
|
* success-story badges, Solr facets and the story mapping
|
||||||
|
* INDUSTRY_TO_MARKET) - aligning that is a separate, editorially decided
|
||||||
|
* step. Pages first, so the workbook imports have their tree.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:align-market-pages
|
||||||
|
* vendor/bin/typo3 vitec:align-market-pages --apply
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:align-market-pages',
|
||||||
|
description: "Align the /markets page tree with Joe's 11-market structure (dry-run by default)"
|
||||||
|
)]
|
||||||
|
final class AlignMarketPagesCommand extends Command
|
||||||
|
{
|
||||||
|
/** Joe's market name => our current page title (null = create new). */
|
||||||
|
private const MARKETS = [
|
||||||
|
'Hospitality' => 'Retail Hospitality & Leisure',
|
||||||
|
'Transport' => 'Transport & Infrastructure',
|
||||||
|
'Sports & Entertainment' => 'Sports Venues & Entertainment',
|
||||||
|
'Broadcast and Telecoms' => 'Media Broadcast & Telecom',
|
||||||
|
'Education' => 'Healthcare & Education',
|
||||||
|
'Corporation' => 'Corporate & Enterprise',
|
||||||
|
'Industrial' => 'Energy Industrial & Resources',
|
||||||
|
'Critical National Infrastructure' => null,
|
||||||
|
'Government' => null,
|
||||||
|
'Public Safety' => null,
|
||||||
|
'Defense and Security' => 'Defense Government & Public Sector',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Dropped by the agency - page gets hidden, nothing is deleted. */
|
||||||
|
private const HIDE = ['Security & Surveillance'];
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('apply', null, InputOption::VALUE_NONE, 'Write changes (default: dry run)');
|
||||||
|
$this->addOption('publish', null, InputOption::VALUE_NONE, 'Also unhide the market pages and their children (dev convenience; the dropped market and element shells stay hidden)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
Bootstrap::initializeBackendAuthentication();
|
||||||
|
$apply = (bool)$input->getOption('apply');
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
|
||||||
|
$root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
||||||
|
if (!$root) {
|
||||||
|
$output->writeln('<error>Page /markets not found.</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$rootUid = (int)$root['uid'];
|
||||||
|
$children = $connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, slug, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1) ORDER BY sorting',
|
||||||
|
[$rootUid]
|
||||||
|
);
|
||||||
|
$byNorm = [];
|
||||||
|
foreach ($children as $child) {
|
||||||
|
$byNorm[$this->norm((string)$child['title'])] = $child;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln(($apply ? 'APPLY' : 'DRY-RUN') . ' · /markets uid ' . $rootUid . ' · ' . count($children) . ' direct children');
|
||||||
|
$datamap = [];
|
||||||
|
$claimed = [];
|
||||||
|
$targets = [];
|
||||||
|
$newIndex = 0;
|
||||||
|
|
||||||
|
foreach (self::MARKETS as $joeName => $oldName) {
|
||||||
|
$joeSlug = '/markets/' . $this->slugify($joeName);
|
||||||
|
$existingJoe = $byNorm[$this->norm($joeName)] ?? null;
|
||||||
|
if ($existingJoe !== null) {
|
||||||
|
$claimed[(int)$existingJoe['uid']] = true;
|
||||||
|
$targets[] = ['uid' => (int)$existingJoe['uid'], 'slug' => (string)$existingJoe['slug']];
|
||||||
|
$line = sprintf('= %-36s exists (uid %d%s)', $joeName, (int)$existingJoe['uid'], ((int)$existingJoe['hidden'] === 1) ? ', hidden' : '');
|
||||||
|
$output->writeln(' ' . $line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$old = $oldName !== null ? ($byNorm[$this->norm($oldName)] ?? null) : null;
|
||||||
|
if ($old !== null) {
|
||||||
|
$claimed[(int)$old['uid']] = true;
|
||||||
|
$datamap['pages'][(int)$old['uid']] = [
|
||||||
|
'title' => $joeName,
|
||||||
|
'slug' => $joeSlug,
|
||||||
|
];
|
||||||
|
$targets[] = ['uid' => (int)$old['uid'], 'slug' => $joeSlug];
|
||||||
|
$output->writeln(sprintf(' ~ %-36s RENAME from "%s" (uid %d), slug %s -> %s (redirect follows)',
|
||||||
|
$joeName, $oldName, (int)$old['uid'], $old['slug'], $joeSlug));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($oldName !== null) {
|
||||||
|
$output->writeln(sprintf(' <error>? %-36s neither Joe name nor "%s" found - review</error>', $joeName, $oldName));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newId = 'NEWMKT' . (++$newIndex);
|
||||||
|
$datamap['pages'][$newId] = [
|
||||||
|
'pid' => $rootUid,
|
||||||
|
'title' => $joeName,
|
||||||
|
'slug' => $joeSlug,
|
||||||
|
'doktype' => 1,
|
||||||
|
'hidden' => 1,
|
||||||
|
];
|
||||||
|
$output->writeln(sprintf(' + %-36s CREATE (hidden)', $joeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::HIDE as $hideName) {
|
||||||
|
$page = $byNorm[$this->norm($hideName)] ?? null;
|
||||||
|
if ($page === null) {
|
||||||
|
$output->writeln(' <comment>hide: "' . $hideName . '" not found - nothing to do</comment>');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$claimed[(int)$page['uid']] = true;
|
||||||
|
if ((int)$page['hidden'] === 1) {
|
||||||
|
$output->writeln(' - ' . $hideName . ' already hidden (uid ' . (int)$page['uid'] . ')');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$datamap['pages'][(int)$page['uid']] = ['hidden' => 1];
|
||||||
|
$output->writeln(' - ' . $hideName . ' HIDE (uid ' . (int)$page['uid'] . ') - dropped by the agency, records/stories untouched');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($children as $child) {
|
||||||
|
if (!isset($claimed[(int)$child['uid']])) {
|
||||||
|
$output->writeln(sprintf(' <comment>! unexpected child "%s" (uid %d%s) - review editorially</comment>',
|
||||||
|
(string)$child['title'], (int)$child['uid'], ((int)$child['hidden'] === 1) ? ', hidden' : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- optional: publish the tree (dev convenience, Olli 2026-10-23) -------
|
||||||
|
// Unhides the 11 market pages and their children. The dropped market
|
||||||
|
// ("Security & Surveillance") is no target and stays hidden; hidden
|
||||||
|
// ELEMENT shells (trusted-by, case studies) also stay hidden - an
|
||||||
|
// unhidden empty story list would render ALL stories.
|
||||||
|
if ((bool)$input->getOption('publish')) {
|
||||||
|
foreach ($targets as $target) {
|
||||||
|
$self = $connection->fetchAssociative('SELECT uid, title, hidden FROM pages WHERE uid = ?', [$target['uid']]);
|
||||||
|
$rows = $connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, hidden FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
|
||||||
|
[$target['uid']]
|
||||||
|
);
|
||||||
|
if ($self) {
|
||||||
|
array_unshift($rows, $self);
|
||||||
|
}
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ((int)$row['hidden'] === 1) {
|
||||||
|
$datamap['pages'][(int)$row['uid']]['hidden'] = 0;
|
||||||
|
$output->writeln(' * publish: "' . (string)$row['title'] . '" (uid ' . (int)$row['uid'] . ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- child slugs follow their (renamed) parent ---------------------------
|
||||||
|
// DataHandler does not cascade slug changes to subpages (only the
|
||||||
|
// backend UI offers that), so realign every child slug to
|
||||||
|
// <parent-slug>/<slugified-title>; EXT:redirects covers the old URLs.
|
||||||
|
// This also normalises slug accidents like embedded slashes from
|
||||||
|
// titles ("RTCC / RTIC").
|
||||||
|
foreach ($targets as $target) {
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, slug FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)',
|
||||||
|
[$target['uid']]
|
||||||
|
) as $child) {
|
||||||
|
$expected = rtrim((string)$target['slug'], '/') . '/' . $this->slugify((string)$child['title']);
|
||||||
|
if ((string)$child['slug'] !== $expected) {
|
||||||
|
$datamap['pages'][(int)$child['uid']]['slug'] = $expected;
|
||||||
|
$output->writeln(sprintf(' ~ child slug: "%s" (uid %d) %s -> %s (redirect follows)',
|
||||||
|
(string)$child['title'], (int)$child['uid'], (string)$child['slug'], $expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$apply) {
|
||||||
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
if ($datamap === []) {
|
||||||
|
$output->writeln('Nothing to change.');
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
$output->writeln('<info>Done. Flush the cache: vendor/bin/typo3 cache:flush</info>');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function norm(string $value): string
|
||||||
|
{
|
||||||
|
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slugify(string $value): string
|
||||||
|
{
|
||||||
|
return trim((string)preg_replace('/[^a-z0-9]+/', '-', str_replace('&', 'and', mb_strtolower($value))), '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Command;
|
||||||
|
|
||||||
|
use Evomedien\Vitec\Import\MarketHeroPageImporter;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI shell around MarketHeroPageImporter - imports the agency "Markets
|
||||||
|
* hero sub page" workbooks into the eleven market hero pages (layout 23).
|
||||||
|
* Run vitec:align-market-pages first, and ideally the sub-market import
|
||||||
|
* before this one so the sub-market cards can link their child pages.
|
||||||
|
*
|
||||||
|
* vendor/bin/typo3 vitec:import-market-hero-workbooks
|
||||||
|
* vendor/bin/typo3 vitec:import-market-hero-workbooks --only=hospitality --apply
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'vitec:import-market-hero-workbooks',
|
||||||
|
description: 'Import the agency market hero workbooks into the market pages (dry-run by default)'
|
||||||
|
)]
|
||||||
|
final class ImportMarketHeroWorkbooksCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly MarketHeroPageImporter $importer)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dir', null, InputOption::VALUE_REQUIRED, 'Directory with filled workbooks', 'migrations/markethero_xlsx_in');
|
||||||
|
$this->addOption('only', null, InputOption::VALUE_REQUIRED, 'Only files whose name contains this substring');
|
||||||
|
$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');
|
||||||
|
$only = mb_strtolower((string)($input->getOption('only') ?? ''));
|
||||||
|
$dir = rtrim((string)$input->getOption('dir'), '/');
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
$output->writeln('<error>Directory not found: ' . $dir . '</error>');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$files = [];
|
||||||
|
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir));
|
||||||
|
foreach ($iterator as $fileInfo) {
|
||||||
|
$name = $fileInfo->getFilename();
|
||||||
|
if ($fileInfo->isFile() && str_ends_with($name, '.xlsx') && !str_starts_with($name, '~$')) {
|
||||||
|
if ($only === '' || str_contains(mb_strtolower($name), $only)) {
|
||||||
|
$files[] = $fileInfo->getPathname();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort($files);
|
||||||
|
if ($files === []) {
|
||||||
|
$output->writeln('No workbooks found in ' . $dir);
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
$output->writeln(count($files) . ' workbook(s) · ' . ($apply ? 'APPLY' : 'DRY-RUN'));
|
||||||
|
|
||||||
|
$errors = 0;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
try {
|
||||||
|
$result = $this->importer->importFile($file, $apply);
|
||||||
|
$output->writeln(sprintf("\n<info>%s</info> (market: %s)", $result['file'], $result['market']));
|
||||||
|
foreach ($result['report'] as $line) {
|
||||||
|
$output->writeln(' <comment>' . $line . '</comment>');
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf(' page #%d · %d element(s) touched · %s',
|
||||||
|
$result['pageUid'], $result['elements'], $apply ? 'written.' : 'dry run'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors++;
|
||||||
|
$output->writeln('<error>' . basename($file) . ': ' . $e->getMessage() . '</error>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$apply) {
|
||||||
|
$output->writeln("\nDRY RUN - nothing written. Add --apply to write.");
|
||||||
|
}
|
||||||
|
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Import;
|
||||||
|
|
||||||
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||||
|
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports ONE agency "Markets hero sub page" workbook (Joe's Dropbox
|
||||||
|
* delivery, structure decision Olli 2026-10-23) into its market hero page -
|
||||||
|
* the eleven pages directly below /markets after vitec:align-market-pages.
|
||||||
|
*
|
||||||
|
* DELIBERATE SIBLING of the other workbook importers - pipelines evolve
|
||||||
|
* independently.
|
||||||
|
*
|
||||||
|
* Workbook format: sheets Read me / Page layout / Content / Sources / Meta;
|
||||||
|
* the Content sheet is a label table (column A component, column B copy,
|
||||||
|
* column C/D guidance). The market is identified by the FILE name (minus a
|
||||||
|
* " market page" suffix); the hero page must already exist below /markets.
|
||||||
|
*
|
||||||
|
* Target template is the NEW layout 23 "Market Hero Page" (zones hero=1,
|
||||||
|
* main=0). Element plan, in page order:
|
||||||
|
*
|
||||||
|
* colPos 1: vitec_markethero (CTA -> #submarkets, bg slot 01)
|
||||||
|
* vitec_introparagraph (header = Hero H1, body = hero intro)
|
||||||
|
* colPos 0: vitec_marketintro (Market intro H2/copy + CTA label)
|
||||||
|
* vitec_introparagraph (Sub-markets H2)
|
||||||
|
* vitec_card xN (sub-market cards, text_only, CTA links
|
||||||
|
* to the child page when it exists -
|
||||||
|
* re-run after the sub-market import
|
||||||
|
* resolves the rest)
|
||||||
|
* vitec_customerlogos (Trusted by - HIDDEN shell, logos are
|
||||||
|
* editorial)
|
||||||
|
* vitec_usecaselist (Case studies - HIDDEN shell, selection
|
||||||
|
* is editorial)
|
||||||
|
* vitec_ctabanner (prefooter, two CTA labels)
|
||||||
|
*
|
||||||
|
* Page properties as on the solution pages (og/twitter/canonical/abstract
|
||||||
|
* from the hero intro copy, keywords from MARKET_KEYWORDS).
|
||||||
|
*
|
||||||
|
* NOTE norm(): '&' maps to 'and' here (unlike the sub-market/sub-solution
|
||||||
|
* importers) so the file "Broadcast & Telecoms" matches the page
|
||||||
|
* "Broadcast and Telecoms". Both spellings occur in Joe's delivery.
|
||||||
|
*/
|
||||||
|
final class MarketHeroPageImporter
|
||||||
|
{
|
||||||
|
private const LAYOUT = 23;
|
||||||
|
private const COLPOS_HERO = 1;
|
||||||
|
private const COLPOS_MAIN = 0;
|
||||||
|
private const CARD_ROWS = 6;
|
||||||
|
|
||||||
|
/** Curated page keywords per market (Claude, 2026-10-23). Keys per norm(). */
|
||||||
|
private const MARKET_KEYWORDS = [
|
||||||
|
'hospitality' => 'hospitality iptv, hotel tv systems, guest entertainment, digital signage hotels, cruise video systems',
|
||||||
|
'transport' => 'transport video solutions, airport iptv, passenger information displays, transport signage, station video systems',
|
||||||
|
'sportsandentertainment' => 'stadium iptv, venue video distribution, sports video solutions, fan engagement displays, casino video systems',
|
||||||
|
'broadcastandtelecoms' => 'broadcast video solutions, telecom video distribution, contribution encoding, media networks, video over ip broadcast',
|
||||||
|
'education' => 'campus iptv, education video distribution, lecture capture, student digital signage, university video systems',
|
||||||
|
'corporation' => 'corporate iptv, enterprise video distribution, workplace digital signage, internal communications video',
|
||||||
|
'industrial' => 'industrial video monitoring, mining control rooms, manufacturing video systems, logistics monitoring',
|
||||||
|
'criticalnationalinfrastructure' => 'critical infrastructure video, utility control rooms, energy grid monitoring, data centre video walls',
|
||||||
|
'government' => 'government video solutions, public sector iptv, traffic control rooms, agency video distribution',
|
||||||
|
'publicsafety' => 'public safety video, emergency operations centre, rtcc video wall, real time crime center technology',
|
||||||
|
'defenseandsecurity' => 'defense video solutions, military video distribution, isr video, command and control video, secure video systems',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var string[] */
|
||||||
|
private array $report = [];
|
||||||
|
/** @var array<string,array<string|int,array<string,mixed>>> */
|
||||||
|
private array $datamap = [];
|
||||||
|
/** @var array<string,array<int,array<string,int>>> */
|
||||||
|
private array $cmdmap = [];
|
||||||
|
/** @var array<int,string[]> */
|
||||||
|
private array $sequence = [];
|
||||||
|
/** @var array<string,int[]> */
|
||||||
|
private array $slotCursor = [];
|
||||||
|
private int $newCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{file:string,market:string,pageUid:int,elements:int,applied:bool,report:string[]}
|
||||||
|
*/
|
||||||
|
public function importFile(string $file, bool $apply): array
|
||||||
|
{
|
||||||
|
$this->report = [];
|
||||||
|
$this->datamap = [];
|
||||||
|
$this->cmdmap = [];
|
||||||
|
$this->sequence = [];
|
||||||
|
$this->slotCursor = [];
|
||||||
|
|
||||||
|
$market = trim((string)preg_replace('/\s+market page$/i', '', (string)preg_replace('/\.xlsx$/i', '', basename($file))));
|
||||||
|
if ($this->norm($market) === 'marketsheropage') {
|
||||||
|
throw new \RuntimeException('"Markets Hero Page" is the /markets main page (template 2) - own flow, skipped here.');
|
||||||
|
}
|
||||||
|
$values = $this->readContentSheet($file);
|
||||||
|
|
||||||
|
// --- resolve the hero page below /markets --------------------------------
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
$root = $connection->fetchAssociative("SELECT uid FROM pages WHERE deleted = 0 AND slug = '/markets'");
|
||||||
|
if (!$root) {
|
||||||
|
throw new \RuntimeException('Page /markets not found.');
|
||||||
|
}
|
||||||
|
$page = $this->childByTitle((int)$root['uid'], $market);
|
||||||
|
if ($page === null) {
|
||||||
|
throw new \RuntimeException('Market hero page "' . $market . '" not found below /markets - run vitec:align-market-pages first.');
|
||||||
|
}
|
||||||
|
$pageUid = (int)$page['uid'];
|
||||||
|
$pageId = (string)$pageUid;
|
||||||
|
if ((int)$page['layout'] !== self::LAYOUT) {
|
||||||
|
$this->datamap['pages'][$pageId]['layout'] = self::LAYOUT;
|
||||||
|
$this->datamap['pages'][$pageId]['backend_layout'] = 'vitec__' . self::LAYOUT;
|
||||||
|
$this->report[] = 'page: switch layout -> ' . self::LAYOUT . ' (was ' . (int)$page['layout'] . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SEO / page properties ------------------------------------------------
|
||||||
|
$seoTitle = trim((string)($values['seo title'] ?? ''));
|
||||||
|
$seoDescription = trim((string)($values['seo description'] ?? ''));
|
||||||
|
if ($seoTitle !== '') {
|
||||||
|
$this->setFields('pages', $pageId, [
|
||||||
|
'seo_title' => $seoTitle, 'og_title' => $seoTitle, 'twitter_title' => $seoTitle,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ($seoDescription !== '') {
|
||||||
|
$this->setFields('pages', $pageId, [
|
||||||
|
'description' => $seoDescription, 'og_description' => $seoDescription, 'twitter_description' => $seoDescription,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$this->datamap['pages'][$pageId]['canonical_link'] = 't3://page?uid=' . $pageId;
|
||||||
|
$abstract = $this->abstractFrom((string)($values['hero intro copy'] ?? ''), $seoDescription);
|
||||||
|
if ($abstract !== '') {
|
||||||
|
$this->datamap['pages'][$pageId]['abstract'] = $abstract;
|
||||||
|
}
|
||||||
|
$keywords = self::MARKET_KEYWORDS[$this->norm($market)] ?? '';
|
||||||
|
if ($keywords !== '') {
|
||||||
|
$this->datamap['pages'][$pageId]['keywords'] = $keywords;
|
||||||
|
}
|
||||||
|
$this->report[] = 'page meta: og/twitter + canonical + abstract (' . mb_strlen($abstract) . ' chars) + keywords' . ($keywords === '' ? ' MISSING (no curated set for "' . $market . '")' : '');
|
||||||
|
|
||||||
|
// --- hero zone -------------------------------------------------------------
|
||||||
|
$heroId = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_markethero');
|
||||||
|
$this->setFields('tt_content', $heroId, [
|
||||||
|
'vitec_cta_label' => (string)($values['hero primary cta'] ?? ''),
|
||||||
|
'vitec_cta_link' => '#submarkets',
|
||||||
|
]);
|
||||||
|
$this->attachPlaceholder($heroId, 'vitec_bgimage', $apply, $pageUid, 1);
|
||||||
|
|
||||||
|
$heroIntroId = $this->slotNext($pageUid, self::COLPOS_HERO, 'vitec_introparagraph');
|
||||||
|
$this->setFields('tt_content', $heroIntroId, [
|
||||||
|
'header' => (string)($values['hero h1'] ?? ''),
|
||||||
|
'bodytext' => $this->p((string)($values['hero intro copy'] ?? '')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- main: market intro -----------------------------------------------------
|
||||||
|
$introId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_marketintro');
|
||||||
|
$this->setFields('tt_content', $introId, [
|
||||||
|
'vitec_intro_headline' => (string)($values['market intro h2'] ?? ''),
|
||||||
|
'vitec_intro_text' => $this->p((string)($values['market intro copy'] ?? '')),
|
||||||
|
'vitec_learnmore_label' => (string)($values['market intro secondary cta'] ?? ''),
|
||||||
|
'vitec_show_portfolio' => 0,
|
||||||
|
]);
|
||||||
|
if (trim((string)($values['market intro secondary cta'] ?? '')) !== '') {
|
||||||
|
$this->report[] = 'intro CTA "' . trim((string)$values['market intro secondary cta']) . '": set the link target editorially (contact page)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main: sub-market cards --------------------------------------------------
|
||||||
|
$subHeaderId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_introparagraph');
|
||||||
|
$this->setFields('tt_content', $subHeaderId, [
|
||||||
|
'header' => (string)($values['sub-markets h2'] ?? 'Explore ' . $market),
|
||||||
|
]);
|
||||||
|
foreach (range(1, self::CARD_ROWS) as $i) {
|
||||||
|
$title = trim((string)($values['sub-market card ' . $i . ' title'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cardId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_card');
|
||||||
|
$fields = [
|
||||||
|
'vitec_layout_variant' => 'text_only',
|
||||||
|
'header' => $title,
|
||||||
|
'bodytext' => $this->p((string)($values['sub-market card ' . $i . ' body'] ?? '')),
|
||||||
|
'vitec_primary_cta_label' => (string)($values['sub-market card ' . $i . ' cta'] ?? 'Learn more'),
|
||||||
|
];
|
||||||
|
$child = $this->childByTitle($pageUid, $title);
|
||||||
|
if ($child !== null) {
|
||||||
|
$fields['vitec_primary_cta'] = 't3://page?uid=' . (int)$child['uid'];
|
||||||
|
$this->report[] = 'sub-market card "' . $title . '" -> page #' . (int)$child['uid'];
|
||||||
|
} else {
|
||||||
|
$this->report[] = 'sub-market card "' . $title . '": child page missing - re-run after the sub-market import to set the link';
|
||||||
|
}
|
||||||
|
$this->setFields('tt_content', $cardId, $fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- main: trusted by (hidden shell) + case studies (hidden shell) -----------
|
||||||
|
$logosExisting = $this->slotElements($pageUid, self::COLPOS_MAIN, 'vitec_customerlogos') !== [];
|
||||||
|
$logosId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_customerlogos');
|
||||||
|
$fields = ['header' => (string)($values['trusted by h2'] ?? 'Trusted by')];
|
||||||
|
if (!$logosExisting) {
|
||||||
|
$fields['hidden'] = 1;
|
||||||
|
$this->report[] = 'trusted-by: created HIDDEN - pick the customer logos editorially, then unhide';
|
||||||
|
}
|
||||||
|
$this->setFields('tt_content', $logosId, $fields);
|
||||||
|
|
||||||
|
$casesExisting = $this->slotElements($pageUid, self::COLPOS_MAIN, 'vitec_usecaselist') !== [];
|
||||||
|
$casesId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_usecaselist');
|
||||||
|
$fields = ['header' => (string)($values['case studies h2'] ?? 'Case studies')];
|
||||||
|
if (!$casesExisting) {
|
||||||
|
$fields['hidden'] = 1;
|
||||||
|
$fields['pi_flexform'] = $this->flex([
|
||||||
|
'settings.usecases' => '',
|
||||||
|
'settings.showtoolbar' => '0',
|
||||||
|
]);
|
||||||
|
$this->report[] = 'case studies: created HIDDEN - pick the stories editorially, then unhide';
|
||||||
|
}
|
||||||
|
$this->setFields('tt_content', $casesId, $fields);
|
||||||
|
|
||||||
|
// --- main: prefooter -----------------------------------------------------------
|
||||||
|
$preId = $this->slotNext($pageUid, self::COLPOS_MAIN, 'vitec_ctabanner');
|
||||||
|
$this->setFields('tt_content', $preId, [
|
||||||
|
'header' => (string)($values['prefooter h2'] ?? ''),
|
||||||
|
'bodytext' => $this->p((string)($values['prefooter supporting copy'] ?? '')),
|
||||||
|
'vitec_primary_cta_label' => (string)($values['prefooter primary cta'] ?? ''),
|
||||||
|
'vitec_secondary_cta_label' => (string)($values['prefooter secondary cta'] ?? ''),
|
||||||
|
'vitec_layout_variant' => 'centered',
|
||||||
|
'vitec_background_variant' => 'orange',
|
||||||
|
]);
|
||||||
|
$this->report[] = 'prefooter CTAs: set the link targets editorially (contact / solutions)';
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'file' => basename($file),
|
||||||
|
'market' => $market,
|
||||||
|
'pageUid' => $pageUid,
|
||||||
|
'elements' => count($this->datamap['tt_content'] ?? []),
|
||||||
|
'applied' => $apply,
|
||||||
|
'report' => $this->report,
|
||||||
|
];
|
||||||
|
if (!$apply) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||||
|
$dataHandler->start($this->datamap, $this->cmdmap);
|
||||||
|
$dataHandler->process_datamap();
|
||||||
|
if ($this->cmdmap !== []) {
|
||||||
|
$dataHandler->process_cmdmap();
|
||||||
|
}
|
||||||
|
if ($dataHandler->errorLog !== []) {
|
||||||
|
throw new \RuntimeException('DataHandler: ' . implode(' | ', $dataHandler->errorLog));
|
||||||
|
}
|
||||||
|
$this->applySequence($dataHandler->substNEWwithIDs);
|
||||||
|
$result['report'] = $this->report;
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- workbook reading --------------------------------------------------------
|
||||||
|
|
||||||
|
/** @return array<string,mixed> lower-cased column-A label => column-B value, first hit wins */
|
||||||
|
private function readContentSheet(string $file): array
|
||||||
|
{
|
||||||
|
$spreadsheet = IOFactory::load($file);
|
||||||
|
$content = $spreadsheet->getSheetByName('Content');
|
||||||
|
if ($content === null) {
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
throw new \RuntimeException('No "Content" sheet - not a market hero workbook?');
|
||||||
|
}
|
||||||
|
$values = [];
|
||||||
|
$highestRow = min(200, (int)$content->getHighestRow());
|
||||||
|
for ($rowNum = 1; $rowNum <= $highestRow; $rowNum++) {
|
||||||
|
$label = mb_strtolower(trim((string)$content->getCell('A' . $rowNum)->getValue()));
|
||||||
|
$label = rtrim($label, ' *');
|
||||||
|
if ($label === '' || str_starts_with($label, 'section ')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!array_key_exists($label, $values)) {
|
||||||
|
$values[$label] = $content->getCell('B' . $rowNum)->getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function p(string $text): string
|
||||||
|
{
|
||||||
|
$text = trim($text);
|
||||||
|
return $text === '' ? '' : '<p>' . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{uid:int,layout:int}|null */
|
||||||
|
private function childByTitle(int $parentUid, string $title): ?array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('pages');
|
||||||
|
foreach ($connection->fetchAllAssociative(
|
||||||
|
'SELECT uid, title, layout FROM pages WHERE deleted = 0 AND pid = ? AND sys_language_uid IN (0,-1)', [$parentUid]
|
||||||
|
) as $row) {
|
||||||
|
if ($this->norm((string)$row['title']) === $this->norm($title)) {
|
||||||
|
return ['uid' => (int)$row['uid'], 'layout' => (int)$row['layout']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- element plumbing (pattern of the sibling importers) -----------------------
|
||||||
|
|
||||||
|
private function slotNext(int $pageUid, int $colPos, string $ctype): string
|
||||||
|
{
|
||||||
|
$key = $colPos . '/' . $ctype;
|
||||||
|
if (!isset($this->slotCursor[$key])) {
|
||||||
|
$this->slotCursor[$key] = $this->slotElements($pageUid, $colPos, $ctype);
|
||||||
|
}
|
||||||
|
$uid = array_shift($this->slotCursor[$key]);
|
||||||
|
if ($uid !== null) {
|
||||||
|
$this->sequence[$colPos][] = (string)$uid;
|
||||||
|
return (string)$uid;
|
||||||
|
}
|
||||||
|
$id = $this->newId();
|
||||||
|
$this->datamap['tt_content'][$id] = [
|
||||||
|
'pid' => $pageUid,
|
||||||
|
'colPos' => $colPos,
|
||||||
|
'CType' => $ctype,
|
||||||
|
];
|
||||||
|
$this->sequence[$colPos][] = $id;
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] uids in sorting order */
|
||||||
|
private function slotElements(int $pageUid, int $colPos, string $ctype): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||||
|
return array_map('intval', $connection->fetchFirstColumn(
|
||||||
|
'SELECT uid FROM tt_content WHERE deleted = 0 AND pid = ? AND colPos = ? AND CType = ? ORDER BY sorting',
|
||||||
|
[$pageUid, $colPos, $ctype]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes tt_content.sorting in page order (DataHandler puts NEW records
|
||||||
|
* with a positive pid at the TOP of their column - lesson 2026-10-07).
|
||||||
|
*
|
||||||
|
* @param array<string,int> $substNEWwithIDs
|
||||||
|
*/
|
||||||
|
private function applySequence(array $substNEWwithIDs): void
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('tt_content');
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $fields */
|
||||||
|
private function setFields(string $table, string $id, array $fields): void
|
||||||
|
{
|
||||||
|
foreach ($fields as $field => $value) {
|
||||||
|
$this->datamap[$table][$id][$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grey numbered stand-in, same rules as the sibling importers. */
|
||||||
|
private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int $pid, int $slot): void
|
||||||
|
{
|
||||||
|
if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||||
|
$identifier = null;
|
||||||
|
if ($slot > 0) {
|
||||||
|
$candidate = 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png';
|
||||||
|
if ($storage->hasFile($candidate)) {
|
||||||
|
$identifier = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$identifier ??= 'placeholders/' . ($fieldName === 'vitec_bgimage' ? 'placeholder-grey-wide.png' : 'placeholder-grey.png');
|
||||||
|
if (!$storage->hasFile($identifier)) {
|
||||||
|
$this->report[] = $fieldName . ': empty in the workbook and fileadmin/' . $identifier . ' is missing - left empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$apply) {
|
||||||
|
$this->report[] = $fieldName . ': empty in the workbook - would attach ' . $identifier;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($identifier), $pid) !== 'unchanged') {
|
||||||
|
$this->report[] = $fieldName . ': placeholder ' . basename($identifier) . ' attached - swap in the real asset later';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content-idempotent FAL attach (lesson 2026-10-02), as in the siblings. */
|
||||||
|
private function attachFalFile(string $elementId, string $fieldName, \TYPO3\CMS\Core\Resource\File $file, int|string $pid): string
|
||||||
|
{
|
||||||
|
if (!str_starts_with($elementId, 'NEW')) {
|
||||||
|
$existingRefs = $this->liveReferences((int)$elementId, $fieldName);
|
||||||
|
if ($existingRefs !== []) {
|
||||||
|
$first = array_shift($existingRefs);
|
||||||
|
$state = 'unchanged';
|
||||||
|
if ((int)$first['uid_local'] !== $file->getUid()) {
|
||||||
|
$this->datamap['sys_file_reference'][(int)$first['uid']] = ['uid_local' => $file->getUid()];
|
||||||
|
$state = 'swapped';
|
||||||
|
}
|
||||||
|
foreach ($existingRefs as $surplus) {
|
||||||
|
$this->cmdmap['sys_file_reference'][(int)$surplus['uid']]['delete'] = 1;
|
||||||
|
$this->report[] = $fieldName . ': removed duplicate reference #' . $surplus['uid'];
|
||||||
|
}
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$refId = $this->newId();
|
||||||
|
// pid, tablenames and uid_foreign are REQUIRED - without them
|
||||||
|
// DataHandler silently rejects the reference (lesson 2026-10-02).
|
||||||
|
$this->datamap['sys_file_reference'][$refId] = [
|
||||||
|
'pid' => $pid,
|
||||||
|
'table_local' => 'sys_file',
|
||||||
|
'uid_local' => $file->getUid(),
|
||||||
|
'tablenames' => 'tt_content',
|
||||||
|
'fieldname' => $fieldName,
|
||||||
|
'uid_foreign' => str_starts_with($elementId, 'NEW') ? $elementId : (int)$elementId,
|
||||||
|
];
|
||||||
|
$this->datamap['tt_content'][$elementId][$fieldName] = $refId;
|
||||||
|
return 'attached';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array{uid:int,uid_local:int}> live refs on one field, oldest first */
|
||||||
|
private function liveReferences(int $elementUid, string $fieldName): array
|
||||||
|
{
|
||||||
|
$connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_file_reference');
|
||||||
|
return array_map(
|
||||||
|
static fn(array $r): array => ['uid' => (int)$r['uid'], 'uid_local' => (int)$r['uid_local']],
|
||||||
|
$connection->fetchAllAssociative(
|
||||||
|
"SELECT uid, uid_local FROM sys_file_reference
|
||||||
|
WHERE deleted = 0 AND hidden = 0 AND tablenames = 'tt_content'
|
||||||
|
AND fieldname = ? AND uid_foreign = ? ORDER BY uid",
|
||||||
|
[$fieldName, $elementUid]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abstract from the hero intro copy, word-boundary cut near 300 chars. */
|
||||||
|
private function abstractFrom(string $introHtml, string $fallback): string
|
||||||
|
{
|
||||||
|
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($introHtml), ENT_QUOTES | ENT_HTML5)));
|
||||||
|
if ($plain === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
if (mb_strlen($plain) <= 300) {
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
$cut = mb_substr($plain, 0, 300);
|
||||||
|
$pos = mb_strrpos($cut, ' ');
|
||||||
|
return rtrim(mb_substr($cut, 0, $pos ?: 300), ' ,;:.') . "\u{2026}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $settings
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function flex(array $settings): array
|
||||||
|
{
|
||||||
|
$fields = [];
|
||||||
|
foreach ($settings as $key => $value) {
|
||||||
|
$fields[$key] = ['vDEF' => $value];
|
||||||
|
}
|
||||||
|
return ['data' => ['sDEF' => ['lDEF' => $fields]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newId(): string
|
||||||
|
{
|
||||||
|
return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '&' -> 'and' so "Broadcast & Telecoms" matches "Broadcast and Telecoms". */
|
||||||
|
private function norm(string $value): string
|
||||||
|
{
|
||||||
|
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace(['+', '&'], ['plus', 'and'], mb_strtolower($value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,14 +122,29 @@ final class SubmarketWorkbookImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- SEO on the page --------------------------------------------------
|
// --- SEO on the page --------------------------------------------------
|
||||||
|
// Full page-properties set (parity with the sub-solution importer,
|
||||||
|
// Olli 2026-10-19/23): OG/Twitter mirror the SEO fields, canonical
|
||||||
|
// points at the page itself, the abstract derives from the intro copy.
|
||||||
$seoTitle = trim((string)($values['seo_title'] ?? ''));
|
$seoTitle = trim((string)($values['seo_title'] ?? ''));
|
||||||
$seoDescription = trim((string)($values['seo_description'] ?? ''));
|
$seoDescription = trim((string)($values['seo_description'] ?? ''));
|
||||||
if ($seoTitle !== '') {
|
if ($seoTitle !== '') {
|
||||||
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
|
$this->datamap['pages'][$pageId]['seo_title'] = $seoTitle;
|
||||||
|
$this->datamap['pages'][$pageId]['og_title'] = $seoTitle;
|
||||||
|
$this->datamap['pages'][$pageId]['twitter_title'] = $seoTitle;
|
||||||
}
|
}
|
||||||
if ($seoDescription !== '') {
|
if ($seoDescription !== '') {
|
||||||
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
|
$this->datamap['pages'][$pageId]['description'] = $seoDescription;
|
||||||
|
$this->datamap['pages'][$pageId]['og_description'] = $seoDescription;
|
||||||
|
$this->datamap['pages'][$pageId]['twitter_description'] = $seoDescription;
|
||||||
}
|
}
|
||||||
|
if (!str_starts_with($pageId, 'NEW')) {
|
||||||
|
$this->datamap['pages'][$pageId]['canonical_link'] = 't3://page?uid=' . $pageId;
|
||||||
|
}
|
||||||
|
$abstract = $this->abstractFrom((string)($values['intro_text'] ?? ''), $seoDescription);
|
||||||
|
if ($abstract !== '') {
|
||||||
|
$this->datamap['pages'][$pageId]['abstract'] = $abstract;
|
||||||
|
}
|
||||||
|
$this->report[] = 'page meta: og/twitter + canonical + abstract (' . mb_strlen($abstract) . ' chars); keywords stay editorial for now';
|
||||||
|
|
||||||
// --- slot 110: hero ---------------------------------------------------
|
// --- slot 110: hero ---------------------------------------------------
|
||||||
$heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero');
|
$heroId = $this->slotElement($pageId, $subPage, self::COLPOS_HERO, 'vitec_markethero');
|
||||||
@@ -657,10 +672,33 @@ final class SubmarketWorkbookImporter
|
|||||||
*/
|
*/
|
||||||
private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int|string $pid, int $slot = 0): void
|
private function attachPlaceholder(string $elementId, string $fieldName, bool $apply, int|string $pid, int $slot = 0): void
|
||||||
{
|
{
|
||||||
if (!str_starts_with($elementId, 'NEW') && $this->liveReferences((int)$elementId, $fieldName) !== []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
$storage = GeneralUtility::makeInstance(StorageRepository::class)->getDefaultStorage();
|
||||||
|
// A page CREATED by the previous run got the generic grey (its uid was
|
||||||
|
// still NEW); when the numbered file exists by now, UPGRADE the
|
||||||
|
// reference on re-run. Any other existing reference stays untouched.
|
||||||
|
if (!str_starts_with($elementId, 'NEW')) {
|
||||||
|
$existing = $this->liveReferences((int)$elementId, $fieldName);
|
||||||
|
if ($existing !== []) {
|
||||||
|
$numbered = ($slot > 0 && !str_starts_with((string)$pid, 'NEW'))
|
||||||
|
? 'placeholders/pages/' . $pid . '-' . str_pad((string)$slot, 2, '0', STR_PAD_LEFT) . '.png'
|
||||||
|
: null;
|
||||||
|
$currentIdentifier = (string)GeneralUtility::makeInstance(ConnectionPool::class)
|
||||||
|
->getConnectionForTable('sys_file')
|
||||||
|
->fetchOne('SELECT identifier FROM sys_file WHERE uid = ?', [(int)$existing[0]['uid_local']]);
|
||||||
|
$isGeneric = str_contains($currentIdentifier, '/placeholders/placeholder-grey');
|
||||||
|
if ($numbered === null || !$isGeneric || !$storage->hasFile($numbered)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$apply) {
|
||||||
|
$this->report[] = $fieldName . ': would upgrade the generic placeholder to ' . $numbered;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->attachFalFile($elementId, $fieldName, $storage->getFile($numbered), $pid) === 'swapped') {
|
||||||
|
$this->report[] = $fieldName . ': generic placeholder upgraded to ' . basename($numbered);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Numbered per page and slot - "#501-02" is rendered INTO the image -
|
// Numbered per page and slot - "#501-02" is rendered INTO the image -
|
||||||
// so a review can name the exact asset to deliver. The generic grey
|
// so a review can name the exact asset to deliver. The generic grey
|
||||||
// file covers pages without a pre-rendered set (e.g. brand-new ones).
|
// file covers pages without a pre-rendered set (e.g. brand-new ones).
|
||||||
@@ -720,6 +758,24 @@ final class SubmarketWorkbookImporter
|
|||||||
return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
|
return 'NEW' . str_pad((string)(++$this->newCounter), 6, '0', STR_PAD_LEFT) . substr(md5(uniqid('', true)), 0, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page abstract from the intro copy: tags stripped, whitespace collapsed,
|
||||||
|
* word-boundary cut near 300 characters; SEO description as fallback.
|
||||||
|
*/
|
||||||
|
private function abstractFrom(string $introHtml, string $fallback): string
|
||||||
|
{
|
||||||
|
$plain = trim((string)preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($introHtml), ENT_QUOTES | ENT_HTML5)));
|
||||||
|
if ($plain === '') {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
if (mb_strlen($plain) <= 300) {
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
$cut = mb_substr($plain, 0, 300);
|
||||||
|
$pos = mb_strrpos($cut, ' ');
|
||||||
|
return rtrim(mb_substr($cut, 0, $pos ?: 300), ' ,;:.') . "\u{2026}";
|
||||||
|
}
|
||||||
|
|
||||||
private function norm(string $value): string
|
private function norm(string $value): string
|
||||||
{
|
{
|
||||||
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
return (string)preg_replace('/[^a-z0-9]+/', '', str_replace('+', 'plus', mb_strtolower($value)));
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ final class BackendLayoutDataProvider implements DataProviderInterface
|
|||||||
// Solution group hero pages (Joe's Template 06, import 2026-10-23) -
|
// Solution group hero pages (Joe's Template 06, import 2026-10-23) -
|
||||||
// same hero/main zones as layout 5, so the front end reuses parts.
|
// same hero/main zones as layout 5, so the front end reuses parts.
|
||||||
22 => ['title' => 'Solution Group Page', 'zones' => ['hero', 'main']],
|
22 => ['title' => 'Solution Group Page', 'zones' => ['hero', 'main']],
|
||||||
|
// Market hero pages (Joe's "Markets hero sub page", import 2026-10-23).
|
||||||
|
23 => ['title' => 'Market Hero Page', 'zones' => ['hero', 'main']],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Display name for each zone. Shown as column header in the BE page module. */
|
/** Display name for each zone. Shown as column header in the BE page module. */
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ defined('TYPO3') or die();
|
|||||||
['label' => $l . 'layout.20', 'value' => '20'],
|
['label' => $l . 'layout.20', 'value' => '20'],
|
||||||
['label' => $l . 'layout.21', 'value' => '21'],
|
['label' => $l . 'layout.21', 'value' => '21'],
|
||||||
['label' => $l . 'layout.22', 'value' => '22'],
|
['label' => $l . 'layout.22', 'value' => '22'],
|
||||||
|
['label' => $l . 'layout.23', 'value' => '23'],
|
||||||
];
|
];
|
||||||
$GLOBALS['TCA']['pages']['columns']['layout']['config']['default'] = '0';
|
$GLOBALS['TCA']['pages']['columns']['layout']['config']['default'] = '0';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
| **Document identifier** | EVO‑VITEC‑HL‑001 |
|
||||||
| **Version** | 1.24 |
|
| **Version** | 1.25 |
|
||||||
| **Status** | Released |
|
| **Status** | Released |
|
||||||
| **Date** | 2026‑10‑23 |
|
| **Date** | 2026‑10‑23 |
|
||||||
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
| **Applies to** | `evomedien/vitec` on TYPO3 v14.3 (headless) |
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
| 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). |
|
| 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). |
|
||||||
| 1.23 | 2026‑10‑09 | **New interface (additive).** Product type-ahead for the megamenu catalog panel: lean page type `?type=7385&q=` (7.16) returning `suggestions` and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout `catalog`, filled by the VITEC listener `ProvideProductCatalog` (main categories → subcategory groups → products with one-line teasers). |
|
| 1.23 | 2026‑10‑09 | **New interface (additive).** Product type-ahead for the megamenu catalog panel: lean page type `?type=7385&q=` (7.16) returning `suggestions` and up to six product cards via Solr, last term completed as a prefix. The megamenu gained the panel layout `catalog`, filled by the VITEC listener `ProvideProductCatalog` (main categories → subcategory groups → products with one-line teasers). |
|
||||||
| 1.24 | 2026‑10‑23 | **Interface change (additive).** New frontend layout **22 Solution Group Page** for the eight solution group hero pages (zones hero = `colPos1`, main = `colPos0`). Element order: `vitec_markethero` + `vitec_introparagraph` in the hero zone; `vitec_marketintro` 2, `vitec_introparagraph` + 4 `text_only` cards (challenges), `vitec_introparagraph` + 4 alternating `horizontal`/`horizontal_reverse` cards (capabilities, bullets as `<ul>`), `vitec_usecaselist` (testimonials, shipped hidden until stories are picked), `vitec_marketinsights`, `vitec_ctabanner` in main — imported from the agency Template‑06 workbooks by `vitec:import-solution-group-workbooks`; page properties as on the sub‑solution pages. |
|
| 1.24 | 2026‑10‑23 | **Interface change (additive).** New frontend layout **22 Solution Group Page** for the eight solution group hero pages (zones hero = `colPos1`, main = `colPos0`). Element order: `vitec_markethero` + `vitec_introparagraph` in the hero zone; `vitec_marketintro` 2, `vitec_introparagraph` + 4 `text_only` cards (challenges), `vitec_introparagraph` + 4 alternating `horizontal`/`horizontal_reverse` cards (capabilities, bullets as `<ul>`), `vitec_usecaselist` (testimonials, shipped hidden until stories are picked), `vitec_marketinsights`, `vitec_ctabanner` in main — imported from the agency Template‑06 workbooks by `vitec:import-solution-group-workbooks`; page properties as on the sub‑solution pages. |
|
||||||
|
| 1.25 | 2026‑10‑23 | **Interface change (additive).** The markets branch is aligned with the agreed agency structure: `/markets` eleven market hero pages their sub‑market pages (the use‑case level stays sections per 7.17). New frontend layout **23 Market Hero Page** (zones hero = `colPos1`, main = `colPos0`): `vitec_markethero` (CTA anchors `#submarkets`) + `vitec_introparagraph` (header carries the hero H1) in the hero zone; `vitec_marketintro`, `vitec_introparagraph`, sub‑market `vitec_card`s whose `primary_cta` links the child page, `vitec_ctabanner` in main (`vitec_customerlogos` and `vitec_usecaselist` ship hidden until filled editorially). Imported by `vitec:import-market-hero-workbooks`; the tree itself by `vitec:align-market-pages` (renames with slug redirects, child slugs normalised — this also repairs embedded‑slash slugs). Sub‑market pages gained the full page‑properties set. |
|
||||||
|
|
||||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||||
@@ -1613,4 +1614,4 @@ remediation.
|
|||||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||||
|
|
||||||
*End of document EVO‑VITEC‑HL‑001 v1.24.*
|
*End of document EVO‑VITEC‑HL‑001 v1.25.*
|
||||||
@@ -72,6 +72,9 @@
|
|||||||
<trans-unit id="layout.22" resname="layout.22">
|
<trans-unit id="layout.22" resname="layout.22">
|
||||||
<source>Solution Group Page</source>
|
<source>Solution Group Page</source>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="layout.23" resname="layout.23">
|
||||||
|
<source>Market Hero Page</source>
|
||||||
|
</trans-unit>
|
||||||
</body>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
Reference in new issue
Block a user