CMS: Standorte an der Marke, Seitenbaum mit Vorschau, Referenzen-Modul

Nachtrag 11: Herkunfts-Sektion liest Ort, Entfernung und Rolle aus der
Markenverwaltung (drei neue Felder an cms_brands, BrandOriginSeeder),
Kartenpositionen im Code, „Marken“ in der Sidebar.

Nachtrag 12: Inhalte-Editor zeigt die Seiten der Website mit ihren
Sektionen in Reihenfolge (config/cms_pages.php, CmsPageTree), Felder in
Klartext, fehlende Sektionen aus der Sprachdatei anlegbar, Vorschau der
echten Seite im iframe mit Sprung zur Sektion; Anker an allen lebenden
Sektionen nachgezogen.

Nachtrag 13: Referenzen als eigenes Modul (CmsReference, Portal-Seite,
/referenzen mit Fallback auf die Markengalerien); Immobilien-Projekte
aus Sidebar und CMS-Übersicht genommen, Route bleibt.

Dazu .gitignore für _b2in_interior_shop/ und docs/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 14:58:45 +00:00
parent 7953a8c9a1
commit 90aa079cf7
50 changed files with 2580 additions and 324 deletions

View file

@ -6,11 +6,30 @@ use App\Models\CmsBrand;
use Livewire\Component;
/**
* „Aus der Region gefertigt“: die vier Standorte der Kernmarken rund um Bielefeld
* (Arbeitsplan 14.09.2026, Punkt 1.3). Inhalte aus dem CMS, Fallback Lang-Datei.
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
* (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
*
* Überschrift, Karte, Drehscheibe und Ablauf-Block kommen aus dem CMS (Fallback Lang-Datei).
* Die Standorte kommen aus den veröffentlichten Marken: Ort, Entfernung und Rolle pflegt
* Marcel in der Markenverwaltung. Die Positionen in der schematischen Karte gehören zur
* Zeichnung und bleiben hier im Code.
*/
class OriginSection extends Component
{
/**
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
*
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
*
* @var array<string, array{x: int, y: int, label: string}>
*/
public const MAP_POSITIONS = [
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
];
/** @var array<string, mixed> */
public array $content = [];
@ -24,58 +43,39 @@ class OriginSection extends Component
}
/**
* Standorte mit ausgefüllten Pflichtfeldern, damit ein leerer CMS-Eintrag die Karte nicht zerreißt.
* Veröffentlichte Marken mit gepflegtem Fertigungsort, in der Reihenfolge der Marken.
*
* Der Link zur Markenseite kommt nicht aus dem CMS, sondern aus den veröffentlichten Marken:
* eine unveröffentlichte Marke bekommt keinen Link, ihr Standort bleibt aber sichtbar.
* Eine unveröffentlichte Marke fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
*
* @return array<int, array<string, mixed>>
* @return list<array{slug: string, city: string, brand: string, role: string, distance: string, link: ?string, map: ?array{x: int, y: int, label: string}}>
*/
public function getLocationsProperty(): array
{
$locations = $this->content['locations'] ?? [];
if (! is_array($locations)) {
return [];
}
$brands = CmsBrand::forFrontend();
$out = [];
foreach ($locations as $location) {
if (! is_array($location) || empty($location['brand']) || empty($location['city'])) {
foreach (CmsBrand::forFrontend() as $slug => $brand) {
$city = trim((string) ($brand['origin_city'] ?? ''));
$name = trim((string) ($brand['name'] ?? ''));
if ($city === '' || $name === '') {
continue;
}
$location['link'] = $this->brandLink($location, $brands);
$out[] = $location;
$out[] = [
'slug' => (string) $slug,
'city' => $city,
'brand' => $name,
'role' => trim((string) ($brand['origin_role'] ?? '')),
'distance' => trim((string) ($brand['origin_distance'] ?? '')),
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
'map' => self::MAP_POSITIONS[$slug] ?? null,
];
}
return $out;
}
/**
* @param array<string, mixed> $location
* @param array<string, array<string, mixed>> $brands
*/
private function brandLink(array $location, array $brands): ?string
{
$slug = $location['brand_slug'] ?? null;
if (is_string($slug) && isset($brands[$slug])) {
return '/marken/'.$slug;
}
foreach ($brands as $brandSlug => $brand) {
if (($brand['name'] ?? null) === $location['brand']) {
return '/marken/'.$brandSlug;
}
}
return null;
}
public function render()
{
return view('livewire.web.components.sections.origin-section');

View file

@ -3,14 +3,16 @@
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use App\Models\CmsReference;
use Livewire\Component;
/**
* Realisierte Projekte aus den Markengalerien, über alle Marken hinweg gesammelt.
* Realisierte Projekte auf /referenzen.
*
* Keine zweite Pflegestelle: Marcel pflegt die Bilder weiterhin je Marke im Kernmarken-Modul.
* Hier erscheinen ausschließlich Einträge der Rubrik „Realisiertes Projekt" – Visualisierungen
* und Entwürfe bleiben bewusst draußen, damit „Referenzen" hält, was das Wort verspricht.
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
* die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien, über alle Marken
* hinweg gesammelt. Visualisierungen und Entwürfe bleiben in beiden Fällen draußen.
*/
class ReferenceProjects extends Component
{
@ -30,13 +32,56 @@ class ReferenceProjects extends Component
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
$this->limit = $limit;
$this->projects = $this->collectProjects();
$projects = $this->fromReferences();
if ($projects === []) {
$projects = $this->fromBrandGalleries();
}
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
}
/**
* @return array<int, array<string, mixed>>
*/
private function collectProjects(): array
private function fromReferences(): array
{
$references = CmsReference::query()->published()->ordered()->get();
if ($references->isEmpty()) {
return [];
}
$brands = CmsBrand::forFrontend();
$projects = [];
foreach ($references as $reference) {
$gallery = CmsReference::normalizeGallery($reference->gallery);
$image = (string) ($reference->image ?: ($gallery[0]['image'] ?? ''));
if ($image === '') {
continue;
}
$projects[] = [
'key' => 'ref-'.$reference->id,
'image' => $image,
'caption' => (string) $reference->title,
'meta' => implode(' · ', array_filter([$reference->location, $reference->year])),
'description' => (string) $reference->description,
'gallery' => array_values(array_filter($gallery, fn (array $item) => $item['image'] !== $image)),
'brands' => $reference->resolveBrands($brands),
];
}
return $projects;
}
/**
* @return array<int, array<string, mixed>>
*/
private function fromBrandGalleries(): array
{
$projects = [];
@ -46,14 +91,19 @@ class ReferenceProjects extends Component
'key' => $slug.'-'.$index,
'image' => $item['image'],
'caption' => $item['caption'],
'brand' => (string) ($brand['name'] ?? ''),
'brand_slug' => $slug,
'has_detail_page' => (bool) ($brand['has_detail_page'] ?? false),
'meta' => '',
'description' => '',
'gallery' => [],
'brands' => [[
'slug' => $slug,
'name' => (string) ($brand['name'] ?? ''),
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
]],
];
}
}
return $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
return $projects;
}
public function render()

