CMS: Standorte an der Marke, Seitenbaum mit Vorschau, Referenzen-Modul

Nachtrag 11: Herkunfts-Sektion liest Ort, Entfernung und Rolle aus der
Markenverwaltung (drei neue Felder an cms_brands, BrandOriginSeeder),
Kartenpositionen im Code, „Marken“ in der Sidebar.

Nachtrag 12: Inhalte-Editor zeigt die Seiten der Website mit ihren
Sektionen in Reihenfolge (config/cms_pages.php, CmsPageTree), Felder in
Klartext, fehlende Sektionen aus der Sprachdatei anlegbar, Vorschau der
echten Seite im iframe mit Sprung zur Sektion; Anker an allen lebenden
Sektionen nachgezogen.

Nachtrag 13: Referenzen als eigenes Modul (CmsReference, Portal-Seite,
/referenzen mit Fallback auf die Markengalerien); Immobilien-Projekte
aus Sidebar und CMS-Übersicht genommen, Route bleibt.

Dazu .gitignore für _b2in_interior_shop/ und docs/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 14:58:45 +00:00
parent 7953a8c9a1
commit 90aa079cf7
50 changed files with 2580 additions and 324 deletions

View file

@ -327,3 +327,69 @@ it('saves the widget label and the closing block', function () {
->and($brand->getTranslation('cta_title', 'de'))->toBe('Ihre Küche. Persönlich geplant.')
->and($brand->getTranslation('cta_text', 'de'))->toBe('Jeder Raum ist anders.');
});
// ========================================
// Fertigungsort für „Aus der Region gefertigt“ (Nachtrag 11)
// ========================================
it('saves the origin of a brand per language', function () {
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.brands-index')
->call('openCreate')
->set('slug', 'mit-herkunft')
->set('brandName', 'Mit Herkunft')
->set('originCity', 'Bünde')
->set('originDistance', 'rund 30 km')
->set('originRole', 'Küchen aus eigener Fertigung.')
->call('save')
->assertHasNoErrors();
$brand = CmsBrand::where('slug', 'mit-herkunft')->firstOrFail();
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
->and($brand->getTranslation('origin_distance', 'de'))->toBe('rund 30 km')
->and($brand->getTranslation('origin_role', 'de'))->toBe('Küchen aus eigener Fertigung.');
Volt::test('admin.cms.brands-index')
->call('openEdit', $brand->id)
->assertSet('originCity', 'Bünde')
->call('switchLocale', 'en')
->assertSet('originCity', '')
->set('brandName', 'With origin')
->set('originCity', 'Buende')
->call('save')
->assertHasNoErrors();
$brand->refresh();
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
->and($brand->getTranslation('origin_city', 'en'))->toBe('Buende');
});
it('clears the origin when the city is emptied', function () {
$this->actingAs(User::factory()->create());
$brand = CmsBrand::factory()->create(['origin_city' => ['de' => 'Herford']]);
Volt::test('admin.cms.brands-index')
->call('openEdit', $brand->id)
->set('originCity', '')
->call('save')
->assertHasNoErrors();
expect($brand->fresh()->getTranslation('origin_city', 'de', false))->toBe('');
});
it('is reachable from the sidebar for admins', function () {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
$user = User::factory()->create();
$user->assignRole('Admin');
$html = $this->actingAs($user)
->get(route('cms.brands.index'))
->assertSuccessful()
->getContent();
expect($html)->toContain(route('cms.brands.index'))
->toMatch('#data-content>\s*Marken\s*</div>#');
});

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
use App\Models\CmsReference;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
uses(RefreshDatabase::class);
/**
* Referenzen verwalten (Nachtrag 13).
*/
beforeEach(function () {
url()->forceRootUrl('https://'.config('domains.domain_portal'));
});
it('requires authentication', function () {
$this->get(route('cms.references.index'))->assertRedirect('/login');
});
it('renders for authenticated users', function () {
$this->actingAs(User::factory()->create())
->get(route('cms.references.index'))
->assertSuccessful()
->assertSee('Referenzen')
->assertSee('Keine Referenzen');
});
it('creates a reference with brands, cover and gallery', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.references-index')
->call('openCreate')
->set('referenceTitle', 'Ankleide unter der Dachschräge')
->set('location', 'Bielefeld')
->set('year', '2025')
->set('description', 'Maßschrank bis unter den First.')
->set('image', 'referenzen/ankleide.webp')
->set('brandSlugs', ['cabinet', 'tischlerei'])
->call('addGalleryImage')
->set('gallery.0.image', 'referenzen/ankleide-2.webp')
->set('gallery.0.caption', 'Innenausbau')
->call('addGalleryImage')
->call('save')
->assertHasNoErrors();
$reference = CmsReference::firstOrFail();
expect($reference->getTranslation('title', 'de'))->toBe('Ankleide unter der Dachschräge')
->and($reference->location)->toBe('Bielefeld')
->and($reference->year)->toBe('2025')
->and($reference->image)->toBe('referenzen/ankleide.webp')
->and($reference->brand_slugs)->toBe(['cabinet', 'tischlerei'])
->and($reference->gallery)->toBe([['image' => 'referenzen/ankleide-2.webp', 'caption' => 'Innenausbau']])
->and($reference->is_published)->toBeTrue();
});
it('requires a title', function () {
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.references-index')
->call('openCreate')
->set('referenceTitle', '')
->call('save')
->assertHasErrors(['referenceTitle']);
expect(CmsReference::count())->toBe(0);
});
it('rejects a brand slug that does not exist', function () {
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.references-index')
->call('openCreate')
->set('referenceTitle', 'Test')
->set('brandSlugs', ['gibt-es-nicht'])
->call('save')
->assertHasErrors(['brandSlugs.0']);
});
it('edits an existing reference and keeps both languages', function () {
$this->actingAs(User::factory()->create());
$reference = CmsReference::factory()->create(['title' => ['de' => 'Küche', 'en' => 'Kitchen']]);
Volt::test('admin.cms.references-index')
->call('openEdit', $reference->id)
->assertSet('referenceTitle', 'Küche')
->set('referenceTitle', 'Küche in Hiddenhausen')
->call('save')
->assertHasNoErrors();
$reference->refresh();
expect($reference->getTranslation('title', 'de'))->toBe('Küche in Hiddenhausen')
->and($reference->getTranslation('title', 'en'))->toBe('Kitchen');
});
it('reorders and removes gallery images', function () {
$this->actingAs(User::factory()->create());
$reference = CmsReference::factory()->withGallery(3)->create();
Volt::test('admin.cms.references-index')
->call('openEdit', $reference->id)
->call('moveGalleryImage', 2, -1)
->call('moveGalleryImage', 0, -1)
->call('removeGalleryImage', 2)
->call('save')
->assertHasNoErrors();
expect(array_column($reference->fresh()->gallery, 'image'))
->toBe(['referenzen/bild-1.webp', 'referenzen/bild-3.webp']);
});
it('toggles the published state and deletes', function () {
$this->actingAs(User::factory()->create());
$reference = CmsReference::factory()->create();
Volt::test('admin.cms.references-index')
->call('togglePublished', $reference->id);
expect($reference->fresh()->is_published)->toBeFalse();
Volt::test('admin.cms.references-index')
->call('deleteReference', $reference->id);
expect(CmsReference::count())->toBe(0);
});
it('is linked from the sidebar while the real estate projects are not', function () {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
$user = User::factory()->create();
$user->assignRole('Admin');
$html = $this->actingAs($user)
->get(route('cms.references.index'))
->assertSuccessful()
->getContent();
expect($html)->toMatch('#data-content>\s*Referenzen\s*</div>#')
->not->toContain(route('cms.projects.index'));
});

