Interior-Farbwelt, Welten-Trennung, Tischlerei und Herkunfts-Sektion

Arbeitsplan 14.09.2026, Punkte 1.1 bis 1.4 (Nachträge 6 bis 10 im Entwicklungsplan):

- Farbwelt Interior (Tanne, Creme, Sand, Ocker, Clay) als Standard, Immobilien-Welt
  behält das bisherige Blau über body.world-immobilien; Logo je Welt, Favicons neu,
  Welten-Wechsler in Kopf- und Fußzeile.
- Saubere Trennung der Welten: eigene Navigation, Ankündigungsleiste, Magazinliste,
  Über-uns-Seite, FAQ, Kontaktseite und Abschluss-CTA für Immobilien; Beiträge tragen
  eine Welt (Migration + Admin-Feld). Abschluss-CTA Ton in Ton statt Grün-zu-Ocker.
- Kontakt: Betreffe in Gruppen Interior / Immobilien / B2in, Marken darin.
  Terminanfrage-Buttons öffnen das Widget mit vorgewähltem Anliegen; Select-Felder
  einheitlich gestaltet.
- Tischlerei als vierte Kernmarke (Platzhalter), Kachelraster 2×2.
- Herkunfts-Sektion „Aus der Region gefertigt“ mit schematischer Standortkarte und
  Ablauf CABINET (Beratung Bielefeld, Fertigung Köln, Aufbau mit Tischlerei).

Deployment: migrate, WorldSplitCmsSeeder, CmsBrandSeeder, OriginSectionCmsSeeder
(Abschnitt 8 im Entwicklungsplan).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 12:46:41 +00:00
parent 1242d4bce8
commit 7953a8c9a1
137 changed files with 3840 additions and 589 deletions

View file

