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:
parent
6d53243ea6
commit
4ef3a4d8f1
13 changed files with 1143 additions and 69 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue