diff --git a/migrations/fe_answers_fixes_20261111.php b/migrations/fe_answers_fixes_20261111.php new file mode 100644 index 0000000..a46ef3d --- /dev/null +++ b/migrations/fe_answers_fixes_20261111.php @@ -0,0 +1,113 @@ + hidden. + * 2. Home: #757 vitec_featuredcontent "Featured Product" carries lorem ipsum + * and links the empty /product shell -> hidden (placeholder). + * 3. Home: #702 vitec_card "Government" linked /markets/defense-and-security + * -> /markets/government (where the footer sends "Government"). + * 4. Home: #699 text, button "Learn more" -> "Explore our solutions". + * 5. Story CDC:

Product Highlight

under an h2 ->

. + * + * Dry run: php migrations/fe_answers_fixes_20261111.php + * Write: php migrations/fe_answers_fixes_20261111.php --apply + */ + +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +$apply = in_array('--apply', $argv, true); + +$root = null; +$dir = __DIR__; +for ($i = 0; $i < 5; $i++) { + if (is_file($dir . '/config/system/settings.php')) { $root = $dir; break; } + $dir = dirname($dir); +} +if ($root === null) { exit("no project root\n"); } +$settings = require $root . '/config/system/settings.php'; +$db = $settings['DB']['Connections']['Default']; +$pdo = new PDO( + sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''), + $db['user'] ?? '', $db['password'] ?? '', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] +); + +echo $apply ? "== APPLY ==\n" : "== DRY RUN (add --apply to write) ==\n"; +$now = time(); +$set = static function (string $table, int $uid, string $field, string $value) use ($pdo, $apply, $now): void { + if ($apply) { $pdo->prepare("UPDATE $table SET $field = ?, tstamp = ? WHERE uid = ?")->execute([$value, $now, $uid]); } +}; +$ce = static function (int $uid) use ($pdo): ?array { + $st = $pdo->prepare("SELECT uid, pid, CType, header, hidden, bodytext, vitec_primary_cta, vitec_cta FROM tt_content WHERE deleted = 0 AND uid = ?"); + $st->execute([$uid]); + $r = $st->fetch(); + return $r ?: null; +}; + +echo "\n1./2. Platzhalter-Elemente verstecken\n"; +foreach ([1 => 'H1 Header', 757 => 'Featured Product'] as $uid => $expect) { + $c = $ce($uid); + if (!$c) { echo " #$uid fehlt\n"; continue; } + if ($c['header'] !== $expect) { echo " #$uid hat Header '{$c['header']}' (erwartet '$expect') - uebersprungen\n"; continue; } + if ((int)$c['hidden'] === 1) { echo " #$uid schon versteckt\n"; continue; } + echo " #$uid {$c['CType']} \"{$c['header']}\" auf Seite {$c['pid']}: hidden = 1\n"; + $set('tt_content', $uid, 'hidden', '1'); +} + +echo "\n3. Karte 'Government' (#702)\n"; +$c = $ce(702); +if ($c && $c['vitec_primary_cta'] === 't3://page?uid=37') { + echo " #702 CTA t3://page?uid=37 (/markets/defense-and-security) -> t3://page?uid=526 (/markets/government)\n"; + $set('tt_content', 702, 'vitec_primary_cta', 't3://page?uid=526'); +} else { + echo " #702: CTA ist '" . ($c['vitec_primary_cta'] ?? '?') . "' - uebersprungen\n"; +} + +echo "\n4. Text #699: Button-Text\n"; +$c = $ce(699); +if ($c && str_contains((string)$c['bodytext'], '>Learn more')) { + echo " #699: 'Learn more' -> 'Explore our solutions'\n"; + $set('tt_content', 699, 'bodytext', str_replace('>Learn more', '>Explore our solutions', (string)$c['bodytext'])); +} else { + echo " #699: kein 'Learn more'-Button gefunden - uebersprungen\n"; +} + +echo "\n5. Story CDC: h4 'Product Highlight' -> h3\n"; +$pattern = '~]*)?>(\s*Product Highlight\s*)