@ -2,6 +2,9 @@
declare(strict_types=1);
use App\Livewire\Web\Components\Ui\AnnouncementBar;
use Livewire\Livewire;
it('announcement bar is visible on homepage', function () {
$this->get('/')
->assertSuccessful()
@ -12,7 +15,50 @@ it('announcement bar is visible on homepage', function () {
it('announcement bar is visible on immobilien page', function () {
$this->get('/immobilien')
->assertSuccessful()
->assertSee('B2in Interior');
->assertSee('id="topbar"', false);
});
// ========================================
// Eine Meldung je Welt
// ========================================
it('carries the real estate topic in the real estate world and the interior topic elsewhere', function () {
$this->get('/immobilien')
->assertSuccessful()
->assertSee('Azizi Creek Views 4')
->assertSee('/immobilien/azizi-creek-views-4')
->assertDontSee('Rotpunkt Küchen jetzt bei B2in Interior', false);
$this->get('/')
->assertSuccessful()
->assertSee('Rotpunkt Küchen jetzt bei B2in Interior', false)
->assertDontSee('Azizi Creek Views 4');
});
it('shows the real estate topic on the real estate magazine and about pages too', function (string $path) {
$this->get($path)->assertSuccessful()->assertSee('Azizi Creek Views 4');
})->with(['/immobilien/magazin', '/immobilien/ueber-b2in', '/immobilien/faq']);
it('uses a separate dismiss key per world so closing one bar keeps the other visible', function () {
$interior = Livewire::test(AnnouncementBar::class)->get('dismissKey');
b2in_world_set('immobilien');
$immobilien = Livewire::test(AnnouncementBar::class)->get('dismissKey');
expect($immobilien)->not->toBe($interior);
});
it('takes the real estate bar from the CMS when one is stored', function () {
\FluxCms\Core\Models\CmsContent::create([
'group' => 'global',
'key' => 'announcement_bar_immobilien',
'type' => 'json',
'value' => ['de' => ['enabled' => true, 'id' => 'cms', 'text' => 'Neues Projekt aus dem CMS', 'link_text' => 'Mehr', 'link_url' => '/immobilien']],
'order' => 0,
]);
$this->get('/immobilien')->assertSuccessful()->assertSee('Neues Projekt aus dem CMS');
$this->get('/')->assertSuccessful()->assertDontSee('Neues Projekt aus dem CMS');
});
it('announcement bar carries an interior topic, not a dubai launch', function () {

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
uses(RefreshDatabase::class);
// ========================================
// <x-appointment-link>: Kontakt-Links oeffnen das Widget
// ========================================
it('marks links to the contact pages as widget triggers', function (string $href) {
$html = Blade::render('<x-appointment-link :href="$href" class="btn">Termin</x-appointment-link>', ['href' => $href]);
expect($html)->toContain('data-open-appointment')
->toContain('href="'.$href.'"')
->toContain('class="btn"');
})->with([
'interior' => '/contact',
'immobilien' => '/immobilien/kontakt',
'absolute' => 'https://b2in.test/contact',
]);
it('leaves links to other pages as plain links', function () {
$html = Blade::render('<x-appointment-link href="/marken" class="btn">Marken</x-appointment-link>');
expect($html)->not->toContain('data-open-appointment')
->toContain('href="/marken"');
});
it('hands over the topic that should be preselected', function () {
$html = Blade::render('<x-appointment-link href="/contact" topic="cabinet">Termin</x-appointment-link>');
expect($html)->toContain('data-appointment-topic="cabinet"');
});
// ========================================
// Sektionen auf der Seite
// ========================================
it('opens the widget from the appointment buttons of the homepage', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
// Hero (zweiter Button), Weiche (beide Karten) und Abschluss-CTA
expect(substr_count($html, 'data-open-appointment'))->toBeGreaterThanOrEqual(4);
});
it('preselects the brand when the widget is opened from a brand page', function () {
$html = $this->get('/marken/cabinet')->assertSuccessful()->getContent();
expect($html)->toContain('data-appointment-topic="cabinet"');
});
it('opens the widget from the real estate calls to action too', function () {
$html = $this->get('/immobilien')->assertSuccessful()->getContent();
expect($html)->toContain('data-open-appointment');
});
it('keeps the contact button in the header as a link to the contact page', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
$header = substr($html, (int) strpos($html, '<header'), (int) strpos($html, '</header>') - (int) strpos($html, '<header'));
expect($header)->toContain('href="'.route('contact').'"')
->not->toContain('data-open-appointment');
});
it('listens for the open event in the widget and intercepts the links in the frontend script', function () {
expect($this->get('/')->assertSuccessful()->getContent())->toContain('open-appointment.window');
expect(file_get_contents(resource_path('js/app.js')))->toContain('a[data-open-appointment]')
->toContain("new CustomEvent('open-appointment'");
});
// ========================================
// Formularfelder
// ========================================
it('styles the selects of the widget and the contact page with the shared field classes', function () {
expect($this->get('/')->assertSuccessful()->getContent())->toMatch('/<select[^>]*id="appointment-topic"[^>]*class="form-field form-select"/');
expect($this->get('/contact')->assertSuccessful()->getContent())->toMatch('/<select[^>]*id="subject"[^>]*class="form-field form-select"/');
$css = file_get_contents(resource_path('css/web/shared-styles.css'));
expect($css)->toContain('.form-field')->toContain('.form-select');
});

View file

@ -59,11 +59,12 @@ it('marks maintained brands as having a detail page', function () {
}
});
it('seeds the three core brands without overwriting maintained ones', function () {
it('seeds the four core brands without overwriting maintained ones', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
expect(CmsBrand::count())->toBe(3);
expect(CmsBrand::pluck('slug')->all())->toContain('cabinet', 'rotpunkt', 'februe');
// Drei Kernmarken plus die Tischlerei als Platzhalter (Arbeitsplan 14.09., Punkt 1.2).
expect(CmsBrand::count())->toBe(4);
expect(CmsBrand::pluck('slug')->all())->toContain('cabinet', 'rotpunkt', 'februe', 'tischlerei');
$cabinet = CmsBrand::where('slug', 'cabinet')->firstOrFail();
$cabinet->setTranslation('tagline', 'de', 'Von Hand gepflegt');
@ -71,7 +72,7 @@ it('seeds the three core brands without overwriting maintained ones', function (
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
expect(CmsBrand::count())->toBe(3);
expect(CmsBrand::count())->toBe(4);
expect($cabinet->fresh()->getTranslation('tagline', 'de'))->toBe('Von Hand gepflegt');
});

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use App\Helpers\ThemeHelper;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
// ========================================
// Farbwelten: Interior (Standard) und Immobilien
// ========================================
it('renders the homepage in the interior world with the interior logo', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-interior"/')
->not->toMatch('/<body class="[^"]*world-immobilien"/')
->toContain('b2in-interior-logo-positive.svg')
->not->toContain('img/logos/b2in-logo-positive.svg');
});
it('renders the real estate pages in the immobilien world with the plain logo', function (string $path) {
$html = $this->get($path)->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-immobilien"/')
->toContain('img/logos/b2in-logo-positive.svg')
->toContain('img/logos/b2in-logo-negative.svg')
->not->toContain('b2in-interior-logo');
})->with([
'overview' => '/immobilien',
'detail' => '/immobilien/azizi-creek-views-4',
]);
it('keeps the other pages in the interior world', function (string $path) {
$html = $this->get($path)->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-interior"/');
})->with([
'brands' => '/marken',
'about' => '/about',
'contact' => '/contact',
]);
// ========================================
// Logo je Welt
// ========================================
it('resolves the logo per world', function () {
config(['app.theme' => 'b2in']);
expect(ThemeHelper::getLogoPath('positive', 'interior'))->toBe('img/logos/b2in-interior-logo-positive.svg');
expect(ThemeHelper::getLogoPath('negative', 'interior'))->toBe('img/logos/b2in-interior-logo-negative.svg');
expect(ThemeHelper::getLogoPath('positive', 'immobilien'))->toBe('img/logos/b2in-logo-positive.svg');
expect(ThemeHelper::getLogoPath('negative', 'immobilien'))->toBe('img/logos/b2in-logo-negative.svg');
});
it('ignores the world for the other themes', function () {
config(['app.theme' => 'b2a']);
expect(ThemeHelper::getLogoPath('positive', 'immobilien'))->toBe('img/logos/b2a-logo-positiv.svg');
});
it('ships both logo families in both variants as svg and png', function (string $family) {
foreach (['positive', 'negative'] as $variant) {
expect(file_exists(public_path("img/logos/{$family}-{$variant}.svg")))->toBeTrue();
expect(file_exists(public_path("img/logos/{$family}-{$variant}.png")))->toBeTrue();
}
})->with(['b2in-interior-logo', 'b2in-logo']);
it('colors the interior logo with the new palette', function () {
$positive = strtolower(file_get_contents(public_path('img/logos/b2in-interior-logo-positive.svg')));
$negative = strtolower(file_get_contents(public_path('img/logos/b2in-interior-logo-negative.svg')));
expect($positive)->toContain('#202c2b')->toContain('#d39e46')->not->toContain('#2c9fda');
expect($negative)->toContain('#f7f4ef')->toContain('#d39e46')->not->toContain('#2c9fda');
});
it('keeps the blue bow on the real estate logo', function () {
$svg = strtolower(file_get_contents(public_path('img/logos/b2in-logo-positive.svg')));
expect($svg)->toContain('#2c9fda')->not->toContain('#d39e46');
});
// ========================================
// Welten-Wechsler in Kopf- und Fusszeile
// ========================================
it('offers the world switcher in header and footer', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
// Desktop-Header, mobiles Menue (nur bei geoeffnetem Menue) und Footer
expect(substr_count($html, 'data-world-switch'))->toBeGreaterThanOrEqual(2);
expect($html)->toContain('Bereich wechseln');
expect(preg_match_all('/data-world-switch.*?>\s*Interior\s*<.*?>\s*Immobilien\s*</s', $html))->toBeGreaterThanOrEqual(2);
});
it('marks the current world in the switcher', function () {
$home = $this->get('/')->assertSuccessful()->getContent();
$immobilien = $this->get('/immobilien')->assertSuccessful()->getContent();
expect($home)->toMatch('/aria-current="page"[^>]*>\s*Interior\s*</');
expect($home)->not->toMatch('/aria-current="page"[^>]*>\s*Immobilien\s*</');
expect($immobilien)->toMatch('/aria-current="page"[^>]*>\s*Immobilien\s*</');
expect($immobilien)->not->toMatch('/aria-current="page"[^>]*>\s*Interior\s*</');
});
// ========================================
// Themensystem
// ========================================
it('defines the interior palette as default and the blue world as override', function () {
$css = file_get_contents(resource_path('css/web/theme-b2in.css'));
// Interior: Tanne, Creme, Sand, Ocker, Clay, Anthrazit
expect($css)->toContain('--primary: 175 16% 15%')
->toContain('--background: 38 33% 95%')
->toContain('--accent: 28 25% 80%')
->toContain('--secondary: 37 62% 55%')
->toContain('--clay: 24 15% 57%')
->toContain('--foreground: 26 8% 16%')
->toContain('.world-immobilien')
->toContain('--secondary: 199 74% 49%');
});
it('binds the glow and shadow effects to the accent instead of a fixed blue', function () {
$css = file_get_contents(resource_path('css/web/shared-styles.css'));
expect($css)->not->toContain('rgba(0, 136, 204')
->not->toContain('rgba(0, 155, 221')
->not->toContain('hsla(199');
});
// ========================================
// Abschlussband
// ========================================
it('keeps ocher out of the interior band gradient and uses it for highlighted words instead', function () {
$theme = file_get_contents(resource_path('css/web/theme-b2in.css'));
$shared = file_get_contents(resource_path('css/web/shared-styles.css'));
// Interior: Ton in Ton (Tanne), Immobilien: weiter Anthrazit → Dynamic Blue
expect($theme)->toContain('--cta-band-end: 175 12% 26%')
->toContain('--cta-band-end: 199 74% 42%');
// Woerter mit `text-primary` im Bandtitel („Ihr nächster Schritt“) laufen auf den Akzent
expect($shared)->toContain('.cta-band .text-primary');
});
it('turns highlighted words on accent-colored sections to the primary tone', function () {
// /marken: „Lernen Sie unsere Marken kennen“ liegt auf einer Ocker-Flaeche; das Wort
// „Marken“ traegt im CMS `text-secondary` und waere sonst Ocker auf Ocker.
$shared = file_get_contents(resource_path('css/web/shared-styles.css'));
expect($shared)->toContain('section.bg-secondary .text-secondary');
// Livewire haengt seine Attribute vor `class`, deshalb ein Muster statt eines festen Strings.
$html = $this->get('/marken')->assertSuccessful()->getContent();
expect($html)->toMatch('/<section [^>]*class="section-padding bg-secondary/')
->toContain('<span class="text-secondary">Marken</span>');
});

View file

@ -30,7 +30,7 @@ it('contact form requires privacy acceptance', function () {
->set('firstName', 'Max')
->set('lastName', 'Mustermann')
->set('email', 'max@example.com')
->set('subject', 'Allgemein')
->set('subject', 'general')
->set('message', 'Testnachricht')
->set('privacy', false)
->call('submit')
@ -47,7 +47,7 @@ it('contact form sends email on valid submission', function () {
->set('firstName', 'Max')
->set('lastName', 'Mustermann')
->set('email', 'max@example.com')
->set('subject', 'Allgemein')
->set('subject', 'general')
->set('message', 'Testnachricht für das Kontaktformular')
->set('privacy', true)
->call('submit')
@ -65,7 +65,7 @@ it('contact form resets after successful submission', function () {
->set('firstName', 'Max')
->set('lastName', 'Mustermann')
->set('email', 'max@example.com')
->set('subject', 'Allgemein')
->set('subject', 'general')
->set('message', 'Testnachricht')
->set('privacy', true)
->call('submit')
@ -81,7 +81,7 @@ it('contact form blocks honeypot spam', function () {
->set('firstName', 'Max')
->set('lastName', 'Mustermann')
->set('email', 'max@example.com')
->set('subject', 'Allgemein')
->set('subject', 'general')
->set('message', 'Testnachricht')
->set('privacy', true)
->set('website', 'https://spam.com')

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
use App\Livewire\Web\Components\Ui\AppointmentWidget;
use App\Models\CmsBrand;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
// ========================================
// Tischlerei als vierte Kernmarke (Arbeitsplan 14.09., Punkt 1.2 – Platzhalter)
// ========================================
beforeEach(function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
});
it('creates the joinery as the fourth brand without touching the existing ones', function () {
expect(CmsBrand::count())->toBe(4);
$brand = CmsBrand::where('slug', 'tischlerei')->firstOrFail();
expect($brand->order)->toBe(4)
->and($brand->is_published)->toBeTrue()
->and($brand->logo)->toBe('')
->and($brand->sections)->toHaveCount(3);
// Erneutes Seeden legt nichts doppelt an.
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
expect(CmsBrand::count())->toBe(4);
});
it('shows four tiles on the homepage in a two by two grid', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
expect($html)->toContain('Tischlerei')
->toContain('/marken/tischlerei')
->toMatch('/<div [^>]*class="grid sm:grid-cols-2 gap-8[^"]*"/')
->not->toContain('xl:grid-cols-4');
});
it('lists the joinery on the brands page after the three established brands', function () {
$html = $this->get('/marken')->assertSuccessful()->getContent();
preg_match_all('#wire:key="brand-([a-z-]+)"#', $html, $matches);
expect($matches[1])->toBe(['cabinet', 'rotpunkt', 'februe', 'tischlerei']);
expect($html)->toContain('Sonderlösungen nach Aufmaß');
});
it('serves the joinery detail page with the placeholder texts and the wordmark', function () {
$this->get('/marken/tischlerei')
->assertSuccessful()
->assertSee('<h1 class="text-section-title">Tischlerei</h1>', false)
->assertSee('Maßarbeit, die den Raum vollendet.', false)
->assertSee('Wo Serienmöbel aufhören.', false)
->assertSee('Ergänzung, nicht Konkurrenz.', false)
->assertSee('Ihr Raum. Bis ins Detail gefertigt.', false)
->assertSee('data-appointment-topic="tischlerei"', false);
});
it('offers the joinery among the other brands of every detail page, three across', function () {
$html = $this->get('/marken/cabinet')->assertSuccessful()->getContent();
expect($html)->toContain('other-tischlerei')
->toContain('sm:grid-cols-2 lg:grid-cols-3');
});
it('labels the widget on the joinery page and offers the joinery as a topic everywhere', function () {
$this->get('/marken/tischlerei')
->assertSuccessful()
->assertSee('Persönliche Beratung zur Maßarbeit vereinbaren', false);
expect(Livewire::test(AppointmentWidget::class)->get('topics'))->toHaveKey('tischlerei');
$this->get('/contact')
->assertSuccessful()
->assertSee('value="tischlerei"', false);
});
it('keeps the joinery in the lang fallback while no brands are maintained', function () {
CmsBrand::query()->delete();
$this->get('/')
->assertSuccessful()
->assertSee('Tischlerei')
->assertSee('Maßarbeit, die den Raum vollendet.', false);
});

View file

@ -98,7 +98,7 @@ it('describes Febrü with the home office first', function () {
// Navigation
// ========================================
it('puts brands before real estate in the navigation and drops the network entry', function () {
it('leads the interior navigation with brands and leaves real estate to the world switcher', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
$navStart = (int) strpos($html, 'aria-label="Hauptnavigation"');
@ -106,9 +106,11 @@ it('puts brands before real estate in the navigation and drops the network entry
expect($nav)->toContain('Marken');
expect($nav)->toContain('/marken');
expect($nav)->toContain('Immobilien');
// „Immobilien“ ist seit der Welten-Trennung kein Navigationspunkt mehr – der Wechsler
// in Kopf- und Fusszeile ist der einzige Weg in die Immobilien-Welt.
expect($nav)->not->toContain('href="/immobilien"');
expect($nav)->not->toContain('Netzwerk');
expect(strpos($nav, 'Marken'))->toBeLessThan(strpos($nav, 'Immobilien'));
expect(strpos($nav, 'Marken'))->toBeLessThan(strpos($nav, 'Magazin'));
});
it('sends the retired network page to the brands page', function () {
@ -174,10 +176,11 @@ it('does not turn the sponsor tiles into links', function () {
// ========================================
it('uses the interior logo in the header and footer', function () {
$this->get('/')
->assertSuccessful()
->assertSee('b2in-interior-logo-positive.svg')
->assertSee('b2in-interior-logo-negative.svg');
// Beide Male die positive Variante: der Interior-Footer liegt auf Sand, nicht auf dunklem Grund.
$html = $this->get('/')->assertSuccessful()->getContent();
expect(substr_count($html, 'b2in-interior-logo-positive.svg'))->toBeGreaterThanOrEqual(2);
expect($html)->not->toContain('img/logos/b2in-logo-positive.svg');
});
it('ships the interior logo files in both variants', function () {
@ -190,8 +193,9 @@ it('ships the interior logo files in both variants', function () {
it('crops the logo viewBox to the artwork', function () {
// Die Lieferung lag auf einem A4-Artboard mit 74 % Weissraum. Ungeschnitten wuerde das
// Logo bei gleicher CSS-Hoehe rund halb so gross rendern wie das bisherige.
// Zuschnitt der Fassung vom 07.09.2026 (Tanne/Ocker): Inhalt 153,3–440,5 × 95,3–320,8.
$svg = file_get_contents(public_path('img/logos/b2in-interior-logo-positive.svg'));
expect($svg)->toContain('viewBox="153 96 289.3 227.5"');
expect($svg)->toContain('viewBox="152.5 94.5 289 228"');
expect($svg)->not->toContain('595.3 419.5');
});

View file

@ -72,13 +72,15 @@ it('immobilien has all five sections', function () {
->assertSee('Investoren');
});
it('marken page lists the core brands', fn () => $this->get('/marken')->assertSee('CABINET')->assertSee('Febrü', false));
it('magazin has all five articles', function () {
it('magazin has all articles across both worlds', function () {
$this->get('/magazin')
->assertSee('Escrow-System')
->assertSee('Al Jaddaf')
->assertSee('Turnkey')
->assertSee('Supply-Chain')
->assertSee('Local for Local');
$this->get('/immobilien/magazin')
->assertSee('Escrow-System')
->assertSee('Al Jaddaf')
->assertSee('Turnkey');
});
// ========================================

View file

@ -13,14 +13,23 @@ it('magazin list page loads successfully', function () {
->assertSee('Magazin');
});
it('magazin list shows all five articles', function () {
it('magazin list shows the interior articles and leaves the dubai ones to the real estate list', function () {
$this->get('/magazin')
->assertSuccessful()
->assertSee('Nach Maß statt von der Stange', false)
->assertSee('Supply-Chain-Management')
->assertSee('Local for Local')
->assertDontSee('Escrow-System')
->assertDontSee('Al Jaddaf')
->assertDontSee('Turnkey-Investments');
$this->get('/immobilien/magazin')
->assertSuccessful()
->assertSee('Escrow-System')
->assertSee('Al Jaddaf')
->assertSee('Turnkey-Investments')
->assertSee('Supply-Chain-Management')
->assertSee('Local for Local');
->assertDontSee('Supply-Chain-Management')
->assertDontSee('Local for Local');
});
it('magazin detail page loads for the first article', function () {
@ -48,7 +57,7 @@ it('returns 404 for an unknown slug instead of silently showing the first articl
});
it('links to the articles by slug, not by id', function () {
$this->get('/magazin')
$this->get('/immobilien/magazin')
->assertSuccessful()
->assertSee('/magazin/escrow-system-dubai-investoren');
});
@ -119,7 +128,7 @@ it('falls back to the lang file while no articles are maintained', function () {
$this->get('/magazin')
->assertSuccessful()
->assertSee('Escrow-System');
->assertSee('Local for Local');
});
it('prefers the CMS over the lang file once articles exist', function () {

View file

@ -63,7 +63,7 @@ it('shows a breadcrumb from home to brands', function () {
it('carries the title and description from the spec', function () {
$this->get('/marken')
->assertSuccessful()
->assertSee('Unsere Marken – CABINET, Rotpunkt Küchen &amp; Febrü | B2in Interior', false)
->assertSee('Unsere Marken – CABINET, Rotpunkt Küchen, Febrü &amp; Tischlerei | B2in Interior', false)
->assertSee('Ausgewählte Hersteller statt beliebiger Kataloge', false);
});
@ -96,12 +96,12 @@ it('keeps the brand order from the CMS', function () {
};
expect($headings($this->get('/marken')->assertSuccessful()->getContent()))
->toBe(['cabinet', 'rotpunkt', 'februe']);
->toBe(['cabinet', 'rotpunkt', 'februe', 'tischlerei']);
CmsBrand::where('slug', 'februe')->update(['order' => 0]);
expect($headings($this->get('/marken')->getContent()))
->toBe(['februe', 'cabinet', 'rotpunkt']);
->toBe(['februe', 'cabinet', 'rotpunkt', 'tischlerei']);
});
it('hides unpublished brands', function () {

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use App\Livewire\Web\Components\Sections\OriginSection;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3).
*/
function originBody(string $html): string
{
$html = preg_replace('/wire:snapshot="[^"]*"/', '', $html) ?? $html;
return substr($html, (int) strpos($html, '<main'));
}
it('shows the four locations on the homepage', function () {
$this->get('/')
->assertSuccessful()
->assertSee('id="herkunft"', false)
->assertSee('Aus der Region', false)
->assertSee('Bünde', false)
->assertSee('Herford')
->assertSee('Rheda-Wiedenbrück', false)
->assertSee('Köln', false);
});
it('sits between the core brands and the founder story on the homepage', function () {
$html = originBody($this->get('/')->assertSuccessful()->getContent());
$brands = strpos($html, 'id="marken"');
$origin = strpos($html, 'id="herkunft"');
$story = strpos($html, 'Gebaut auf Expertise');
expect($brands)->not->toBeFalse();
expect($origin)->toBeGreaterThan($brands);
expect($story)->toBeGreaterThan($origin);
});
it('appears on the brands page after the brand overview and before the network', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$html = originBody($this->get('/marken')->assertSuccessful()->getContent());
$overview = strpos($html, '/marken/cabinet');
$origin = strpos($html, 'id="herkunft"');
$network = strpos($html, 'unser Netzwerk');
expect($overview)->not->toBeFalse();
expect($origin)->toBeGreaterThan($overview);
expect($network)->toBeGreaterThan($origin);
});
it('links every location to its brand page', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$html = originBody($this->get('/')->assertSuccessful()->getContent());
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
$section = substr($section, 0, (int) strpos($section, '</section>'));
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
expect($section)->toContain('href="'.$link.'"');
}
});
it('drops the link of an unpublished brand but keeps its location', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
\App\Models\CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
$html = originBody($this->get('/')->assertSuccessful()->getContent());
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
$section = substr($section, 0, (int) strpos($section, '</section>'));
expect($section)->toContain('Herford')
->toContain('href="/marken/cabinet"')
->not->toContain('/marken/februe');
});
it('finds the brand page by name when the lang fallback uses other slugs', function () {
$html = originBody($this->get('/')->assertSuccessful()->getContent());
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
$section = substr($section, 0, (int) strpos($section, '</section>'));
expect($section)->toContain('href="/marken/cabinet"')
->toContain('href="/marken/tischlerei"');
});
it('draws Bielefeld as the hub with one spoke per location', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
expect($html)->toContain('class="origin-map"')
->toContain('origin-map-hub')
->toContain('Ostwestfalen');
expect(substr_count($html, 'class="origin-map-spoke"'))->toBe(4);
expect(substr_count($html, 'class="origin-map-dot"'))->toBe(4);
});
it('separates manufacturing from sales for CABINET', function () {
$this->get('/')
->assertSuccessful()
->assertSee('Gefertigt in Köln', false)
->assertSee('Beratung und Planung in Bielefeld')
->assertSee('Fertigung in Köln', false)
->assertSee('Aufbau vor Ort', false);
});
it('prefers the CMS content over the lang fallback', function () {
CmsContent::create([
'group' => 'home',
'key' => 'origin_section',
'type' => 'json',
'value' => ['de' => [
'title' => 'Gefertigt in <span class="text-secondary">OWL</span>',
'hub' => ['city' => 'Bielefeld', 'x' => 400, 'y' => 175],
'locations' => [
['city' => 'Verl', 'brand' => 'Testmarke', 'x' => 300, 'y' => 100],
],
]],
'order' => 0,
]);
$this->get('/')
->assertSuccessful()
->assertSee('Gefertigt in <span class="text-secondary">OWL</span>', false)
->assertSee('Verl')
->assertSee('Testmarke')
->assertDontSee('Rheda-Wiedenbrück');
});
it('skips locations without brand or city instead of breaking the map', function () {
Livewire::test(OriginSection::class)
->set('content', [
'title' => 'Herkunft',
'locations' => [
['city' => 'Bünde', 'brand' => 'Rotpunkt Küchen', 'x' => 1, 'y' => 1],
['city' => '', 'brand' => 'Ohne Stadt'],
'kaputt',
['brand' => '', 'city' => 'Ohne Marke'],
],
])
->assertSee('Rotpunkt Küchen', false)
->assertDontSee('Ohne Stadt')
->assertDontSee('Ohne Marke');
});
it('renders without the steps block when the CMS leaves it empty', function () {
Livewire::test(OriginSection::class)
->set('content', ['title' => 'Herkunft', 'locations' => [], 'steps' => []])
->assertSee('Herkunft')
->assertDontSee('<ol', false);
});
it('creates the CMS section only when it is missing', function () {
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
$row = CmsContent::query()->where('group', 'home')->where('key', 'origin_section')->first();
expect($row)->not->toBeNull();
expect($row->getTranslation('value', 'de')['locations'])->toHaveCount(4);
$row->update(['value' => ['de' => ['title' => 'Vom Admin geändert']]]);
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
expect($row->fresh()->getTranslation('value', 'de')['title'])->toBe('Vom Admin geändert');
});

View file

@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
use App\Livewire\Web\Components\Ui\AppointmentWidget;
use App\Models\CmsArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Livewire\Volt\Volt;
uses(RefreshDatabase::class);
function mainNavigation(string $html): string
{
$start = (int) strpos($html, 'aria-label="Hauptnavigation"');
return substr($html, $start, (int) strpos($html, '</nav>', $start) - $start);
}
// ========================================
// Navigation je Welt
// ========================================
it('shows the interior navigation without a real estate entry', function () {
$nav = mainNavigation($this->get('/')->assertSuccessful()->getContent());
expect($nav)->toContain('/marken')
->toContain('/referenzen')
->toContain('href="/magazin"')
->toContain('/about')
->not->toContain('href="/immobilien"');
});
it('shows the real estate navigation on the real estate pages', function (string $path) {
$nav = mainNavigation($this->get($path)->assertSuccessful()->getContent());
expect($nav)->toContain('href="/immobilien"')
->toContain('/immobilien#projekte')
->toContain('/immobilien/magazin')
->toContain('/immobilien/ueber-b2in')
->not->toContain('/marken')
->not->toContain('/referenzen');
})->with([
'overview' => '/immobilien',
'detail' => '/immobilien/azizi-creek-views-4',
'magazine' => '/immobilien/magazin',
'about' => '/immobilien/ueber-b2in',
'faq' => '/immobilien/faq',
'contact' => '/immobilien/kontakt',
]);
it('marks only the most specific navigation entry as active', function () {
$nav = mainNavigation($this->get('/immobilien/magazin')->assertSuccessful()->getContent());
preg_match_all('/<a href="([^"]+)"[^>]*class="[^"]*after:bg-secondary/', $nav, $matches);
expect($matches[1])->toBe(['/immobilien/magazin']);
});
it('points the footer of the real estate world to its own pages', function () {
$html = $this->get('/immobilien')->assertSuccessful()->getContent();
expect($html)->toContain('href="/immobilien/faq"')
->not->toContain('href="/faq"');
$home = $this->get('/')->assertSuccessful()->getContent();
expect($home)->toContain('href="/faq"');
});
// ========================================
// Magazin: zwei Listen, Detailseite in der Welt des Beitrags
// ========================================
it('renders the real estate magazine in the real estate world', function () {
$html = $this->get('/immobilien/magazin')->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-immobilien"/')
->toContain('Immobilien-Magazin')
->toContain('img/logos/b2in-logo-positive.svg');
});
it('renders a dubai article in the real estate world and leads back to its list', function () {
$html = $this->get('/magazin/escrow-system-dubai-investoren')->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-immobilien"/')
->toContain('img/logos/b2in-logo-positive.svg')
->toContain('href="'.route('immobilien.magazin').'"')
->not->toContain('b2in-interior-logo');
});
it('renders an interior article in the interior world', function () {
$html = $this->get('/magazin/massanfertigung-warum-der-millimeter-zaehlt')->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-interior"/')
->toContain('b2in-interior-logo-positive.svg')
->toContain('href="'.route('magazin').'"');
});
it('suggests related articles from the same world only', function () {
$html = $this->get('/magazin/escrow-system-dubai-investoren')->assertSuccessful()->getContent();
expect($html)->toContain('Al Jaddaf')
->not->toContain('Local for Local')
->not->toContain('Nach Maß statt von der Stange');
});
it('keeps the supply chain article in the interior world', function () {
$this->get('/magazin')->assertSuccessful()->assertSee('Supply-Chain-Management');
$this->get('/immobilien/magazin')->assertSuccessful()->assertDontSee('Supply-Chain-Management');
});
it('sorts maintained articles into the list of their world', function () {
CmsArticle::factory()->create(['slug' => 'wohnzimmer', 'title' => 'Beitrag fürs Wohnzimmer', 'world' => 'interior', 'order' => 1]);
CmsArticle::factory()->create(['slug' => 'dubai-marina', 'title' => 'Beitrag zur Dubai Marina', 'world' => 'immobilien', 'order' => 2]);
$this->get('/magazin')
->assertSuccessful()
->assertSee('Beitrag fürs Wohnzimmer')
->assertDontSee('Beitrag zur Dubai Marina');
$this->get('/immobilien/magazin')
->assertSuccessful()
->assertSee('Beitrag zur Dubai Marina')
->assertDontSee('Beitrag fürs Wohnzimmer');
expect(array_keys(CmsArticle::forFrontend('immobilien')))->toBe(['dubai-marina']);
expect(array_keys(CmsArticle::forFrontend()))->toBe(['wohnzimmer', 'dubai-marina']);
});
it('assigns the three legacy dubai articles to the real estate world in the lang fallback', function () {
$worlds = array_map(fn (array $article) => $article['world'], CmsArticle::forFrontend());
foreach (CmsArticle::LEGACY_IMMOBILIEN_SLUGS as $slug) {
expect($worlds[$slug])->toBe('immobilien');
}
expect($worlds['supply-chain-management-vertragssicherheit'])->toBe('interior');
expect($worlds['local-for-local-regionaler-moebelhandel'])->toBe('interior');
});
it('returns 404 for an unknown article on the world-aware detail route', function () {
$this->get('/magazin/gibt-es-nicht')->assertNotFound();
});
it('lets the editor choose the world of an article', function () {
$this->actingAs(\App\Models\User::factory()->create());
Volt::test('admin.cms.articles-index')
->call('openCreate')
->set('slug', 'neuer-beitrag')
->set('articleTitle', 'Neuer Beitrag')
->set('world', 'immobilien')
->call('save')
->assertHasNoErrors();
expect(CmsArticle::where('slug', 'neuer-beitrag')->value('world'))->toBe('immobilien');
});
it('rejects an unknown world in the editor', function () {
$this->actingAs(\App\Models\User::factory()->create());
Volt::test('admin.cms.articles-index')
->call('openCreate')
->set('slug', 'neuer-beitrag')
->set('articleTitle', 'Neuer Beitrag')
->set('world', 'shop')
->call('save')
->assertHasErrors(['world']);
});
// ========================================
// Über uns und FAQ der Immobilien-Welt
// ========================================
it('serves a real estate about page with the pre-relaunch story, marked as such', function () {
$html = $this->get('/immobilien/ueber-b2in')->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-immobilien"/')
->toContain('B2in Immobilien')
->toContain('Connecting Design and')
->toContain('Die Erweiterung')
->not->toContain('Supply-Chain');
$this->get('/about')
->assertSuccessful()
->assertDontSee('Connecting Design and');
});
it('serves a real estate faq and keeps those questions out of the interior faq', function () {
$this->get('/immobilien/faq')
->assertSuccessful()
->assertSee('Muss ich für den Immobilienkauf persönlich nach Dubai reisen', false)
->assertSee('world-immobilien', false);
$this->get('/faq')
->assertSuccessful()
->assertDontSee('persönlich nach Dubai reisen')
->assertSee('Maßanfertigung', false);
});
// ========================================
// Terminanfrage-Widget
// ========================================
it('preselects the real estate topic in the real estate world', function () {
b2in_world_set('immobilien');
Livewire::test(AppointmentWidget::class)
->assertSet('world', 'immobilien')
->assertSet('topic', 'immobilien');
});
it('leaves the topic open in the interior world', function () {
Livewire::test(AppointmentWidget::class)
->assertSet('world', 'interior')
->assertSet('topic', '');
});
it('offers real estate as a fixed topic', function () {
expect(Livewire::test(AppointmentWidget::class)->get('topics')['immobilien'])->toBe('Immobilien');
});
// ========================================
// Kontaktseite je Welt
// ========================================
it('serves a real estate contact page in the real estate world', function () {
$html = $this->get('/immobilien/kontakt')->assertSuccessful()->getContent();
expect($html)->toMatch('/<body class="[^"]*world-immobilien"/')
->toContain('Projekt?')
->toContain('Exposé und Verfügbarkeit')
->toContain('Rathausstraße 11');
});
// ========================================
// Betreffe in Gruppen
// ========================================
it('groups the subjects into interior, real estate and b2in with the brands first', function () {
$html = $this->get('/contact')->assertSuccessful()->getContent();
expect($html)->toContain('<optgroup label="Interior">')
->toContain('<optgroup label="Immobilien">')
->toContain('<optgroup label="B2in">');
$interior = strpos($html, '<optgroup label="Interior">');
$immobilien = strpos($html, '<optgroup label="Immobilien">');
$b2in = strpos($html, '<optgroup label="B2in">');
expect($interior)->toBeLessThan($immobilien)
->and($immobilien)->toBeLessThan($b2in);
// Die Kernmarken stehen am Anfang der Interior-Gruppe, vor dem Beratungstermin.
$group = substr($html, $interior, $immobilien - $interior);
$brandSlugs = array_keys(\App\Models\CmsBrand::forFrontend());
expect($brandSlugs)->not->toBeEmpty();
foreach ($brandSlugs as $slug) {
expect($group)->toContain('value="'.$slug.'"');
}
expect($group)->toContain('Küchenplanung');
expect(strpos($group, 'value="'.$brandSlugs[0].'"'))->toBeLessThan(strpos($group, 'value="beratung"'));
});
it('puts the real estate group first on the real estate contact page', function () {
$html = $this->get('/immobilien/kontakt')->assertSuccessful()->getContent();
expect(strpos($html, '<optgroup label="Immobilien">'))
->toBeLessThan(strpos($html, '<optgroup label="Interior">'));
});
it('accepts a brand as subject and rejects an unknown one', function () {
\Illuminate\Support\Facades\Mail::fake();
$form = fn () => Livewire::test(\App\Livewire\Web\Components\Ui\ContactForm::class)
->set('firstName', 'Anna')
->set('lastName', 'Muster')
->set('email', 'anna@example.com')
->set('message', 'Hallo.')
->set('privacy', true);
$brand = array_key_first(\App\Models\CmsBrand::forFrontend());
$form()->set('subject', $brand)->call('submit')->assertHasNoErrors()->assertSet('success', true);
$form()->set('subject', 'gibt-es-nicht')->call('submit')->assertHasErrors(['subject']);
});
it('falls back to a flat subject list for an older CMS entry without groups', function () {
\FluxCms\Core\Models\CmsContent::create([
'group' => 'contact',
'key' => 'contact_form',
'type' => 'json',
'value' => ['de' => ['form' => ['subjects' => ['' => 'Bitte wählen', 'alt' => 'Alter Betreff']]]],
'order' => 0,
]);
$html = $this->get('/contact')->assertSuccessful()->getContent();
expect($html)->toContain('value="alt"')
->toContain('Bitte wählen')
->not->toContain('<optgroup');
});
it('sends the contact button of the header to the contact page of the current world', function () {
expect($this->get('/immobilien')->getContent())->toContain('href="'.route('immobilien.contact').'"');
expect($this->get('/')->getContent())->toContain('href="'.route('contact').'"')
->not->toContain(route('immobilien.contact'));
});
it('links the calls to action of the real estate page to its own contact page', function () {
$html = $this->get('/immobilien')->assertSuccessful()->getContent();
expect($html)->not->toContain('href="/contact"')
->toContain('href="/immobilien/kontakt"')
->toContain('Ob Exposé, Kaufprozess oder Einrichtung nach dem Kauf');
// Der Interior-Abschluss bleibt, wie er ist.
expect($this->get('/')->getContent())->toContain('Ob einzelnes Möbelstück');
});
it('prefixes real estate contact mails so they are recognisable in the inbox', function () {
\Illuminate\Support\Facades\Mail::fake();
Livewire::test(\App\Livewire\Web\Components\Ui\ContactForm::class, ['world' => 'immobilien'])
->assertSet('world', 'immobilien')
->set('firstName', 'Anna')
->set('lastName', 'Muster')
->set('email', 'anna@example.com')
->set('subject', 'expose')
->set('message', 'Bitte um das Exposé.')
->set('privacy', true)
->call('submit')
->assertHasNoErrors()
->assertSet('success', true);
});