b2in/app/Livewire/Web/Components/UI/AppointmentWidget.php
Kevin Adametz 4ef3a4d8f1 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>
2026-09-03 08:52:36 +00:00

155 lines
4.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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');
}
}