b2in/app/Models/CmsBrand.php
Kevin Adametz 8ddc85422e Englische Fassung der Markenseiten: Merkmale, Leistungen, Abschnitte, Showroom, Bildunterschriften
Diese Markenfelder waren nur einsprachig gespeichert und erschienen auf der englischen Seite
deutsch. Neue Spalte cms_brands.field_translations (additiv) trägt die Übersetzungen je Sprache,
die bisherigen Spalten bleiben die deutsche Fassung; ohne Übersetzung gilt weiter Deutsch.
Marken-Editor bearbeitet die Felder über den DE/EN-Umschalter (Bilder und Rubriken bleiben
sprachübergreifend). BrandTranslationsSeeder spielt die englischen Texte der vier Kernmarken
ein, ohne gepflegte Übersetzungen zu überschreiben. php artisan cms:untranslated listet
Inhalte, die in einer Sprache noch fehlen oder deutsch sind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:03 +00:00

481 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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';
/** Einblick in Werkstatt, Fertigung oder Montage des Herstellers – weder Projekt noch Entwurf (Nachtrag 19). */
public const GALLERY_COMPANY = 'company';
/** @var list<string> */
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<string> */
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<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
{
// 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<array{title: string, paragraphs: list<string>, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list<string>}|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<string> $paragraphs
* @return list<array{type: 'paragraph', text: string}|array{type: 'list', items: list<array{lead: string, text: string}>}>
*/
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<string>
*/
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<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, Einblicken in die Werkstatt und Inspirationen getrennt.
*
* @param mixed $gallery
* @return array{projects: list<array{image: string, category: string, caption: string}>, company: 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,
)),
'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<string, mixed>
*/
/**
* 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<array{image: string, category: string, caption: string}>
*/
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,
];
}
}