B2in Interior: Relaunch der Website, Kernmarken-CMS und Terminanfrage

Umsetzung von AP0-AP6 aus dev/2026-08-28/entwicklungsplan.md sowie der
vorab gefundenen Befunde T1 und B1-B13.

Befunde
- CmsContentSeeder überschrieb bei jedem Lauf alle redaktionellen Inhalte;
  jetzt Redaktionsschutz plus gezielter Relaunch-Seeder (B1)
- Magazin-CMS war nicht ans Frontend angebunden, Artikel jetzt über Slugs
  mit 301 von den alten numerischen URLs (B2)
- Vier veröffentlichte Display-Playlists zeigten auf b2in.test und wären
  live nicht ladbar gewesen (B13)
- Info-Tablet speicherte geleerte Öffnungszeiten nicht (T1)
- /service und /portfolio lieferten seit Februar 500 (B12)

Website
- Startseite neu strukturiert und umgetextet, eigene Seite /marken mit
  Detailseite je Kernmarke, /netzwerk und Altrouten leiten dorthin
- Galerie je Marke, getrennt nach realisierten Projekten und
  Visualisierungen; ohne gesetzte Kategorie gilt ein Bild als Entwurf
- Seite /referenzen, gespeist aus den Markengalerien
- Showroom-Display im 9:16-Rahmen über eine schreibgeschützte Embed-Route
  ohne Preview-Token
- Terminanfrage-Widget auf allen Seiten, Markenauswahl aus dem CMS,
  WhatsApp per Klick-zum-Chat-Link mit vorbereitetem Text
- FAQ, Über-uns, Kontakt und Magazin inhaltlich auf Interior umgestellt

Sonstiges
- Vorschau-Instanzen liefern noindex und verlinken sich bei unbekanntem
  Host auf sich selbst statt auf eine fremde Domain
- Seeder-Zugangsdaten aus dem Code in die Konfiguration (B8)
- Ablauf für die Vorschau: dev/2026-08-28/preview-deployment.md

833 Tests grün (3084 Assertions), Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-08-28 15:31:19 +00:00
parent 193d37b1ce
commit 6d53243ea6
132 changed files with 8672 additions and 335 deletions

