*/ public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION, self::GALLERY_COMPANY]; /** Sprache der Spalten highlights, services, sections, showroom und der Bildunterschriften. */ public const BASE_LOCALE = 'de'; /** * Felder, die nur einsprachig als Spalte existieren und ihre Übersetzungen in `field_translations` * tragen (09.10.2026). `gallery_captions` sind die Bildunterschriften, nach Bild-Datei. */ public const LOCALIZED_FIELDS = ['highlights', 'services', 'sections', 'showroom', 'gallery_captions']; protected $table = 'cms_brands'; protected $fillable = [ 'slug', 'name', 'tagline', 'teaser', 'summary', 'highlights', 'description', 'logo', 'image', 'services', 'sections', 'widget_label', 'cta_title', 'cta_text', 'gallery', 'field_translations', 'website_url', 'showroom', 'origin_city', 'origin_distance', 'origin_role', 'is_published', 'order', ]; /** @var array */ public array $translatable = [ 'name', 'tagline', 'teaser', 'summary', 'description', 'widget_label', 'cta_title', 'cta_text', 'origin_city', 'origin_distance', 'origin_role', ]; protected function casts(): array { return [ 'highlights' => 'array', 'services' => 'array', 'sections' => 'array', 'gallery' => 'array', 'field_translations' => '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 Liste `core_brands.brands` * der Lang-Datei als Fallback, damit die Startseite auf einer frischen Installation nicht ohne * Kernmarken dasteht. * * @return array> */ 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> */ private static function fallbackFromLang(): array { // Direkt aus der Lang-Datei: Im CMS-Eintrag `home.core_brands` steht die Liste nicht // (mehr), dort pflegt man nur Überschrift und Untertitel – die Marken in der Markenverwaltung. $brands = trans('b2in.themes.'.config('app.theme', 'b2in').'.core_brands.brands'); $brands = is_array($brands) ? $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' => [], 'sections' => [], 'widget_label' => '', 'cta_title' => '', 'cta_text' => '', 'gallery' => [], 'website_url' => '', 'showroom' => '', 'origin_city' => '', 'origin_distance' => '', 'origin_role' => '', 'has_detail_page' => false, ], $brand, ['slug' => $slug, 'has_detail_page' => false]); $entry['gallery'] = self::normalizeGallery($entry['gallery']); $entry['sections'] = self::normalizeSections($entry['sections']); $out[$slug] = $entry; } return $out; } /** * Seitenabschnitte in eine feste Form bringen. * * Jeder Abschnitt hat einen Titel und Absätze. Optional dazu: * - `claim` hervorgehobene Schlusszeile * - `quote` Zitat mit `quote_author` * - `detail` Vertiefung, die im Frontend aufklappbar dargestellt wird * * @param mixed $sections * @return list, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list}|null}> */ public static function normalizeSections($sections): array { if (! is_array($sections)) { return []; } $out = []; foreach ($sections as $section) { if (! is_array($section)) { continue; } $paragraphs = self::paragraphList($section['paragraphs'] ?? []); $title = trim((string) ($section['title'] ?? '')); if ($title === '' && $paragraphs === []) { continue; } $detail = null; if (is_array($section['detail'] ?? null)) { $detailParagraphs = self::paragraphList($section['detail']['paragraphs'] ?? []); $detailTitle = trim((string) ($section['detail']['title'] ?? '')); if ($detailTitle !== '' || $detailParagraphs !== []) { $detail = ['title' => $detailTitle, 'paragraphs' => $detailParagraphs]; } } $out[] = [ 'title' => $title, 'paragraphs' => $paragraphs, 'claim' => trim((string) ($section['claim'] ?? '')), 'quote' => trim((string) ($section['quote'] ?? '')), 'quote_author' => trim((string) ($section['quote_author'] ?? '')), 'detail' => $detail, ]; } return $out; } /** * Absätze eines Abschnitts für die Ausgabe gruppieren. * * Ein Absatz, der mit „- “ beginnt, ist ein Listenpunkt; aufeinanderfolgende Punkte bilden * eine Liste. Steht im Punkt ein Doppelpunkt, wird der Teil davor als fette Einleitung * ausgegeben („- Sichere Prüfung & Terminierung: Wir vereinbaren …“). So lässt sich eine * Aufzählung im Backend im normalen Textfeld pflegen. * * @param list $paragraphs * @return list}> */ public static function paragraphBlocks(array $paragraphs): array { $blocks = []; foreach ($paragraphs as $paragraph) { if (! preg_match('/^[-–•]\s+(.+)$/su', $paragraph, $match)) { $blocks[] = ['type' => 'paragraph', 'text' => $paragraph]; continue; } [$lead, $text] = str_contains($match[1], ': ') ? array_map('trim', explode(': ', $match[1], 2)) : ['', trim($match[1])]; $last = array_key_last($blocks); if ($last === null || $blocks[$last]['type'] !== 'list') { $blocks[] = ['type' => 'list', 'items' => []]; $last = array_key_last($blocks); } $blocks[$last]['items'][] = ['lead' => $lead, 'text' => $text]; } return $blocks; } /** * @param mixed $paragraphs * @return list */ private static function paragraphList($paragraphs): array { if (is_string($paragraphs)) { $paragraphs = preg_split('/\R{2,}/', $paragraphs) ?: []; } if (! is_array($paragraphs)) { return []; } return array_values(array_filter(array_map( fn ($p) => trim((string) (is_array($p) ? ($p['text'] ?? '') : $p)), $paragraphs, ))); } /** * 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 */ 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, Einblicken in die Werkstatt und Inspirationen getrennt. * * @param mixed $gallery * @return array{projects: list, company: list, inspirations: list} */ 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, )), 'company' => array_values(array_filter( $items, fn (array $item) => $item['category'] === self::GALLERY_COMPANY, )), 'inspirations' => array_values(array_filter( $items, fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION, )), ]; } /** * Beschriftung des Badges auf dem Galeriebild („Realisiertes Projekt“, „Aus der Werkstatt“, „Visualisierung“). */ public static function galleryBadge(string $category): string { return match ($category) { self::GALLERY_PROJECT => __('ui.brand_gallery_badge_project'), self::GALLERY_COMPANY => __('ui.brand_gallery_badge_company'), default => __('ui.brand_gallery_badge_inspiration'), }; } /** * @return array */ /** * Ein einsprachiges Feld in der gewünschten Sprache: die Übersetzung aus `field_translations`, * sonst die deutsche Spalte. `gallery_captions` liefert Bild-Datei => Unterschrift. */ public function localized(string $field, ?string $locale = null): mixed { $locale ??= app()->getLocale(); $base = $field === 'gallery_captions' ? collect(self::normalizeGallery($this->gallery))->mapWithKeys(fn (array $item) => [$item['image'] => $item['caption']])->all() : $this->getAttribute($field); if ($locale === self::BASE_LOCALE) { return $base; } $translated = $this->field_translations[$locale][$field] ?? null; if ($field === 'gallery_captions') { foreach ($base as $image => $caption) { if (filled($translated[$image] ?? null)) { $base[$image] = (string) $translated[$image]; } } return $base; } return filled($translated) ? $translated : $base; } /** * Schreibt ein einsprachiges Feld: Deutsch in die Spalte, andere Sprachen nach `field_translations`. * Eine Übersetzung, die genau dem deutschen Stand entspricht, wird nicht gespeichert – sonst * gälte deutscher Text als übersetzt. */ public function setLocalized(string $field, string $locale, mixed $value): void { if ($locale === self::BASE_LOCALE) { if ($field !== 'gallery_captions') { $this->setAttribute($field, $value); } return; } $translations = $this->field_translations ?? []; $base = $this->localized($field, self::BASE_LOCALE); if ($field === 'gallery_captions') { $value = array_filter( (array) $value, fn (mixed $caption, string $image): bool => filled($caption) && $caption !== ($base[$image] ?? null), ARRAY_FILTER_USE_BOTH, ); } if (blank($value) || $value === $base) { unset($translations[$locale][$field]); } else { $translations[$locale][$field] = $value; } $translations = array_filter($translations, fn (mixed $fields): bool => filled($fields)); $this->field_translations = $translations === [] ? null : $translations; } /** * Bildergalerie in der gewünschten Sprache (Bilder und Rubriken sind sprachunabhängig). * * @return list */ public function localizedGallery(?string $locale = null): array { $captions = $this->localized('gallery_captions', $locale); return array_map( fn (array $item): array => [...$item, 'caption' => (string) ($captions[$item['image']] ?? $item['caption'])], self::normalizeGallery($this->gallery), ); } 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->localized('highlights') ?? [], 'description' => $this->description, 'logo' => $this->logo, 'image' => $this->image, 'services' => $this->localized('services') ?? [], 'sections' => self::normalizeSections($this->localized('sections')), 'widget_label' => (string) $this->widget_label, 'cta_title' => (string) $this->cta_title, 'cta_text' => (string) $this->cta_text, 'gallery' => $this->localizedGallery(), 'website_url' => $this->website_url, 'showroom' => $this->localized('showroom'), 'origin_city' => (string) $this->origin_city, 'origin_distance' => (string) $this->origin_distance, 'origin_role' => (string) $this->origin_role, 'has_detail_page' => true, ]; } }