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>
481 lines
16 KiB
PHP
481 lines
16 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';
|
||
|
||
/** 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,
|
||
];
|
||
}
|
||
}
|