~i'; +$n = 0; +foreach ($pdo->query("SELECT c.uid, u.slug, c.bodytext FROM tt_content c LEFT JOIN tx_vitec_domain_model_usecase u ON u.uid = c.tx_vitec_usecase_content WHERE c.deleted = 0 AND c.bodytext LIKE '%Product Highlight%'")->fetchAll() as $r) { + $new = (string)preg_replace($pattern, '$2', (string)$r['bodytext'], -1, $count); + if ($count === 0) { continue; } + $n++; + echo " tt_content #{$r['uid']} (Story " . ($r['slug'] ?? '-') . "): $count x h4 -> h3\n"; + $set('tt_content', (int)$r['uid'], 'bodytext', $new); +} +// The story text may also live on the record itself. +$cols = $pdo->query("SHOW COLUMNS FROM tx_vitec_domain_model_usecase")->fetchAll(PDO::FETCH_COLUMN); +foreach ($cols as $col) { + if (!in_array($col, ['description', 'teaser', 'subtitle'], true)) { continue; } + foreach ($pdo->query("SELECT uid, slug, $col AS v FROM tx_vitec_domain_model_usecase WHERE deleted = 0 AND $col LIKE '%Product Highlight%'")->fetchAll() as $r) { + $new = (string)preg_replace($pattern, '$2', (string)$r['v'], -1, $count); + if ($count === 0) { continue; } + $n++; + echo " usecase #{$r['uid']} ({$r['slug']}) Feld $col: $count x h4 -> h3\n"; + $set('tx_vitec_domain_model_usecase', (int)$r['uid'], $col, $new); + } +} +// Story sections live in the content-block collection table vitec_items. +foreach ($pdo->query("SELECT uid, text FROM vitec_items WHERE deleted = 0 AND text LIKE '%Product Highlight%'")->fetchAll() as $r) { + $new = (string)preg_replace($pattern, '$2', (string)$r['text'], -1, $count); + if ($count === 0) { continue; } + $n++; + echo " vitec_items #{$r['uid']}: $count x h4 -> h3\n"; + $set('vitec_items', (int)$r['uid'], 'text', $new); +} +if ($n === 0) { echo " nichts gefunden (Text liegt woanders)\n"; } + +echo "\nSSR-Pfade: / /solutions /success-stories/centers-for-disease-control-cdc\n"; +echo $apply ? "FERTIG.\n" : "(Trockenlauf, nichts geschrieben)\n"; diff --git a/packages/vitec/Classes/Seo/VitecMetaHandler.php b/packages/vitec/Classes/Seo/VitecMetaHandler.php index 14da3ef..e6a0b7f 100644 --- a/packages/vitec/Classes/Seo/VitecMetaHandler.php +++ b/packages/vitec/Classes/Seo/VitecMetaHandler.php @@ -266,6 +266,32 @@ class VitecMetaHandler extends MetaHandler $content['seo']['link'] = $links; } + // --- paginated / filtered list pages: self-referencing canonical -------- + // /insights/news?page=2 pointed at page 1, which marks every further + // page as a duplicate and weakens the links to its articles (0100-FE). + if ($canonicalPath === '' && $newsUid === 0 && $productArg === '' && $usecaseUid === 0) { + $query = $request->getQueryParams(); + $keep = []; + foreach (['category', 'page'] as $key) { + $value = $query[$key] ?? null; + if (!is_scalar($value) || trim((string)$value) === '') { + continue; + } + if ($key === 'page' && (int)$value <= 1) { + continue; + } + $keep[$key] = trim((string)$value); + } + if ($keep !== [] && is_array($content['seo']['link'] ?? null)) { + foreach ($content['seo']['link'] as $index => $link) { + if (($link['rel'] ?? '') === 'canonical' && is_string($link['href'] ?? null)) { + $content['seo']['link'][$index]['href'] = + strtok($link['href'], '?') . '?' . http_build_query($keep); + } + } + } + } + return $content; } diff --git a/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php b/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php index 433ec79..966bb06 100755 --- a/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php +++ b/packages/vitec/Classes/UserFunc/PageJsonLdRenderer.php @@ -78,8 +78,17 @@ final class PageJsonLdRenderer $nodes[] = $service->buildWebSite($base, $siteName, $hasOrganization); } - // BreadcrumbList from the rootline. - $breadcrumb = $service->buildBreadcrumbList($this->breadcrumbItems($rootLine), $base); + // BreadcrumbList from the rootline. On record detail pages the last + // crumb is the generic shell page ("Story", "Product", "Article") + // and is swapped for the record itself (0108-FE, F11). + $args = $this->routeArguments($request); + $crumbs = $this->breadcrumbItems($rootLine); + $recordCrumb = $this->recordCrumb($args); + if ($recordCrumb !== null && $crumbs !== []) { + array_pop($crumbs); + $crumbs[] = $recordCrumb; + } + $breadcrumb = $service->buildBreadcrumbList($crumbs, $base); if ($breadcrumb !== null) { $nodes[] = $breadcrumb; } @@ -113,7 +122,6 @@ final class PageJsonLdRenderer // Product on product detail pages (routed record, SEO audit 2026-11-09). - $args = $this->routeArguments($request); $productArg = (string)($args['tx_vitec_productshow']['product'] ?? ''); if ($productArg !== '') { $productRow = $this->fetchDetailRecord( @@ -220,6 +228,40 @@ final class PageJsonLdRenderer ]; } + /** + * Breadcrumb item for the routed detail record (product, news, story), or + * null on plain pages. + * + * @param array $args + * @return array{name:string, url:string}|null + */ + private function recordCrumb(array $args): ?array + { + $productArg = (string)($args['tx_vitec_productshow']['product'] ?? ''); + if ($productArg !== '') { + $row = $this->fetchDetailRecord('tx_vitec_domain_model_product', is_numeric($productArg) ? 'uid' : 'slug', $productArg); + if ($row !== null && (string)($row['slug'] ?? '') !== '') { + return ['name' => (string)($row['title'] ?? ''), 'url' => '/product/' . $row['slug']]; + } + } + $newsUid = (int)($args['tx_news_pi1']['news'] ?? 0); + if ($newsUid > 0) { + $row = $this->fetchDetailRecord('tx_news_domain_model_news', 'uid', (string)$newsUid); + if ($row !== null && (string)($row['path_segment'] ?? '') !== '') { + $name = trim((string)($row['alternative_title'] ?? '')) ?: (string)($row['title'] ?? ''); + return ['name' => $name, 'url' => '/news/article/' . $row['path_segment']]; + } + } + $usecaseUid = (int)($args['tx_vitec_usecaseshow']['usecase'] ?? 0); + if ($usecaseUid > 0) { + $row = $this->fetchDetailRecord('tx_vitec_domain_model_usecase', 'uid', (string)$usecaseUid); + if ($row !== null && (string)($row['slug'] ?? '') !== '') { + return ['name' => (string)($row['title'] ?? ''), 'url' => '/success-stories/' . $row['slug']]; + } + } + return null; + } + /** * Build ordered breadcrumb items (root → current) from the rootline. * Skips spacers, folders and recyclers; uses nav_title with title fallback. diff --git a/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php b/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php index ff4dce2..b328557 100755 --- a/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php +++ b/packages/vitec/Classes/UserFunc/UsecaseListJsonRenderer.php @@ -178,6 +178,11 @@ class UsecaseListJsonRenderer $usecases = array_map( static function (array $u) use ($serializer, $detailBase): array { $item = $serializer->serializeListItem($u); + // The list cards read url/srcset/alternative only; the card + // crop variants cost about 1 kB per story (0102-FE, F8). + if (is_array($item['cardImage'] ?? null)) { + unset($item['cardImage']['crops']); + } // A story with its own detail page wins; only the others // fall back to the Single PID plus the slug, so nothing // changes for the stories that have no page assigned.