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>
225 lines
6.5 KiB
PHP
225 lines
6.5 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Spatie\Translatable\HasTranslations;
|
||
|
||
/**
|
||
* Kernmarke mit eigener Detailseite (CABINET, Rotpunkt Küchen, Febrü …).
|
||
*
|
||
* Nicht zu verwechseln mit {@see Brand} – das ist die Produkt-/Partner-Marke.
|
||
*/
|
||
class CmsBrand extends Model
|
||
{
|
||
use HasFactory, HasTranslations;
|
||
|
||
/** Tatsaechlich ausgefuehrtes Projekt – ein Foto aus einem echten Auftrag. */
|
||
public const GALLERY_PROJECT = 'project';
|
||
|
||
/** Entwurf, Rendering oder KI-gestuetzte Darstellung – nicht realisiert. */
|
||
public const GALLERY_INSPIRATION = 'inspiration';
|
||
|
||
/** @var list<string> */
|
||
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION];
|
||
|
||
protected $table = 'cms_brands';
|
||
|
||
protected $fillable = [
|
||
'slug',
|
||
'name',
|
||
'tagline',
|
||
'teaser',
|
||
'summary',
|
||
'highlights',
|
||
'description',
|
||
'logo',
|
||
'image',
|
||
'services',
|
||
'gallery',
|
||
'website_url',
|
||
'showroom',
|
||
'is_published',
|
||
'order',
|
||
];
|
||
|
||
/** @var array<string> */
|
||
public array $translatable = [
|
||
'name',
|
||
'tagline',
|
||
'teaser',
|
||
'summary',
|
||
'description',
|
||
];
|
||
|
||
protected function casts(): array
|
||
{
|
||
return [
|
||
'highlights' => 'array',
|
||
'services' => 'array',
|
||
'gallery' => 'array',
|
||
'is_published' => 'boolean',
|
||
'order' => 'integer',
|
||
];
|
||
}
|
||
|
||
public function scopePublished(Builder $query): Builder
|
||
{
|
||
return $query->where('is_published', true);
|
||
}
|
||
|
||
public function scopeOrdered(Builder $query): Builder
|
||
{
|
||
return $query->orderBy('order')->orderBy('id');
|
||
}
|
||
|
||
/**
|
||
* Veröffentlichte Kernmarken fürs Frontend, nach Slug indiziert.
|
||
*
|
||
* Das CMS gewinnt. Solange keine Marke gepflegt ist, greift die Lang-Sektion `core_brands`
|
||
* als Fallback – dieselbe Reihenfolge wie bei cms_theme_section(), damit die Startseite auf
|
||
* einer frischen Installation nicht ohne Kernmarken dasteht.
|
||
*
|
||
* @return array<string, array<string, mixed>>
|
||
*/
|
||
public static function forFrontend(): array
|
||
{
|
||
$brands = static::query()->published()->ordered()->get();
|
||
|
||
if ($brands->isNotEmpty()) {
|
||
return $brands
|
||
->mapWithKeys(fn (self $brand) => [$brand->slug => $brand->toFrontendArray()])
|
||
->all();
|
||
}
|
||
|
||
return static::fallbackFromLang();
|
||
}
|
||
|
||
/**
|
||
* @return array<string, array<string, mixed>>
|
||
*/
|
||
private static function fallbackFromLang(): array
|
||
{
|
||
$section = cms_theme_section('core_brands');
|
||
$brands = is_array($section) ? ($section['brands'] ?? []) : [];
|
||
|
||
$out = [];
|
||
|
||
foreach ($brands as $brand) {
|
||
if (! is_array($brand) || empty($brand['name'])) {
|
||
continue;
|
||
}
|
||
|
||
$slug = $brand['slug'] ?? \Illuminate\Support\Str::slug((string) $brand['name']);
|
||
|
||
$entry = array_merge([
|
||
'slug' => $slug,
|
||
'tagline' => '',
|
||
'teaser' => '',
|
||
'summary' => '',
|
||
'highlights' => [],
|
||
'description' => '',
|
||
'logo' => '',
|
||
'image' => '',
|
||
'services' => [],
|
||
'gallery' => [],
|
||
'website_url' => '',
|
||
'showroom' => '',
|
||
'has_detail_page' => false,
|
||
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
|
||
|
||
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
|
||
|
||
$out[$slug] = $entry;
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Galerie-Eintraege in eine feste Form bringen.
|
||
*
|
||
* Ein Eintrag ohne (oder mit unbekannter) Kategorie gilt als Inspiration, nicht als Projekt:
|
||
* Ein Entwurf, der versehentlich als Referenz durchgeht, waere die schaedlichere Verwechslung.
|
||
*
|
||
* @param mixed $gallery
|
||
* @return list<array{image: string, category: string, caption: string}>
|
||
*/
|
||
public static function normalizeGallery($gallery): array
|
||
{
|
||
if (! is_array($gallery)) {
|
||
return [];
|
||
}
|
||
|
||
$out = [];
|
||
|
||
foreach ($gallery as $item) {
|
||
$image = is_array($item) ? (string) ($item['image'] ?? '') : (string) $item;
|
||
$image = trim($image);
|
||
|
||
if ($image === '') {
|
||
continue;
|
||
}
|
||
|
||
$category = is_array($item) ? (string) ($item['category'] ?? '') : '';
|
||
|
||
$out[] = [
|
||
'image' => $image,
|
||
'category' => in_array($category, self::GALLERY_CATEGORIES, true)
|
||
? $category
|
||
: self::GALLERY_INSPIRATION,
|
||
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
||
];
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Galerie nach realisierten Projekten und Inspirationen getrennt.
|
||
*
|
||
* @param mixed $gallery
|
||
* @return array{projects: list<array{image: string, category: string, caption: string}>, inspirations: list<array{image: string, category: string, caption: string}>}
|
||
*/
|
||
public static function groupGallery($gallery): array
|
||
{
|
||
$items = self::normalizeGallery($gallery);
|
||
|
||
return [
|
||
'projects' => array_values(array_filter(
|
||
$items,
|
||
fn (array $item) => $item['category'] === self::GALLERY_PROJECT,
|
||
)),
|
||
'inspirations' => array_values(array_filter(
|
||
$items,
|
||
fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION,
|
||
)),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function toFrontendArray(): array
|
||
{
|
||
return [
|
||
'id' => $this->id,
|
||
'slug' => $this->slug,
|
||
'name' => $this->name,
|
||
'tagline' => $this->tagline,
|
||
'teaser' => $this->teaser,
|
||
'summary' => $this->summary,
|
||
'highlights' => $this->highlights ?? [],
|
||
'description' => $this->description,
|
||
'logo' => $this->logo,
|
||
'image' => $this->image,
|
||
'services' => $this->services ?? [],
|
||
'gallery' => self::normalizeGallery($this->gallery),
|
||
'website_url' => $this->website_url,
|
||
'showroom' => $this->showroom,
|
||
'has_detail_page' => true,
|
||
];
|
||
}
|
||
}
|