Markenseiten Rotpunkt und Febrü aus der Zulieferung vom 31.08.

Umsetzung der beiden finalen Webseitentexte aus dev/2026-08-28/.
Die angekündigte Aufgabenliste umsetzung-03-09-2026.md war leer, Grundlage
sind daher die Umsetzungshinweise in den PDFs selbst.

- Neue Felder in cms_brands: sections, widget_label, cta_title, cta_text.
  Einleitung und Leistungskacheln reichten für diese Texte nicht aus.
- Die als Vertiefung gelieferten Blöcke werden aufklappbar dargestellt,
  mit aria-expanded und aria-controls. Bewusst x-transition statt
  x-collapse, weil das Collapse-Plugin hier nicht registriert ist.
- Kein zweiter CTA-Button: das schwebende Terminanfrage-Widget trägt auf
  der Markenseite die Beschriftung der Marke und liest den Kontext aus
  der Route, da es im Layout und nicht je Seite eingebunden ist.
- Footer bekommt unten Platz und lange Beschriftungen brechen um, damit
  das Widget auf dem Smartphone nichts verdeckt.
- Abschnitte sind im Admin vollständig pflegbar, inklusive Sortierung.
- Interne Quellenhinweise der PDFs sind nicht übernommen; ein Test prüft,
  dass sie nicht im Ausgabe-HTML stehen.
- BrandPageTextSeeder fasst nur die Textfelder an, Bilder, Galerie und
  Leistungen bleiben unangetastet.

Vor der Veröffentlichung: Herstellerfreigabe einholen und die Aussage zur
erhaltenen Waldfläche nur mit ausdrücklicher Bestätigung verwenden. Beides
steht in der Deployment-Checkliste.

854 Tests grün (3150 Assertions), Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-03 08:52:36 +00:00
parent 6d53243ea6
commit 4ef3a4d8f1
13 changed files with 1143 additions and 69 deletions

View file

@ -38,6 +38,10 @@ class CmsBrand extends Model
'logo',
'image',
'services',
'sections',
'widget_label',
'cta_title',
'cta_text',
'gallery',
'website_url',
'showroom',
@ -52,6 +56,9 @@ class CmsBrand extends Model
'teaser',
'summary',
'description',
'widget_label',
'cta_title',
'cta_text',
];
protected function casts(): array
@ -59,6 +66,7 @@ class CmsBrand extends Model
return [
'highlights' => 'array',
'services' => 'array',
'sections' => 'array',
'gallery' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
@ -124,6 +132,10 @@ class CmsBrand extends Model
'logo' => '',
'image' => '',
'services' => [],
'sections' => [],
'widget_label' => '',
'cta_title' => '',
'cta_text' => '',
'gallery' => [],
'website_url' => '',
'showroom' => '',
@ -131,6 +143,7 @@ class CmsBrand extends Model
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
$entry['sections'] = self::normalizeSections($entry['sections']);
$out[$slug] = $entry;
}
@ -138,6 +151,81 @@ class CmsBrand extends Model
return $out;
}
/**
* Seitenabschnitte in eine feste Form bringen.
*
* Jeder Abschnitt hat einen Titel und Absätze. Optional dazu:
* - `claim` hervorgehobene Schlusszeile
* - `quote` Zitat mit `quote_author`
* - `detail` Vertiefung, die im Frontend aufklappbar dargestellt wird
*
* @param mixed $sections
* @return list<array{title: string, paragraphs: list<string>, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list<string>}|null}>
*/
public static function normalizeSections($sections): array
{
if (! is_array($sections)) {
return [];
}
$out = [];
foreach ($sections as $section) {
if (! is_array($section)) {
continue;
}
$paragraphs = self::paragraphList($section['paragraphs'] ?? []);
$title = trim((string) ($section['title'] ?? ''));
if ($title === '' && $paragraphs === []) {
continue;
}
$detail = null;
if (is_array($section['detail'] ?? null)) {
$detailParagraphs = self::paragraphList($section['detail']['paragraphs'] ?? []);
$detailTitle = trim((string) ($section['detail']['title'] ?? ''));
if ($detailTitle !== '' || $detailParagraphs !== []) {
$detail = ['title' => $detailTitle, 'paragraphs' => $detailParagraphs];
}
}
$out[] = [
'title' => $title,
'paragraphs' => $paragraphs,
'claim' => trim((string) ($section['claim'] ?? '')),
'quote' => trim((string) ($section['quote'] ?? '')),
'quote_author' => trim((string) ($section['quote_author'] ?? '')),
'detail' => $detail,
];
}
return $out;
}
/**
* @param mixed $paragraphs
* @return list<string>
*/
private static function paragraphList($paragraphs): array
{
if (is_string($paragraphs)) {
$paragraphs = preg_split('/\R{2,}/', $paragraphs) ?: [];
}
if (! is_array($paragraphs)) {
return [];
}
return array_values(array_filter(array_map(
fn ($p) => trim((string) (is_array($p) ? ($p['text'] ?? '') : $p)),
$paragraphs,
)));
}
/**
* Galerie-Eintraege in eine feste Form bringen.
*
@ -216,6 +304,10 @@ class CmsBrand extends Model
'logo' => $this->logo,
'image' => $this->image,
'services' => $this->services ?? [],
'sections' => self::normalizeSections($this->sections),
'widget_label' => (string) $this->widget_label,
'cta_title' => (string) $this->cta_title,
'cta_text' => (string) $this->cta_text,
'gallery' => self::normalizeGallery($this->gallery),
'website_url' => $this->website_url,
'showroom' => $this->showroom,