View file

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
use Acme\ContactForm\ContactFormService;
use App\Livewire\Web\Components\Ui\AppointmentWidget;
use App\Models\CmsBrand;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function storeWhatsappNumber(): void
{
CmsContent::create([
'group' => 'global',
'key' => 'contact',
'type' => 'json',
'value' => ['de' => ['whatsapp' => '+49 151 51002992']],
'order' => 0,
]);
}
beforeEach(function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
config()->set('contact-form.recipient', 'termine@b2in.test');
});
// ========================================
// Einbindung
// ========================================
it('sits on every page, not only on the homepage', function (string $url) {
$this->get($url)
->assertSuccessful()
->assertSeeLivewire(AppointmentWidget::class);
})->with(['/', '/marken', '/marken/cabinet', '/magazin', '/contact']);
it('offers the maintained brands plus the fixed topics', function () {
$topics = Livewire::test(AppointmentWidget::class)->get('topics');
expect($topics)->toHaveKey('cabinet')
->and($topics)->toHaveKey('rotpunkt')
->and($topics)->toHaveKey('februe')
->and($topics['projektausstattung'])->toBe('Projektausstattung')
->and($topics['sonstiges'])->toBe('Sonstiges');
});
it('picks up a brand that was added in the CMS', function () {
CmsBrand::factory()->create(['slug' => 'neue-marke', 'name' => ['de' => 'Neue Marke', 'en' => 'New Brand']]);
expect(Livewire::test(AppointmentWidget::class)->get('topics'))->toHaveKey('neue-marke');
});
// ========================================
// Validierung
// ========================================
it('requires name, email and the privacy consent', function () {
Livewire::test(AppointmentWidget::class)
->call('submit')
->assertHasErrors(['name' => 'required', 'email' => 'required', 'privacy' => 'accepted']);
});
it('rejects a malformed email', function () {
Livewire::test(AppointmentWidget::class)
->set('name', 'Max Muster')
->set('email', 'keine-adresse')
->set('privacy', true)
->call('submit')
->assertHasErrors(['email' => 'email']);
});
it('rejects a preferred date in the past', function () {
Livewire::test(AppointmentWidget::class)
->set('name', 'Max Muster')
->set('email', 'max@example.com')
->set('preferredDate', now()->subDay()->toDateString())
->set('privacy', true)
->call('submit')
->assertHasErrors(['preferredDate']);
});
it('accepts a request without phone, date and message', function () {
Mail::fake();
$service = $this->mock(ContactFormService::class);
$service->shouldReceive('handle')->once();
Livewire::test(AppointmentWidget::class)
->set('name', 'Max Muster')
->set('email', 'max@example.com')
->set('privacy', true)
->call('submit')
->assertHasNoErrors()
->assertSet('success', true);
});
// ========================================
// Versand
// ========================================
it('names the chosen brand in the mail subject and hands over the request', function () {
Mail::fake();
$service = $this->mock(ContactFormService::class);
$service->shouldReceive('handle')
->once()
->withArgs(function (array $payload, string $subject, string $logContext) {
return $subject === 'Terminanfrage B2in Interior: CABINET'
&& $logContext === 'appointment-widget'
&& $payload['topic'] === 'CABINET'
&& $payload['email'] === 'max@example.com';
});
Livewire::test(AppointmentWidget::class)
->set('name', 'Max Muster')
->set('email', 'max@example.com')
->set('topic', 'cabinet')
->set('privacy', true)
->call('submit')
->assertHasNoErrors();
});
it('clears the form after a successful request', function () {
Mail::fake();
$this->mock(ContactFormService::class)->shouldReceive('handle')->once();
Livewire::test(AppointmentWidget::class)
->set('name', 'Max Muster')
->set('email', 'max@example.com')
->set('message', 'Bitte um Rückruf.')
->set('privacy', true)
->call('submit')
->assertSet('name', '')
->assertSet('email', '')
->assertSet('message', '')
->assertSet('privacy', false);
});
it('lets the visitor start a second request', function () {
Mail::fake();
$this->mock(ContactFormService::class)->shouldReceive('handle')->once();
Livewire::test(AppointmentWidget::class)
->set('name', 'Max Muster')
->set('email', 'max@example.com')
->set('privacy', true)
->call('submit')
->assertSet('success', true)
->call('startOver')
->assertSet('success', false);
});
it('swallows a filled honeypot instead of mailing it', function () {
Mail::fake();
$this->mock(ContactFormService::class)->shouldReceive('handle')->once();
Livewire::test(AppointmentWidget::class)
->set('name', 'Bot')
->set('email', 'bot@example.com')
->set('website', '')
->set('privacy', true)
->call('submit')
->assertHasNoErrors();
// Honeypot leer → regulärer Versand. Gegenprobe: gefülltes Feld fällt in der Validierung durch.
Livewire::test(AppointmentWidget::class)
->set('name', 'Bot')
->set('email', 'bot@example.com')
->set('website', 'http://spam.example')
->set('privacy', true)
->call('submit')
->assertHasErrors(['website']);
});
// ========================================
// WhatsApp
// ========================================
it('hides the whatsapp button while no number is stored', function () {
$this->get('/')
->assertSuccessful()
->assertDontSee('wa.me');
});
it('shows the whatsapp button once a number is stored', function () {
storeWhatsappNumber();
$html = $this->get('/')->assertSuccessful()->getContent();
// Die Basis-URL steht in den Alpine-Daten, dort escaped @js die Schrägstriche.
expect($html)->toContain('wa.me')
->and($html)->toContain('4915151002992');
});
it('prepares the whatsapp message from what is already filled in', function () {
storeWhatsappNumber();
$html = $this->get('/')->assertSuccessful()->getContent();
// Der Text wird im Browser aus dem Formularstand zusammengesetzt.
expect($html)->toContain('whatsappHref')
->and($html)->toContain('Hallo B2in Interior')
->and($html)->toContain('Wunschtermin: ');
});
it('says plainly that the visitor sends the whatsapp message themselves', function () {
storeWhatsappNumber();
$this->get('/')
->assertSuccessful()
->assertSee('Abgeschickt wird die Nachricht erst von Ihnen', false);
});
it('offers the brand labels to the whatsapp text, not the slugs', function () {
storeWhatsappNumber();
$html = $this->get('/')->assertSuccessful()->getContent();
expect($html)->toContain('topicLabels');
});
it('uses the light blue of the theme for the floating button', function () {
$this->get('/')
->assertSuccessful()
->assertSee('btn-secondary-accent flex items-center gap-2 rounded-full');
});