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.