View file

@ -45,6 +45,9 @@ class CmsBrand extends Model
'gallery',
'website_url',
'showroom',
'origin_city',
'origin_distance',
'origin_role',
'is_published',
'order',
];
@ -59,6 +62,9 @@ class CmsBrand extends Model
'widget_label',
'cta_title',
'cta_text',
'origin_city',
'origin_distance',
'origin_role',
];
protected function casts(): array
@ -139,6 +145,9 @@ class CmsBrand extends Model
'gallery' => [],
'website_url' => '',
'showroom' => '',
'origin_city' => '',
'origin_distance' => '',
'origin_role' => '',
'has_detail_page' => false,
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
@ -311,6 +320,9 @@ class CmsBrand extends Model
'gallery' => self::normalizeGallery($this->gallery),
'website_url' => $this->website_url,
'showroom' => $this->showroom,
'origin_city' => (string) $this->origin_city,
'origin_distance' => (string) $this->origin_distance,
'origin_role' => (string) $this->origin_role,
'has_detail_page' => true,
];
}

115
app/Models/CmsReference.php Normal file
View file

@ -0,0 +1,115 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* Referenzprojekt der Interior-Welt: ein realisierter Auftrag mit Titelbild, weiteren Bildern
* und den beteiligten Kernmarken (Nachtrag 13). Erscheint auf /referenzen.
*
* Nicht zu verwechseln mit {@see CmsProject} – das sind die Immobilienprojekte.
*/
class CmsReference extends Model
{
use HasFactory, HasTranslations;
protected $table = 'cms_references';
protected $fillable = [
'title',
'location',
'year',
'description',
'image',
'gallery',
'brand_slugs',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = ['title', 'description'];
protected function casts(): array
{
return [
'gallery' => 'array',
'brand_slugs' => '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');
}
/**
* Galerie in eine feste Form bringen: nur Einträge mit Bild, Bildunterschrift getrimmt.
*
* @param mixed $gallery
* @return list<array{image: string, caption: string}>
*/
public static function normalizeGallery($gallery): array
{
if (! is_array($gallery)) {
return [];
}
$out = [];
foreach ($gallery as $item) {
$image = trim((string) (is_array($item) ? ($item['image'] ?? '') : $item));
if ($image === '') {
continue;
}
$out[] = [
'image' => $image,
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
];
}
return $out;
}
/**
* Beteiligte Marken als Liste mit Name und Link, aufgelöst gegen die veröffentlichten Marken.
*
* Eine unveröffentlichte oder gelöschte Marke fällt heraus.
*
* @param array<string, array<string, mixed>> $brands Ergebnis von CmsBrand::forFrontend()
* @return list<array{slug: string, name: string, link: ?string}>
*/
public function resolveBrands(array $brands): array
{
$out = [];
foreach ($this->brand_slugs ?? [] as $slug) {
$slug = (string) $slug;
if (! isset($brands[$slug])) {
continue;
}
$out[] = [
'slug' => $slug,
'name' => (string) ($brands[$slug]['name'] ?? $slug),
'link' => ! empty($brands[$slug]['has_detail_page']) ? '/marken/'.$slug : null,
];
}
return $out;
}
}

View file

@ -0,0 +1,187 @@
<?php
namespace App\Services;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Support\Str;
/**
* Seitenbaum für den Inhalte-Editor (config/cms_pages.php, Nachtrag 12).
*
* Verbindet die Registry mit dem Datenbestand: je Sektion, ob sie in flux_cms_contents liegt
* (dann mit `content_id`), und was sonst noch in der Datenbank steht, aber in keiner Seite
* eingetragen ist („Nicht mehr verwendet“).
*/
class CmsPageTree
{
/** Schlüssel des Sammelpunkts für nicht eingetragene Sektionen. */
public const UNLISTED = 'unlisted';
/**
* Alle Seiten mit ihren Sektionen in Website-Reihenfolge.
*
* @return array<string, array{title: string, path: ?string, sections: list<array<string, mixed>>}>
*/
public function pages(): array
{
$rows = CmsContent::query()
->get(['id', 'group', 'key'])
->keyBy(fn (CmsContent $row) => $row->group.'/'.$row->key);
$pages = [];
foreach (config('cms_pages.pages', []) as $pageKey => $page) {
$sections = [];
foreach ($page['sections'] ?? [] as $section) {
$id = $section['group'].'/'.$section['key'];
$sections[] = array_merge($section, [
'page' => $pageKey,
'path' => $section['path'] ?? $page['path'] ?? null,
'anchor' => $section['anchor'] ?? null,
'content_id' => $rows[$id]->id ?? null,
'theme_key' => $this->themeKey($section['group'], $section['key']),
]);
}
$pages[$pageKey] = [
'title' => $page['title'],
'path' => $page['path'] ?? null,
'sections' => $sections,
];
}
$unlisted = $this->unlistedSections($rows->keys()->all(), $rows);
if ($unlisted !== []) {
$pages[self::UNLISTED] = [
'title' => config('cms_pages.unlisted.title', 'Nicht mehr verwendet'),
'path' => null,
'sections' => $unlisted,
];
}
return $pages;
}
/**
* Eine Sektion aus der Registry samt Datenbankbezug, oder null.
*
* @return array<string, mixed>|null
*/
public function section(string $group, string $key): ?array
{
foreach ($this->pages() as $page) {
foreach ($page['sections'] as $section) {
if ($section['group'] === $group && $section['key'] === $key) {
return $section;
}
}
}
return null;
}
/**
* Klartext-Beschriftung eines Feldes: Sektions-Override, dann Wörterbuch, sonst lesbar gemacht.
*/
public function fieldLabel(string $field, ?string $group = null, ?string $key = null): string
{
if ($group !== null && $key !== null) {
foreach (config('cms_pages.pages', []) as $page) {
foreach ($page['sections'] ?? [] as $section) {
if ($section['group'] === $group && $section['key'] === $key && isset($section['fields'][$field])) {
return $section['fields'][$field];
}
}
}
}
$labels = config('cms_pages.field_labels', []);
if (isset($labels[$field])) {
return $labels[$field];
}
return Str::ucfirst(str_replace('_', ' ', $field));
}
/**
* Adresse der Vorschau für eine Sektion: Frontend-Domain, Pfad, Cache-Brecher und Sprungmarke.
*/
public function previewUrl(?array $section, int $version = 0): ?string
{
$path = $section['path'] ?? null;
if ($path === null) {
return null;
}
$url = rtrim(config('domains.protocol', 'https://').config('domains.domain_b2in'), '/')
.'/'.ltrim($path, '/')
.'?cms-preview='.$version;
if (! empty($section['anchor'])) {
$url .= '#'.$section['anchor'];
}
return $url;
}
/**
* Theme-Section-Schlüssel (Lang-Datei) zu Gruppe/Key, für „Aus Vorlage anlegen“.
*/
public function themeKey(string $group, string $key): ?string
{
foreach (config('cms_section_map.sections', []) as $themeKey => $target) {
if ($target === [$group, $key]) {
return $themeKey;
}
}
return null;
}
/**
* @param list<string> $ids
* @param \Illuminate\Support\Collection<string, CmsContent> $rows
* @return list<array<string, mixed>>
*/
private function unlistedSections(array $ids, $rows): array
{
$listed = [];
foreach (config('cms_pages.pages', []) as $page) {
foreach ($page['sections'] ?? [] as $section) {
$listed[$section['group'].'/'.$section['key']] = true;
}
}
$out = [];
foreach ($ids as $id) {
if (isset($listed[$id])) {
continue;
}
$row = $rows[$id];
$out[] = [
'group' => $row->group,
'key' => $row->key,
'page' => self::UNLISTED,
'path' => null,
'anchor' => null,
'title' => $row->group.' / '.$row->key,
'description' => null,
'content_id' => $row->id,
'theme_key' => null,
];
}
usort($out, fn (array $a, array $b) => [$a['group'], $a['key']] <=> [$b['group'], $b['key']]);
return $out;
}
}