View file

@ -52,8 +52,8 @@ test('cms dashboard renders for authenticated users', function () {
test('cms dashboard shows correct stats', function () {
$user = User::factory()->create();
CmsProject::factory()->published()->count(2)->create();
CmsProject::factory()->unpublished()->create();
\App\Models\CmsReference::factory()->published()->count(2)->create();
\App\Models\CmsReference::factory()->unpublished()->create();
Volt::actingAs($user)
->test('admin.cms.dashboard')
@ -408,3 +408,129 @@ test('cms articles index can toggle published status', function () {
expect($article->refresh()->is_published)->toBeFalse();
});
// ========================================
// Inhalte-Editor: Seitenbaum, Beschriftungen, Vorschau (Nachtrag 12)
// ========================================
test('cms content index shows the pages of the website instead of raw groups', function () {
$user = User::factory()->create();
CmsContent::create([
'group' => 'home',
'key' => 'hero',
'type' => 'json',
'value' => ['de' => ['title' => 'Test']],
]);
Volt::actingAs($user)
->test('admin.cms.content-index')
->assertSee('Startseite')
->assertSee('Aus der Region gefertigt')
->assertSee('Auf jeder Seite')
->assertSee('Rechtliches')
->assertDontSee('Nicht mehr verwendet');
});
test('cms content index selects one section and labels its fields in plain language', function () {
$user = User::factory()->create();
$hero = CmsContent::create([
'group' => 'home',
'key' => 'hero',
'type' => 'json',
'value' => ['de' => ['title' => 'Hero Title', 'button_text' => 'Los']],
]);
CmsContent::create([
'group' => 'home',
'key' => 'sponsoring',
'type' => 'json',
'value' => ['de' => ['title' => 'Sponsoring Title']],
]);
$component = Volt::actingAs($user)
->test('admin.cms.content-index')
->call('selectSection', 'home', 'hero')
->assertSet('selectedGroup', 'home')
->assertSet('selectedKey', 'hero')
->assertSee('Überschrift')
->assertSee('Button-Beschriftung')
->assertSee('Hero Title')
->assertDontSee('Sponsoring Title');
expect($component->instance()->previewUrl)->toEndWith('/?cms-preview=0#hero');
$component->call('startFieldEdit', $hero->id, 'title')
->set('editValue', 'Neu')
->call('saveEdit')
->assertSet('previewVersion', 1);
});
test('cms content index lists unregistered rows under "not used any more"', function () {
$user = User::factory()->create();
CmsContent::create([
'group' => 'netzwerk',
'key' => 'teasers',
'type' => 'json',
'value' => ['de' => ['title' => 'Alt']],
]);
Volt::actingAs($user)
->test('admin.cms.content-index')
->assertSee('Nicht mehr verwendet')
->assertSee('netzwerk / teasers')
->call('selectSection', 'netzwerk', 'teasers')
->assertSee('Alt');
expect(Volt::actingAs($user)->test('admin.cms.content-index')
->call('selectSection', 'netzwerk', 'teasers')
->instance()->previewUrl)->toBeNull();
});
test('cms content index creates a missing section from the lang template', function () {
$user = User::factory()->create();
expect(CmsContent::where('group', 'global')->where('key', 'appointment_widget')->exists())->toBeFalse();
Volt::actingAs($user)
->test('admin.cms.content-index')
->call('createSection', 'global', 'appointment_widget')
->assertSet('selectedGroup', 'global')
->assertSet('selectedKey', 'appointment_widget');
$row = CmsContent::where('group', 'global')->where('key', 'appointment_widget')->first();
expect($row)->not->toBeNull();
expect($row->getTranslation('value', 'de'))->toBe(cms_theme_section('appointment_widget'));
});
test('cms content index refuses to create a section without a lang template', function () {
$user = User::factory()->create();
Volt::actingAs($user)
->test('admin.cms.content-index')
->call('createSection', 'global', 'contact')
->assertSet('selectedKey', null);
expect(CmsContent::where('group', 'global')->where('key', 'contact')->exists())->toBeFalse();
});
test('cms content index can hide the preview', function () {
$user = User::factory()->create();
CmsContent::create([
'group' => 'home',
'key' => 'hero',
'type' => 'json',
'value' => ['de' => ['title' => 'Test']],
]);
Volt::actingAs($user)
->test('admin.cms.content-index')
->call('selectSection', 'home', 'hero')
->assertSee('Vorschau der Website')
->call('togglePreview')
->assertSet('showPreview', false)
->assertDontSee('Vorschau der Website');
});

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use App\Services\CmsPageTree;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Seitenbaum des Inhalte-Editors (config/cms_pages.php, Nachtrag 12).
*/
function cmsRow(string $group, string $key, array $value = ['title' => 'x']): CmsContent
{
return CmsContent::create([
'group' => $group,
'key' => $key,
'type' => 'json',
'value' => ['de' => $value],
'order' => 0,
]);
}
it('lists the pages in menu order with the homepage sections top to bottom', function () {
$pages = app(CmsPageTree::class)->pages();
expect(array_slice(array_keys($pages), 0, 7))->toBe(['home', 'marken', 'referenzen', 'magazin', 'about', 'contact', 'faq']);
expect($pages['home']['title'])->toBe('Startseite')
->and($pages['home']['path'])->toBe('/');
$keys = array_map(fn (array $s) => $s['group'].'/'.$s['key'], $pages['home']['sections']);
expect(array_slice($keys, 0, 5))->toBe(['home/hero', 'home/founder_bar', 'home/audience_switch', 'home/core_brands', 'home/origin_section'])
->and(array_slice($keys, -2))->toBe(['home/immobilien_teaser', 'global/cta_section']);
});
it('marks sections that exist in the database and leaves the rest without content id', function () {
$hero = cmsRow('home', 'hero');
$pages = app(CmsPageTree::class)->pages();
$sections = collect($pages['home']['sections'])->keyBy('key');
expect($sections['hero']['content_id'])->toBe($hero->id)
->and($sections['founder_bar']['content_id'])->toBeNull()
->and($sections['hero']['theme_key'])->toBe('hero')
->and($sections['hero']['anchor'])->toBe('hero');
});
it('collects database rows that no page lists under "not used any more"', function () {
cmsRow('netzwerk', 'teasers');
cmsRow('home', 'synergie_section');
$pages = app(CmsPageTree::class)->pages();
expect($pages)->toHaveKey(CmsPageTree::UNLISTED);
expect(array_key_last($pages))->toBe(CmsPageTree::UNLISTED);
$titles = array_column($pages[CmsPageTree::UNLISTED]['sections'], 'title');
expect($titles)->toBe(['home / synergie_section', 'netzwerk / teasers']);
});
it('omits the collection point while every row is listed on a page', function () {
cmsRow('home', 'hero');
expect(app(CmsPageTree::class)->pages())->not->toHaveKey(CmsPageTree::UNLISTED);
});
it('resolves a section by group and key', function () {
cmsRow('marken', 'network');
$section = app(CmsPageTree::class)->section('marken', 'network');
expect($section['title'])->toBe('Mehr als Marken: unser Netzwerk')
->and($section['page'])->toBe('marken')
->and($section['path'])->toBe('/marken')
->and($section['content_id'])->not->toBeNull();
expect(app(CmsPageTree::class)->section('marken', 'gibt-es-nicht'))->toBeNull();
});
it('labels fields from the dictionary, a section override or a readable fallback', function () {
config()->set('cms_pages.pages.home.sections.0.fields', ['title' => 'Große Überschrift']);
$tree = app(CmsPageTree::class);
expect($tree->fieldLabel('title', 'home', 'hero'))->toBe('Große Überschrift')
->and($tree->fieldLabel('title', 'home', 'sponsoring'))->toBe('Überschrift')
->and($tree->fieldLabel('button_text'))->toBe('Button-Beschriftung')
->and($tree->fieldLabel('some_unknown_key'))->toBe('Some unknown key');
});
it('builds the preview url from the frontend domain, path, version and anchor', function () {
config()->set('domains.protocol', 'https://');
config()->set('domains.domain_b2in', 'b2in.test');
$tree = app(CmsPageTree::class);
expect($tree->previewUrl($tree->section('home', 'origin_section'), 3))
->toBe('https://b2in.test/?cms-preview=3#herkunft');
expect($tree->previewUrl($tree->section('faq', 'faq_immobilien')))
->toBe('https://b2in.test/immobilien/faq?cms-preview=0#faq');
expect($tree->previewUrl($tree->section('magazin', 'detail')))
->toBe('https://b2in.test/magazin?cms-preview=0');
expect($tree->previewUrl(null))->toBeNull();
expect($tree->previewUrl(['path' => null]))->toBeNull();
});
it('finds the lang template for every registered section that has one', function () {
$tree = app(CmsPageTree::class);
foreach (config('cms_pages.pages') as $page) {
foreach ($page['sections'] as $section) {
$themeKey = $tree->themeKey($section['group'], $section['key']);
// Kontaktdaten und die rechtlichen Seiten liegen nur in der Datenbank (CmsLegalSeeder), nicht in b2in.php.
if ($section['group'] === 'legal' || ($section['group'] === 'global' && $section['key'] === 'contact')) {
expect($themeKey)->toBeNull();
continue;
}
expect($themeKey)->not->toBeNull("Kein Theme-Key für {$section['group']}/{$section['key']}");
expect(cms_theme_section($themeKey))->toBeArray("Keine Lang-Vorlage für {$themeKey}");
}
}
});
it('renders an anchor on the website for every section the registry points to', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$expected = [];
foreach (config('cms_pages.pages') as $page) {
foreach ($page['sections'] as $section) {
$path = $section['path'] ?? $page['path'] ?? null;
if ($path === null || empty($section['anchor'])) {
continue;
}
$expected[$path][$section['anchor']] = true;
}
}
foreach ($expected as $path => $anchors) {
$html = $this->get($path)->assertSuccessful()->getContent();
foreach (array_keys($anchors) as $anchor) {
expect(str_contains($html, 'id="'.$anchor.'"'))->toBeTrue("Anker #{$anchor} fehlt auf {$path}");
}
}
});

View file

@ -3,6 +3,10 @@
declare(strict_types=1);
use App\Livewire\Web\Components\Sections\OriginSection;
use App\Models\CmsBrand;
use Database\Seeders\BrandOriginSeeder;
use Database\Seeders\CmsBrandSeeder;
use Database\Seeders\OriginSectionCmsSeeder;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -10,7 +14,10 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3).
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
*
* Die Standorte kommen aus den Marken (origin_city, origin_distance, origin_role),
* die Kartenpositionen aus OriginSection::MAP_POSITIONS.
*/
function originBody(string $html): string
{
@ -19,7 +26,21 @@ function originBody(string $html): string
return substr($html, (int) strpos($html, '<main'));
}
it('shows the four locations on the homepage', function () {
function originSection(string $html): string
{
$html = originBody($html);
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
return substr($section, 0, (int) strpos($section, '</section>'));
}
// ========================================
// Standorte aus den Marken
// ========================================
it('shows the four locations from the seeded brands on the homepage', function () {
$this->seed(CmsBrandSeeder::class);
$this->get('/')
->assertSuccessful()
->assertSee('id="herkunft"', false)
@ -27,9 +48,96 @@ it('shows the four locations on the homepage', function () {
->assertSee('Bünde', false)
->assertSee('Herford')
->assertSee('Rheda-Wiedenbrück', false)
->assertSee('Köln', false);
->assertSee('Köln', false)
->assertSee('rund 200 km');
});
it('links every location to its brand page', function () {
$this->seed(CmsBrandSeeder::class);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
expect($section)->toContain('href="'.$link.'"');
}
});
it('drops an unpublished brand from the locations entirely', function () {
$this->seed(CmsBrandSeeder::class);
CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
// „Herford“ steht auch im Alt-Text der Karte – geprüft wird die Kachel.
expect($section)->toContain('href="/marken/cabinet"')
->toContain('wire:key="origin-cabinet"')
->not->toContain('wire:key="origin-februe"')
->not->toContain('/marken/februe');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
});
it('leaves out a brand whose origin city is empty', function () {
$this->seed(CmsBrandSeeder::class);
CmsBrand::query()->where('slug', 'februe')->update(['origin_city' => ['de' => '', 'en' => '']]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->toContain('wire:key="origin-rotpunkt"')
->not->toContain('wire:key="origin-februe"');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
});
it('shows a brand without a map position in the tiles only', function () {
$this->seed(CmsBrandSeeder::class);
CmsBrand::factory()->create([
'slug' => 'neue-marke',
'name' => ['de' => 'Neue Marke'],
'origin_city' => ['de' => 'Verl'],
'is_published' => true,
'order' => 9,
]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->toContain('Verl')
->toContain('wire:key="origin-neue-marke"');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
});
it('uses the lang fallback brands without links while no brand is maintained', function () {
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->toContain('Bünde')
->toContain('Köln')
->not->toContain('href="/marken/');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
});
it('draws Bielefeld as the hub with one spoke per location', function () {
$this->seed(CmsBrandSeeder::class);
$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('hides map and tiles but keeps the steps when no brand has an origin', function () {
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null, 'is_published' => true]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->not->toContain('origin-map')
->toContain('Fertigung in Köln');
});
// ========================================
// Platzierung und Ablauf-Block
// ========================================
it('sits between the core brands and the founder story on the homepage', function () {
$html = originBody($this->get('/')->assertSuccessful()->getContent());
@ -43,7 +151,7 @@ it('sits between the core brands and the founder story on the homepage', functio
});
it('appears on the brands page after the brand overview and before the network', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$this->seed(CmsBrandSeeder::class);
$html = originBody($this->get('/marken')->assertSuccessful()->getContent());
@ -56,50 +164,6 @@ it('appears on the brands page after the brand overview and before the network',
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()
@ -109,17 +173,16 @@ it('separates manufacturing from sales for CABINET', function () {
->assertSee('Aufbau vor Ort', false);
});
it('prefers the CMS content over the lang fallback', function () {
it('prefers the CMS content for the texts but keeps the locations from the brands', function () {
$this->seed(CmsBrandSeeder::class);
CmsContent::create([
'group' => 'home',
'key' => 'origin_section',
'type' => 'json',
'value' => ['de' => [
'title' => 'Gefertigt in <span class="text-secondary">OWL</span>',
'link_text' => 'Zur Manufaktur',
'hub' => ['city' => 'Bielefeld', 'x' => 400, 'y' => 175],
'locations' => [
['city' => 'Verl', 'brand' => 'Testmarke', 'x' => 300, 'y' => 100],
],
]],
'order' => 0,
]);
@ -127,43 +190,47 @@ it('prefers the CMS content over the lang fallback', function () {
$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');
->assertSee('Zur Manufaktur')
->assertSee('Rheda-Wiedenbrück', false);
});
it('renders without the steps block when the CMS leaves it empty', function () {
Livewire::test(OriginSection::class)
->set('content', ['title' => 'Herkunft', 'locations' => [], 'steps' => []])
->set('content', ['title' => 'Herkunft', 'steps' => []])
->assertSee('Herkunft')
->assertDontSee('<ol', false);
});
it('creates the CMS section only when it is missing', function () {
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
// ========================================
// Seeder
// ========================================
it('creates the CMS section only when it is missing and strips the legacy locations', function () {
$this->seed(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);
expect($row->getTranslation('value', 'de'))->not->toHaveKey('locations');
$row->update(['value' => ['de' => ['title' => 'Vom Admin geändert']]]);
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
$row->update(['value' => ['de' => [
'title' => 'Vom Admin geändert',
'locations' => [['city' => 'Alt', 'brand' => 'Alt']],
]]]);
$this->seed(OriginSectionCmsSeeder::class);
expect($row->fresh()->getTranslation('value', 'de')['title'])->toBe('Vom Admin geändert');
$value = $row->fresh()->getTranslation('value', 'de');
expect($value['title'])->toBe('Vom Admin geändert');
expect($value)->not->toHaveKey('locations');
});
it('fills the origin of existing brands only where it is empty', function () {
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null]);
CmsBrand::factory()->create(['slug' => 'rotpunkt', 'origin_city' => ['de' => 'Eigener Ort']]);
$this->seed(BrandOriginSeeder::class);
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_city', 'de'))->toBe('Köln');
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_distance', 'en'))->toBe('about 200 km');
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_city', 'de'))->toBe('Eigener Ort');
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_distance', 'de', false))->toBeEmpty();
});

View file

@ -73,3 +73,65 @@ it('says so plainly when there is nothing to show yet', function () {
->assertSee('sobald wir sie zeigen dürfen', false)
->assertSee('Termin anfragen');
});
// ========================================
// Eigene Referenzen aus dem Portal (Nachtrag 13)
// ========================================
it('prefers maintained references over the brand galleries', function () {
CmsBrand::where('slug', 'cabinet')->firstOrFail()->update([
'gallery' => [['image' => 'a.webp', 'category' => 'project', 'caption' => 'Aus der Galerie']],
]);
\App\Models\CmsReference::factory()->forBrands(['cabinet', 'tischlerei'])->withGallery(2)->create([
'title' => ['de' => 'Ankleide unter der Dachschräge'],
'location' => 'Bielefeld',
'year' => '2025',
'description' => ['de' => 'Maßschrank bis unter den First.'],
'image' => 'referenzen/ankleide.webp',
]);
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
expect($html)->toContain('Ankleide unter der Dachschräge')
->toContain('Bielefeld · 2025')
->toContain('Maßschrank bis unter den First.')
->toContain('referenzen/bild-1.webp')
->toContain('href="/marken/cabinet"')
->toContain('href="/marken/tischlerei"')
->not->toContain('Aus der Galerie');
});
it('hides unpublished references and falls back to the galleries when none is published', function () {
CmsBrand::where('slug', 'rotpunkt')->firstOrFail()->update([
'gallery' => [['image' => 'c.webp', 'category' => 'project', 'caption' => 'Küche in Hiddenhausen']],
]);
\App\Models\CmsReference::factory()->unpublished()->create(['title' => ['de' => 'Noch nicht freigegeben']]);
$this->get('/referenzen')
->assertSuccessful()
->assertDontSee('Noch nicht freigegeben')
->assertSee('Küche in Hiddenhausen', false);
});
it('drops a brand that is unpublished and skips a reference without any image', function () {
CmsBrand::where('slug', 'februe')->firstOrFail()->update(['is_published' => false]);
\App\Models\CmsReference::factory()->forBrands(['februe', 'cabinet'])->create(['title' => ['de' => 'Büro in Herford']]);
\App\Models\CmsReference::factory()->create(['title' => ['de' => 'Ohne Bild'], 'image' => null, 'gallery' => []]);
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
expect($html)->toContain('Büro in Herford')
->toContain('href="/marken/cabinet"')
->not->toContain('/marken/februe')
->not->toContain('Ohne Bild');
});
it('uses the first gallery image as cover when no cover is set', function () {
\App\Models\CmsReference::factory()->withGallery(2)->create(['title' => ['de' => 'Nur Galerie'], 'image' => null]);
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
expect($html)->toContain('Nur Galerie')
->toContain('referenzen/bild-1.webp');
expect(substr_count($html, 'referenzen/bild-1.webp'))->toBe(1);
});