Startseite und Heroes - Hero-Slideshow (11 Motive inkl. Febrü) mit Infokarte, Laufzeit-Linie, größeren Klickflächen, Goldrahmen und mobiler Karte unter dem Bild; Zeile über der Überschrift, neue Vertrauenspunkte - Hero-Stil auf Marken, Über uns und Kontakt übertragen, Breadcrumb entfernt - Gründer-Leiste, Vision-Box auf Ocker, Showroom-Display in Box mit „Größer ansehen“/Vollbild - Akzentfarbe #a8741f, größere Serif-Überschriften in der Interior-Welt - Marken: Bildpause mit Zitat; Vereine aktualisiert und verlinkt Inhalte - Lektorat aller angezeigten Texte, /about-Korrekturen, Rechtstexte (DDG, TDDDG, Hosting Hetzner) - Ablauf Maßanfertigung überall einheitlich, Magazin-Beitrag neu, alte Beiträge offline, deutsches Datum/Lesezeit, FAQ Projektausstattung, Otterpohl - Referenzen-Seite per CMS-Schalter ausblendbar CMS und Technik - Tote Sektionen, Felder, Seiten und Komponenten entfernt und archiviert (dev/2026-08-28/archiv-*) - Entwurf /entwurf, Farb-Schalter und /welcome entfernt; Test-Routen nur noch lokal - Seitenbaum-Beschreibungen geprüft, fehlende CMS-Felder ergänzt, Markenliste aus „Kernmarken“ - Admin-Bildvorschauen repariert, verwendete Bilder in die Mediathek übernommen - Seeder für das Deployment siehe dev/2026-08-28/entwicklungsplan.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
9.6 KiB
PHP
200 lines
9.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Livewire\Web\Components\Sections\Hero;
|
||
use Database\Seeders\HomeHeroSlidesSeeder;
|
||
use FluxCms\Core\Models\CmsContent;
|
||
use Livewire\Livewire;
|
||
|
||
/**
|
||
* Slideshow im Hero der Startseite (07.10.2026).
|
||
*/
|
||
function heroHtml(string $html): string
|
||
{
|
||
$start = strpos($html, 'id="hero"');
|
||
|
||
return substr($html, $start, strpos($html, '</section>', $start) - $start);
|
||
}
|
||
|
||
it('runs the eleven slideshow images in the homepage hero', function () {
|
||
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
|
||
|
||
expect(substr_count($hero, 'data-hero-slide='))->toBe(11)
|
||
->and($hero)->toContain('/img/assets/b2in/slideshow/rotpunkt-fenix-house.webp')
|
||
->toContain('/img/assets/b2in/slideshow/rotpunkt-bad-mk04.webp')
|
||
->toContain('aria-roledescription="carousel"')
|
||
->toContain('interval: 7000')
|
||
->toContain('role="tablist"')
|
||
->toContain('Räume aus einer Hand.')
|
||
->not->toContain('hero-room.jpg');
|
||
|
||
// Erstes Bild sofort, die übrigen lazy.
|
||
expect(substr_count($hero, 'loading="lazy"'))->toBe(10);
|
||
});
|
||
|
||
it('changes the info card with the slide and puts the controls below the whole block', function () {
|
||
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
|
||
|
||
expect(substr_count($hero, 'data-hero-card='))->toBe(11)
|
||
->and($hero)->toContain('Ein ganzes Haus, durchgängig eingerichtet')
|
||
->toContain('Ankleide Solitär in Eiche Tano')
|
||
->toContain('Bad mit Waschtisch in Holz');
|
||
|
||
// Die Punkte stehen hinter dem Rahmen, nicht im Bild.
|
||
$frameEnd = strrpos($hero, 'data-hero-card=');
|
||
expect(strpos($hero, 'role="tablist"'))->toBeGreaterThan($frameEnd);
|
||
});
|
||
|
||
it('puts the info card below the image on mobile, over it on desktop, and fades the texts one after another', function () {
|
||
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
|
||
|
||
// Mobil: Bildfläche 4:3 oben, Karte als Streifen darunter. Ab lg schwebt sie unten links.
|
||
// Keine Klasse mit „card“ – die Glass-Flow-Regel [class*="card"] würde sie sonst umstellen.
|
||
expect($hero)->toContain('class="relative aspect-[4/3] w-full shrink-0 lg:absolute lg:inset-0 lg:aspect-auto" data-hero-stage')
|
||
->toMatch('/class="relative bg-white px-5 py-4[^"]*lg:absolute lg:bottom-6 lg:left-6[^"]*" data-hero-info/')
|
||
->not->toMatch('/class="[^"]*card[^"]*" data-hero-info/');
|
||
|
||
// Die Karte steht hinter der Bildfläche, nicht in ihr.
|
||
expect(strpos($hero, 'data-hero-info'))->toBeGreaterThan(strpos($hero, 'data-hero-progress'));
|
||
|
||
expect($hero)
|
||
->toContain('data-[active=true]:opacity-100 data-[active=true]:delay-500');
|
||
|
||
// Nur die erste Karte ist sichtbar; ohne fest gesetzte Zustandsklassen, die Alpine stehen ließe.
|
||
preg_match_all('/data-hero-card="\d+"\s+data-active="(true|false)"/', $hero, $states);
|
||
expect($states[1])->toBe(['true', ...array_fill(0, 10, 'false')])
|
||
->and($hero)->not->toContain('x-bind:class')
|
||
// Die Marke in der Karte trägt den Akzent (#a8741f, 07.10.2026).
|
||
->toContain('<div class="text-base font-medium text-secondary lg:text-xl">B2in Interior</div>');
|
||
|
||
expect(file_get_contents(resource_path('css/web/shared-styles.css')))
|
||
->toContain('box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 20px 5px;');
|
||
});
|
||
|
||
it('uses the label and caption for the alt text when none is maintained', function () {
|
||
expect(Hero::activeSlides([['image' => 'a.webp', 'label' => 'CABINET', 'caption' => 'Ankleide']])[0]['alt'])
|
||
->toBe('CABINET – Ankleide')
|
||
->and(Hero::activeSlides([['image' => 'a.webp', 'caption' => 'Ankleide']])[0]['alt'])->toBe('Ankleide');
|
||
});
|
||
|
||
it('falls back to the fixed card texts for slides without label and caption', function () {
|
||
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
|
||
'title' => 'Titel', 'subtitle' => '', 'cta1_text' => 'Marken', 'cta1_link' => '/marken',
|
||
'card_title' => 'B2in Interior', 'card_text' => 'Einrichtung, <span class="text-secondary">die bleibt.</span>',
|
||
'slides' => [['image' => 'a.webp'], ['image' => 'b.webp', 'label' => 'CABINET', 'caption' => 'Ankleide']],
|
||
]]]);
|
||
|
||
Livewire::test(Hero::class)
|
||
->assertSeeHtml('Einrichtung, <span class="text-secondary">die bleibt.</span>')
|
||
->assertSee('CABINET')
|
||
->assertSee('Ankleide');
|
||
});
|
||
|
||
it('ships existing WebP files for every default slide', function () {
|
||
foreach (trans('b2in.themes.b2in.hero.slides', [], 'de') as $slide) {
|
||
expect(public_path('img/assets/'.$slide['image']))->toBeFile()
|
||
->and($slide['alt'])->not->toBe('');
|
||
}
|
||
});
|
||
|
||
it('skips inactive slides and slides without an image', function () {
|
||
expect(Hero::activeSlides([
|
||
['image' => 'a.webp', 'label' => 'CABINET', 'alt' => 'Ankleide', 'active' => true],
|
||
['image' => 'b.webp', 'label' => 'Rotpunkt', 'active' => false],
|
||
['image' => 'c.webp', 'label' => 'Febrü', 'active' => '0'],
|
||
['image' => '', 'label' => 'Leer'],
|
||
['image' => 'd.webp', 'label' => 'Otterpohl'],
|
||
]))->toBe([
|
||
['image' => 'a.webp', 'alt' => 'Ankleide', 'label' => 'CABINET', 'caption' => ''],
|
||
['image' => 'd.webp', 'alt' => 'Otterpohl', 'label' => 'Otterpohl', 'caption' => ''],
|
||
]);
|
||
});
|
||
|
||
it('falls back to the single image when no slide is active', function () {
|
||
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
|
||
'title' => 'Titel', 'subtitle' => '', 'image' => 'b2in/hero-room.jpg', 'image_alt' => 'Raum',
|
||
'cta1_text' => 'Marken', 'cta1_link' => '/marken', 'card_title' => '', 'card_text' => '',
|
||
'slides' => [['image' => 'x.webp', 'active' => false]],
|
||
]]]);
|
||
|
||
Livewire::test(Hero::class)
|
||
->assertSet('slides', [['image' => 'b2in/hero-room.jpg', 'alt' => 'Raum', 'label' => '', 'caption' => '']])
|
||
->assertDontSee('aria-roledescription="carousel"', false)
|
||
->assertDontSee('role="tablist"', false);
|
||
});
|
||
|
||
it('clamps the interval to 3 to 30 seconds', function (mixed $interval, int $expected) {
|
||
CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
|
||
'title' => 'Titel', 'subtitle' => '', 'cta1_text' => 'Marken', 'cta1_link' => '/marken', 'card_title' => '', 'card_text' => '',
|
||
'interval' => $interval, 'slides' => [['image' => 'a.webp'], ['image' => 'b.webp']],
|
||
]]]);
|
||
|
||
expect(Livewire::test(Hero::class)->instance()->intervalMs)->toBe($expected);
|
||
})->with([[1, 3000], [7, 7000], ['4.5', 4500], [120, 30000]]);
|
||
|
||
it('adds missing card captions to slides that were stored before', function () {
|
||
$row = CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => ['de' => [
|
||
'title' => 'Titel',
|
||
'slides' => [
|
||
['image' => 'b2in/slideshow/rotpunkt-fenix-house.webp', 'label' => 'Rotpunkt Küchen'],
|
||
['image' => 'b2in/slideshow/cabinet-flur-schrank-eiche-tano.webp', 'caption' => 'Eigener Text'],
|
||
['image' => 'eigenes.webp'],
|
||
],
|
||
]]]);
|
||
|
||
$this->seed(HomeHeroSlidesSeeder::class);
|
||
|
||
expect($row->refresh()->getTranslation('value', 'de')['slides'])->toBe([
|
||
['image' => 'b2in/slideshow/rotpunkt-fenix-house.webp', 'label' => 'Rotpunkt Küchen', 'caption' => 'Ein ganzes Haus, durchgängig eingerichtet'],
|
||
['image' => 'b2in/slideshow/cabinet-flur-schrank-eiche-tano.webp', 'caption' => 'Eigener Text'],
|
||
['image' => 'eigenes.webp'],
|
||
]);
|
||
});
|
||
|
||
it('adds the slides to a stored hero once and keeps maintained ones', function () {
|
||
$row = CmsContent::create(['group' => 'home', 'key' => 'hero', 'type' => 'json', 'order' => 0, 'value' => [
|
||
'de' => ['title' => 'Eigener Titel'],
|
||
'en' => ['title' => 'Own title', 'slides' => [['image' => 'eigenes.webp']]],
|
||
]]);
|
||
|
||
$this->seed(HomeHeroSlidesSeeder::class);
|
||
$this->seed(HomeHeroSlidesSeeder::class);
|
||
|
||
$value = $row->refresh()->getTranslations('value');
|
||
|
||
expect($value['de']['title'])->toBe('Eigener Titel')
|
||
->and($value['de']['slides'])->toHaveCount(11)
|
||
->and($value['de']['interval'])->toBe(7)
|
||
->and($value['en']['slides'])->toBe([['image' => 'eigenes.webp']]);
|
||
});
|
||
|
||
it('frames the hero in a gold gradient and sets the slideshow off with a white hairline', function () {
|
||
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
|
||
|
||
expect($hero)->toContain('bg-hero-container hero-frame')
|
||
->toMatch('/rounded-2xl border border-white! bg-muted/');
|
||
|
||
$css = file_get_contents(resource_path('css/web/theme-b2in.css'));
|
||
expect($css)->toContain('.world-interior .variante-glass-flow div.hero-frame')
|
||
->toContain('border: 1.5px solid transparent;')
|
||
->toContain('linear-gradient(to bottom right, hsl(40, 5.65%, 95.65%), hsl(40, 15.95%, 93.9%)) padding-box')
|
||
->toContain(') border-box;');
|
||
});
|
||
|
||
it('shows a progress line in the image and gives the dots a larger click area', function () {
|
||
$hero = heroHtml($this->get('/')->assertSuccessful()->getContent());
|
||
|
||
expect($hero)->toContain('data-hero-progress')
|
||
->toContain('scaleX(${Math.min(elapsed / interval, 1)})')
|
||
->toContain('requestAnimationFrame')
|
||
->not->toContain('setInterval')
|
||
->not->toContain('Höchstens 100 ms');
|
||
|
||
// Elf Punkte, jeder als Klickfläche mit Abstand um den dünnen Balken.
|
||
expect(substr_count($hero, 'data-hero-dot'))->toBe(11)
|
||
->and($hero)->toContain('class="group cursor-pointer px-1 py-4"');
|
||
|
||
expect(file_get_contents(resource_path('css/web/theme-b2in.css')))
|
||
->toContain(".variante-glass-flow button[data-hero-dot]:hover {\n box-shadow: none;");
|
||
});
|