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

@ -25,6 +25,10 @@ state([
'logo' => '',
'image' => '',
'services' => [],
'sections' => [],
'widgetLabel' => '',
'ctaTitle' => '',
'ctaText' => '',
'gallery' => [],
'website_url' => '',
'showroom' => '',
@ -66,7 +70,8 @@ $brands = computed(
$openCreate = function () {
$this->editingId = null;
$this->reset(['slug', 'brandName', 'tagline', 'teaser', 'summary', 'description', 'logo', 'image', 'website_url', 'showroom']);
$this->reset(['slug', 'brandName', 'tagline', 'teaser', 'summary', 'description', 'logo', 'image', 'website_url', 'showroom', 'widgetLabel', 'ctaTitle', 'ctaText']);
$this->sections = [];
$this->highlights = ['', '', ''];
$this->services = [['title' => '', 'description' => '']];
$this->gallery = [];
@ -81,6 +86,9 @@ $loadLocaleFields = function (CmsBrand $brand, string $locale) {
$this->teaser = $brand->getTranslation('teaser', $locale) ?? '';
$this->summary = $brand->getTranslation('summary', $locale) ?? '';
$this->description = CmsFluxEditorHtmlTransformer::toEditor((string) ($brand->getTranslation('description', $locale) ?? ''));
$this->widgetLabel = $brand->getTranslation('widget_label', $locale) ?? '';
$this->ctaTitle = $brand->getTranslation('cta_title', $locale) ?? '';
$this->ctaText = $brand->getTranslation('cta_text', $locale) ?? '';
};
$openEdit = function (int $id) {
@ -101,6 +109,18 @@ $openEdit = function (int $id) {
$this->highlights = $brand->highlights ?: ['', '', ''];
$this->services = $brand->services ?: [['title' => '', 'description' => '']];
$this->gallery = CmsBrand::normalizeGallery($brand->gallery);
// Absätze im Formular als Textblock: eine Leerzeile trennt sie.
$this->sections = collect(CmsBrand::normalizeSections($brand->sections))
->map(fn (array $s) => [
'title' => $s['title'],
'paragraphs' => implode("\n\n", $s['paragraphs']),
'claim' => $s['claim'],
'quote' => $s['quote'],
'quote_author' => $s['quote_author'],
'detail_title' => $s['detail']['title'] ?? '',
'detail_paragraphs' => implode("\n\n", $s['detail']['paragraphs'] ?? []),
])
->all();
$this->loadLocaleFields($brand, $this->editLocale);
$this->showForm = true;
@ -132,6 +152,26 @@ $removeService = function (int $index) {
$this->services = array_values($this->services);
};
$addSection = fn () => $this->sections[] = [
'title' => '', 'paragraphs' => '', 'claim' => '',
'quote' => '', 'quote_author' => '', 'detail_title' => '', 'detail_paragraphs' => '',
];
$removeSection = function (int $index) {
unset($this->sections[$index]);
$this->sections = array_values($this->sections);
};
$moveSection = function (int $index, int $direction) {
$target = $index + $direction;
if (! isset($this->sections[$index], $this->sections[$target])) {
return;
}
[$this->sections[$index], $this->sections[$target]] = [$this->sections[$target], $this->sections[$index]];
};
$addGalleryImage = function (string $category = CmsBrand::GALLERY_PROJECT) {
$this->gallery[] = [
'image' => '',
@ -211,6 +251,23 @@ $save = function () {
$brand->gallery = CmsBrand::normalizeGallery($this->gallery);
$brand->setTranslation('widget_label', $this->editLocale, trim((string) $this->widgetLabel));
$brand->setTranslation('cta_title', $this->editLocale, trim((string) $this->ctaTitle));
$brand->setTranslation('cta_text', $this->editLocale, trim((string) $this->ctaText));
// normalizeSections() nimmt die Textblöcke entgegen und trennt sie an den Leerzeilen.
$brand->sections = CmsBrand::normalizeSections(collect($this->sections)->map(fn (array $s) => [
'title' => $s['title'] ?? '',
'paragraphs' => $s['paragraphs'] ?? '',
'claim' => $s['claim'] ?? '',
'quote' => $s['quote'] ?? '',
'quote_author' => $s['quote_author'] ?? '',
'detail' => [
'title' => $s['detail_title'] ?? '',
'paragraphs' => $s['detail_paragraphs'] ?? '',
],
])->all());
$brand->is_published = $validated['is_published'];
$brand->order = $validated['order'];
$brand->save();
@ -390,6 +447,80 @@ $cancelForm = function () {
</div>
</div>
{{-- Abschnitte der Markenseite --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">
<flux:heading size="sm">Abschnitte der Markenseite</flux:heading>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addSection">Abschnitt hinzufügen</flux:button>
</div>
<div class="mb-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/50 dark:text-zinc-300">
Die Abschnitte erscheinen auf der Detailseite unter der Einleitung, in dieser Reihenfolge.
<strong>Eine Leerzeile</strong> trennt zwei Absätze. Die <strong>Vertiefung</strong> wird auf
der Seite als aufklappbarer Bereich dargestellt – leer lassen, wenn es keine gibt.
</div>
@if (empty($sections))
<p class="text-sm text-zinc-400">Noch keine Abschnitte.</p>
@else
<div class="space-y-4">
@foreach ($sections as $i => $section)
<div wire:key="section-{{ $i }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-medium text-zinc-500">Abschnitt {{ $i + 1 }}</span>
<div class="flex items-center gap-1">
<flux:button size="xs" variant="ghost" icon="arrow-up"
wire:click="moveSection({{ $i }}, -1)" :disabled="$i === 0" />
<flux:button size="xs" variant="ghost" icon="arrow-down"
wire:click="moveSection({{ $i }}, 1)" :disabled="$i === count($sections) - 1" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeSection({{ $i }})" />
</div>
</div>
<flux:input wire:model="sections.{{ $i }}.title" label="Überschrift" class="mb-3"
placeholder="z.B. Aus Ostwestfalen. Für Ihr Zuhause." />
<flux:textarea wire:model="sections.{{ $i }}.paragraphs" label="Text" rows="5" class="mb-3"
placeholder="Absätze durch eine Leerzeile trennen..." />
<flux:input wire:model="sections.{{ $i }}.claim" label="Schlusszeile (optional)" class="mb-3"
placeholder="Wird hervorgehoben unter den Text gesetzt" />
<div class="mb-3 grid grid-cols-1 gap-3 md:grid-cols-3">
<div class="md:col-span-2">
<flux:textarea wire:model="sections.{{ $i }}.quote" label="Zitat (optional)" rows="2" />
</div>
<flux:input wire:model="sections.{{ $i }}.quote_author" label="Zitat von" placeholder="Name, Funktion" />
</div>
<div class="rounded-md border border-dashed border-zinc-300 p-3 dark:border-zinc-600">
<p class="mb-2 text-xs font-medium text-zinc-500">Vertiefung – erscheint aufklappbar</p>
<flux:input wire:model="sections.{{ $i }}.detail_title" label="Überschrift der Vertiefung" class="mb-3" />
<flux:textarea wire:model="sections.{{ $i }}.detail_paragraphs" label="Text der Vertiefung" rows="4"
placeholder="Absätze durch eine Leerzeile trennen..." />
</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Abschlussblock und Beschriftung des Terminanfrage-Widgets --}}
<div class="mt-6 space-y-4">
<flux:heading size="sm">Abschluss und Terminanfrage</flux:heading>
<flux:input wire:model="widgetLabel" label="Beschriftung des Terminanfrage-Widgets ({{ strtoupper($editLocale) }})"
placeholder="z.B. Persönliche Küchenberatung vereinbaren" />
<p class="-mt-3 text-xs text-zinc-400">
Steht auf dem schwebenden Button, solange man auf dieser Markenseite ist. Leer lassen für die
allgemeine Beschriftung.
</p>
<flux:input wire:model="ctaTitle" label="Überschrift Abschlussblock ({{ strtoupper($editLocale) }})"
placeholder="z.B. Ihre Küche. Persönlich geplant." />
<flux:textarea wire:model="ctaText" label="Text Abschlussblock ({{ strtoupper($editLocale) }})" rows="3" />
</div>
{{-- Galerie --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">

View file

@ -58,6 +58,71 @@
</section>
@endif
{{-- 2b. Abschnitte der Markenseite. Die als Vertiefung gelieferten Blöcke sind aufklappbar,
damit die Seite schnell erfassbar bleibt, ohne dass Inhalt verloren geht. --}}
@if (!empty($brand['sections']))
<section class="pb-16">
<div class="container-narrow space-y-14">
@foreach ($brand['sections'] as $index => $section)
<div wire:key="section-{{ $index }}">
@if (!empty($section['title']))
<h2 class="text-2xl lg:text-3xl font-medium">{{ $section['title'] }}</h2>
@endif
@foreach ($section['paragraphs'] as $i => $paragraph)
<p class="text-muted-foreground leading-relaxed mt-4">{{ $paragraph }}</p>
@endforeach
@if (!empty($section['quote']))
<figure class="mt-8 border-l-4 border-secondary pl-6">
<blockquote class="text-large leading-relaxed italic">„{{ $section['quote'] }}"</blockquote>
@if (!empty($section['quote_author']))
<figcaption class="text-sm text-muted-foreground mt-3">{{ $section['quote_author'] }}</figcaption>
@endif
</figure>
@endif
@if (!empty($section['detail']))
<div class="mt-6 card-elevated rounded-2xl overflow-hidden"
x-data="{ open: false }">
<button type="button" x-on:click="open = !open"
x-bind:aria-expanded="open.toString()"
aria-controls="detail-{{ $index }}"
class="flex w-full items-center justify-between gap-4 p-5 text-left cursor-pointer hover:bg-muted/30 transition-colors">
<span class="font-medium">{{ $section['detail']['title'] }}</span>
<svg class="h-5 w-5 shrink-0 text-secondary transition-transform duration-200"
x-bind:class="open ? 'rotate-180' : ''" fill="none" stroke="currentColor"
stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{{-- x-transition statt x-collapse: das Collapse-Plugin ist in diesem
Projekt nicht registriert, dieselbe Bewegung wie im FAQ-Akkordeon. --}}
<div id="detail-{{ $index }}" x-show="open" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-end="opacity-0 -translate-y-2">
<div class="px-5 pb-5 border-t border-border/60 pt-4">
@foreach ($section['detail']['paragraphs'] as $paragraph)
<p class="text-muted-foreground leading-relaxed mt-3 first:mt-0">{{ $paragraph }}</p>
@endforeach
</div>
</div>
</div>
@endif
@if (!empty($section['claim']))
<p class="text-large text-secondary font-medium mt-6">{{ $section['claim'] }}</p>
@endif
</div>
@endforeach
</div>
</section>
@endif
{{-- 3. Leistungen --}}
@if (!empty($brand['services']))
<section class="section-padding bg-accent">
@ -215,8 +280,12 @@
{{-- 5. CTA --}}
<section class="section-padding bg-secondary text-secondary-foreground">
<div class="container-narrow text-center spacing-section">
<h2 class="text-section-title text-white">{{ __('ui.brand_cta_title', ['name' => $brand['name']]) }}</h2>
<p class="text-large max-w-2xl mx-auto leading-relaxed">{{ __('ui.brand_cta_text') }}</p>
<h2 class="text-section-title text-white">
{{ $brand['cta_title'] ?: __('ui.brand_cta_title', ['name' => $brand['name']]) }}
</h2>
<p class="text-large max-w-2xl mx-auto leading-relaxed">
{{ $brand['cta_text'] ?: __('ui.brand_cta_text') }}
</p>
<div class="pt-4 flex flex-wrap items-center justify-center gap-4">
<a href="{{ route('contact') }}" class="btn-primary-accent px-8 py-4 text-lg">
@ -229,7 +298,6 @@
</a>
@endif
</div>
{{-- Der WhatsApp-Button kommt mit AP3, sobald die Nummer bestätigt ist. --}}
</div>
</section>

View file

@ -162,8 +162,9 @@
<div class="flex items-center gap-3">
<button type="button" x-on:click="open = !open" x-bind:aria-expanded="open.toString()"
aria-controls="appointment-widget-title"
class="btn-secondary-accent flex items-center gap-2 rounded-full px-5 py-3 shadow-lg cursor-pointer">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"
class="btn-secondary-accent flex items-center gap-2 rounded-full px-5 py-3 shadow-lg cursor-pointer
max-w-[calc(100vw-2rem)] text-left leading-tight">
<svg class="h-5 w-5 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />

View file

@ -1,4 +1,6 @@
<footer class="section-dark section-padding">
{{-- pb-28 auf kleinen Schirmen: darunter schwebt das Terminanfrage-Widget, das sonst die
letzten Zeilen des Footers verdecken wuerde. --}}
<footer class="section-dark section-padding pb-28 sm:pb-16">
<div class="container-padding">
{{-- Main Content --}}
<div class="text-center spacing-section">