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>
155 lines
4.7 KiB
PHP
155 lines
4.7 KiB
PHP
<?php
|
||
|
||
namespace App\Livewire\Web\Components\Ui;
|
||
|
||
use Acme\ContactForm\ContactFormService;
|
||
use Acme\ContactForm\SpamDetector;
|
||
use App\Models\CmsBrand;
|
||
use Livewire\Component;
|
||
|
||
/**
|
||
* Schwebende Terminanfrage – auf allen Seiten unten rechts.
|
||
*
|
||
* Setzt wie die übrigen Formulare der Seite auf {@see ContactFormService} und {@see SpamDetector}
|
||
* auf, statt auf Acme\ContactForm\Livewire\ContactForm: dessen mitgelieferte View bringt ein
|
||
* eigenes (blaues) Layout mit, das nicht zum Auftritt passt. Honeypot, Rate-Limit und
|
||
* Spam-Erkennung kommen so trotzdem aus dem Paket.
|
||
*/
|
||
class AppointmentWidget extends Component
|
||
{
|
||
/** Feste Anliegen neben den gepflegten Marken. */
|
||
public const EXTRA_TOPICS = [
|
||
'projektausstattung' => 'Projektausstattung',
|
||
'sonstiges' => 'Sonstiges',
|
||
];
|
||
|
||
public string $name = '';
|
||
|
||
public string $email = '';
|
||
|
||
public string $phone = '';
|
||
|
||
public string $topic = '';
|
||
|
||
public string $preferredDate = '';
|
||
|
||
public string $message = '';
|
||
|
||
public bool $privacy = false;
|
||
|
||
public bool $success = false;
|
||
|
||
/** @var string Honeypot – muss leer bleiben. */
|
||
public string $website = '';
|
||
|
||
public ?int $formLoadedAt = null;
|
||
|
||
/** @var array<string, mixed> */
|
||
public array $content = [];
|
||
|
||
public function mount(): void
|
||
{
|
||
$section = cms_theme_section('appointment_widget');
|
||
$this->content = is_array($section) ? $section : [];
|
||
$this->formLoadedAt = time();
|
||
|
||
$this->applyBrandContext();
|
||
}
|
||
|
||
/**
|
||
* Auf einer Markenseite trägt der Button die Beschriftung dieser Marke.
|
||
*
|
||
* Die Zulieferung sieht ausdrücklich keinen zweiten CTA-Button vor, sondern eine
|
||
* kontextbezogene Beschriftung des vorhandenen Widgets. Da das Widget im Layout steckt und
|
||
* nicht je Seite eingebunden wird, liest es den Kontext aus der Route.
|
||
*/
|
||
private function applyBrandContext(): void
|
||
{
|
||
$route = request()->route();
|
||
|
||
if ($route?->getName() !== 'marken.show') {
|
||
return;
|
||
}
|
||
|
||
$slug = (string) $route->parameter('slug');
|
||
$brand = CmsBrand::forFrontend()[$slug] ?? null;
|
||
$label = is_array($brand) ? trim((string) ($brand['widget_label'] ?? '')) : '';
|
||
|
||
if ($label !== '') {
|
||
$this->content['button_label'] = $label;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Auswahl „Anliegen": gepflegte Kernmarken zuerst, danach die festen Einträge.
|
||
*
|
||
* @return array<string, string>
|
||
*/
|
||
public function getTopicsProperty(): array
|
||
{
|
||
$brands = [];
|
||
|
||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||
$brands[$slug] = (string) ($brand['name'] ?? $slug);
|
||
}
|
||
|
||
return $brands + self::EXTRA_TOPICS;
|
||
}
|
||
|
||
public function getWhatsappUrlProperty(): string
|
||
{
|
||
return whatsapp_link();
|
||
}
|
||
|
||
public function submit(ContactFormService $service): void
|
||
{
|
||
$this->validate([
|
||
'name' => ['required', 'string', 'max:255'],
|
||
'email' => ['required', 'email:rfc', 'max:255'],
|
||
'phone' => ['nullable', 'string', 'max:80'],
|
||
'topic' => ['nullable', 'string', 'max:255'],
|
||
'preferredDate' => ['nullable', 'date', 'after_or_equal:today'],
|
||
'message' => ['nullable', 'string', 'max:2000'],
|
||
'privacy' => ['accepted'],
|
||
'website' => ['nullable', 'string', 'max:0'],
|
||
], [
|
||
'preferredDate.after_or_equal' => __('ui.appointment.date_in_past'),
|
||
]);
|
||
|
||
$topicLabel = $this->topics[$this->topic] ?? '';
|
||
|
||
$payload = [
|
||
'name' => $this->name,
|
||
'email' => $this->email,
|
||
'phone' => $this->phone,
|
||
'topic' => $topicLabel,
|
||
'preferred_date' => $this->preferredDate,
|
||
'message' => $this->message,
|
||
'website' => $this->website,
|
||
'source' => request()->path(),
|
||
'ip' => request()->ip(),
|
||
'user_agent' => request()->userAgent(),
|
||
'is_spam' => false,
|
||
];
|
||
|
||
$payload['is_spam'] = SpamDetector::fromConfig()->detect($payload, $this->formLoadedAt);
|
||
|
||
$subject = 'Terminanfrage B2in Interior'.($topicLabel !== '' ? ': '.$topicLabel : '');
|
||
$service->handle($payload, $subject, 'appointment-widget');
|
||
|
||
$this->success = true;
|
||
$this->reset(['name', 'email', 'phone', 'topic', 'preferredDate', 'message', 'privacy', 'website']);
|
||
$this->formLoadedAt = time();
|
||
}
|
||
|
||
public function startOver(): void
|
||
{
|
||
$this->success = false;
|
||
$this->formLoadedAt = time();
|
||
}
|
||
|
||
public function render(): \Illuminate\View\View
|
||
{
|
||
return view('livewire.web.components.ui.appointment-widget');
|
||
}
|
||
}
|