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

4
.gitignore vendored
View file

@ -60,3 +60,7 @@ _storage/
dev/immobilien 07-05-2026/AZIZI SharePoint
# Deployment-Archive werden bei Bedarf neu erzeugt
dev/**/deploy/*.tar.gz
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
/_b2in_interior_shop/
/docs/

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;
}
}

294
config/cms_pages.php Normal file
View file

@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
/**
* Seitenbaum für den Inhalte-Editor im Portal (Nachtrag 12).
*
* Der Editor zeigt die CMS-Sektionen nicht mehr nach Datenbank-Gruppe, sondern so, wie sie auf
* der Website stehen: je Seite in der Reihenfolge von oben nach unten, mit Klartext-Titel und
* einem Satz, der sagt, was die Sektion ist. `anchor` ist die Sprungmarke in der Vorschau
* (das `id` des Abschnitts im Frontend).
*
* Eine Sektion, die auf mehreren Seiten steht, ist einmal eingetragen – dort, wo sie zuerst
* auftaucht – und nennt die anderen Seiten in der Beschreibung.
*
* `group`/`key` sind Gruppe und Schlüssel in flux_cms_contents (siehe cms_section_map.php).
* Sektionen, die in der Datenbank liegen, aber hier nicht eingetragen sind, sammelt der Editor
* unter „Nicht mehr verwendet“ – sie gehen also nicht verloren, stehen aber auch nicht im Weg.
*
* `fields` überschreibt Feldbeschriftungen je Sektion; alles andere kommt aus `field_labels`.
*/
return [
/*
|--------------------------------------------------------------------------
| Feldbeschriftungen
|--------------------------------------------------------------------------
| Gilt für Felder auf Sektionsebene und für Felder in Listeneinträgen.
| Unbekannte Schlüssel werden lesbar gemacht (button_text → „Button text“).
*/
'field_labels' => [
'title' => 'Überschrift',
'subtitle' => 'Untertitel',
'headline' => 'Überschrift',
'tagline' => 'Claim',
'text' => 'Text',
'intro' => 'Einleitung',
'description' => 'Beschreibung',
'content' => 'Inhalt',
'lead' => 'Vorspann',
'note' => 'Hinweis',
'label' => 'Beschriftung',
'badge' => 'Kennzeichen',
'eyebrow' => 'Überzeile',
'quote' => 'Zitat',
'author' => 'Zitatgeber',
'name' => 'Name',
'role' => 'Rolle / Funktion',
'city' => 'Ort',
'value' => 'Wert',
'number' => 'Zahl',
'suffix' => 'Zusatz',
'icon' => 'Icon',
'image' => 'Bild',
'image_alt' => 'Bildbeschreibung',
'alt' => 'Bildbeschreibung',
'logo' => 'Logo',
'link' => 'Link',
'url' => 'Adresse (URL)',
'href' => 'Link',
'link_text' => 'Linktext',
'button_text' => 'Button-Beschriftung',
'button_link' => 'Button-Link',
'primary_button' => 'Erster Button',
'secondary_button' => 'Zweiter Button',
'cta_title' => 'Überschrift Abschluss',
'cta_text' => 'Text Abschluss',
'cta_link' => 'Link Abschluss',
'items' => 'Einträge',
'steps' => 'Schritte',
'cards' => 'Karten',
'stats' => 'Kennzahlen',
'features' => 'Merkmale',
'benefits' => 'Vorteile',
'values' => 'Werte',
'links' => 'Links',
'columns' => 'Spalten',
'menu' => 'Menüpunkte',
'navigation' => 'Navigation',
'topics' => 'Themen',
'options' => 'Auswahlmöglichkeiten',
'placeholder' => 'Platzhaltertext',
'meta_title' => 'Seitentitel (Browser, Google)',
'meta_description' => 'Beschreibung für Google',
'email' => 'E-Mail',
'phone' => 'Telefon',
'whatsapp' => 'WhatsApp-Nummer',
'address' => 'Adresse',
'opening_hours' => 'Öffnungszeiten',
'copyright' => 'Copyright-Zeile',
'map_alt' => 'Beschreibung der Karte',
'region_label' => 'Beschriftung der Region',
'distance_suffix' => 'Zusatz hinter der Entfernung',
'hub' => 'Drehscheibe (Bielefeld)',
'steps_title' => 'Überschrift Ablauf-Block',
'steps_text' => 'Einleitung Ablauf-Block',
'x' => 'Position waagerecht (Entwickler)',
'y' => 'Position senkrecht (Entwickler)',
'id' => 'Sprungmarke (Entwickler)',
'slug' => 'Adresskürzel (Entwickler)',
'teaser' => 'Kurztext',
'summary' => 'Zusammenfassung',
'brands' => 'Marken',
'projects' => 'Projekte',
'partners' => 'Partner',
'questions' => 'Fragen',
'question' => 'Frage',
'answer' => 'Antwort',
'read_more' => 'Weiterlesen-Text',
'empty_text' => 'Text, wenn nichts vorhanden ist',
'success_text' => 'Text nach dem Absenden',
'submit_text' => 'Beschriftung Absenden-Button',
'privacy_text' => 'Datenschutz-Hinweis',
'subjects' => 'Betreffe',
'subject' => 'Betreff',
],
/*
|--------------------------------------------------------------------------
| Seiten in der Reihenfolge des Menüs
|--------------------------------------------------------------------------
*/
'pages' => [
'home' => [
'title' => 'Startseite',
'path' => '/',
'sections' => [
['group' => 'home', 'key' => 'hero', 'anchor' => 'hero', 'title' => 'Hero',
'description' => 'Der große Einstieg ganz oben: Überschrift, Text, Buttons und Bild.'],
['group' => 'home', 'key' => 'founder_bar', 'anchor' => 'gruender', 'title' => 'Gründer-Leiste',
'description' => 'Schmale Zeile mit Marcel Scheibe und Zitat. Steht auch auf „Über uns“ und in der Immobilien-Welt.'],
['group' => 'home', 'key' => 'audience_switch', 'anchor' => 'wege', 'title' => 'Zielgruppen-Wahl',
'description' => 'Zwei Wege: Privat einrichten oder Projekt ausstatten.'],
['group' => 'home', 'key' => 'core_brands', 'anchor' => 'marken', 'title' => 'Kernmarken',
'description' => 'Überschrift und Untertitel des Marken-Rasters. Die Kacheln selbst kommen aus der Markenverwaltung.'],
['group' => 'home', 'key' => 'origin_section', 'anchor' => 'herkunft', 'title' => 'Aus der Region gefertigt',
'description' => 'Karte mit Bielefeld als Drehscheibe und der Ablauf-Block am Beispiel CABINET. Die Standorte pflegst du bei den Marken. Steht auch auf /marken.'],
['group' => 'home', 'key' => 'vision_section', 'anchor' => 'vision', 'title' => 'Gebaut auf Expertise',
'description' => 'Die Geschichte hinter B2in: Text, Kennzahlen und Bild.'],
['group' => 'home', 'key' => 'sponsoring', 'anchor' => 'engagement', 'title' => 'Regional verbunden',
'description' => 'Vereine und Organisationen, die B2in unterstützt.'],
['group' => 'referenzen', 'key' => 'display_feed', 'anchor' => 'displays', 'title' => 'Aus den Stores',
'description' => 'Überschrift und Text des Display-Feeds. Die Inhalte kommen aus den Store Displays. Steht auch auf /referenzen.'],
['group' => 'home', 'key' => 'immobilien_teaser', 'anchor' => 'immobilien', 'title' => 'Immobilien-Teaser',
'description' => 'Der Übergang in die Immobilien-Welt am Ende der Startseite.'],
['group' => 'global', 'key' => 'cta_section', 'anchor' => 'cta', 'title' => 'Abschluss: Termin anfragen',
'description' => 'Der letzte Block vor dem Fuß. Steht auf allen Interior-Seiten.'],
],
],
'marken' => [
'title' => 'Marken',
'path' => '/marken',
'sections' => [
['group' => 'marken', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Überschrift, Einleitung und Bild oben auf /marken. Die Markenblöcke darunter kommen aus der Markenverwaltung.'],
['group' => 'marken', 'key' => 'network', 'anchor' => 'netzwerk', 'title' => 'Mehr als Marken: unser Netzwerk',
'description' => 'Partner und Zulieferer jenseits der Kernmarken.'],
['group' => 'marken', 'key' => 'growth', 'anchor' => 'wachstum', 'title' => 'Die Markenwelt wächst',
'description' => 'Hinweis, dass weitere Marken folgen.'],
['group' => 'marken', 'key' => 'cta', 'anchor' => 'marken-cta', 'title' => 'Abschluss auf /marken',
'description' => 'Der Abschlussblock „Lernen Sie unsere Marken kennen“ mit Terminanfrage und WhatsApp.'],
],
],
'referenzen' => [
'title' => 'Referenzen',
'path' => '/referenzen',
'sections' => [
['group' => 'referenzen', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Überschrift und Einleitung der Referenzen-Seite.'],
['group' => 'referenzen', 'key' => 'projects', 'anchor' => 'projekte', 'title' => 'Realisierte Projekte',
'description' => 'Überschrift, Text und Leerzustand über den Projekten. Die Projekte selbst pflegst du unter „Referenzen“.'],
],
],
'magazin' => [
'title' => 'Magazin',
'path' => '/magazin',
'sections' => [
['group' => 'magazin', 'key' => 'list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht',
'description' => 'Überschrift und Einleitung der Übersicht. Die Beiträge pflegst du unter „Magazin“.'],
['group' => 'magazin', 'key' => 'detail', 'anchor' => null, 'title' => 'Beitragsseite',
'description' => 'Feste Texte auf jeder Beitragsseite, etwa „Zurück zur Übersicht“ und „Weitere Beiträge“.'],
],
],
'about' => [
'title' => 'Über uns',
'path' => '/about',
'sections' => [
['group' => 'about', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Überschrift und Einleitung von „Über uns“.'],
['group' => 'about', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Unsere Geschichte',
'description' => 'Der Text zur Entstehung von B2in.'],
['group' => 'about', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
'description' => 'Großes Bild mit kurzer Zeile zwischen Geschichte und Werten.'],
['group' => 'about', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Unsere Werte',
'description' => 'Die Werte-Kacheln.'],
['group' => 'netzwerk', 'key' => 'partner_cta', 'anchor' => 'cta', 'title' => 'Abschluss auf „Über uns“',
'description' => 'Der Abschlussblock am Ende von „Über uns“.'],
],
],
'contact' => [
'title' => 'Kontakt',
'path' => '/contact',
'sections' => [
['group' => 'contact', 'key' => 'contact_form', 'anchor' => 'kontakt', 'title' => 'Kontaktformular',
'description' => 'Überschrift, Betreffe, Feldbeschriftungen und Datenschutz-Hinweis des Formulars.'],
['group' => 'global', 'key' => 'contact', 'anchor' => 'kontakt', 'title' => 'Kontaktdaten',
'description' => 'Telefon, E-Mail, Adresse und die WhatsApp-Nummer. Ohne WhatsApp-Nummer erscheint kein WhatsApp-Button.'],
],
],
'faq' => [
'title' => 'FAQ',
'path' => '/faq',
'sections' => [
['group' => 'faq', 'key' => 'faq', 'anchor' => 'faq', 'title' => 'Fragen und Antworten',
'description' => 'Überschrift und die Liste der Fragen der Interior-Welt.'],
],
],
'global' => [
'title' => 'Auf jeder Seite',
'path' => '/',
'sections' => [
['group' => 'global', 'key' => 'announcement_bar', 'anchor' => 'header', 'title' => 'Hinweisleiste',
'description' => 'Schmale Zeile ganz oben über dem Menü. Leer lassen, um sie auszublenden.'],
['group' => 'global', 'key' => 'header', 'anchor' => 'header', 'title' => 'Hauptmenü',
'description' => 'Menüpunkte und Buttons im Kopf der Interior-Welt.'],
['group' => 'global', 'key' => 'appointment_widget', 'anchor' => null, 'title' => 'Terminanfrage-Widget',
'description' => 'Das schwebende Widget rechts unten: Texte, Themen und Zeitfenster.'],
['group' => 'global', 'key' => 'footer', 'anchor' => 'footer', 'title' => 'Fußbereich',
'description' => 'Spalten, Links, Social-Media und Copyright-Zeile.'],
],
],
'immobilien' => [
'title' => 'Immobilien-Welt',
'path' => '/immobilien',
'sections' => [
['group' => 'global', 'key' => 'announcement_bar_immobilien', 'anchor' => 'header', 'title' => 'Hinweisleiste (Immobilien)',
'description' => 'Hinweisleiste über dem Menü der Immobilien-Welt.'],
['group' => 'global', 'key' => 'header_immobilien', 'anchor' => 'header', 'title' => 'Hauptmenü (Immobilien)',
'description' => 'Menüpunkte und Buttons im Kopf der Immobilien-Welt.'],
['group' => 'global', 'key' => 'cta_section_immobilien', 'anchor' => 'cta', 'title' => 'Abschluss (Immobilien)',
'description' => 'Der letzte Block vor dem Fuß auf allen Immobilien-Seiten.'],
['group' => 'immobilien', 'key' => 'magazin_list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht (Immobilien)',
'description' => 'Überschrift und Einleitung unter /immobilien/magazin.', 'path' => '/immobilien/magazin'],
['group' => 'immobilien', 'key' => 'about_hero', 'anchor' => 'seitenkopf', 'title' => 'Über B2in: Seitenkopf',
'description' => 'Überschrift und Einleitung unter /immobilien/ueber-b2in.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Über B2in: Geschichte',
'description' => 'Der Geschichte-Block der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'about_image_break', 'anchor' => 'bild', 'title' => 'Über B2in: Bildpause',
'description' => 'Bild zwischen Geschichte und Werten.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Über B2in: Werte',
'description' => 'Die Werte-Kacheln der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'faq', 'key' => 'faq_immobilien', 'anchor' => 'faq', 'title' => 'FAQ (Immobilien)',
'description' => 'Fragen und Antworten unter /immobilien/faq.', 'path' => '/immobilien/faq'],
['group' => 'contact', 'key' => 'contact_form_immobilien', 'anchor' => 'kontakt', 'title' => 'Kontaktformular (Immobilien)',
'description' => 'Abweichende Texte und Betreffe unter /immobilien/kontakt. Leere Felder übernehmen den Interior-Wert.', 'path' => '/immobilien/kontakt'],
],
],
'legal' => [
'title' => 'Rechtliches',
'path' => '/impressum',
'sections' => [
['group' => 'legal', 'key' => 'impressum', 'anchor' => null, 'title' => 'Impressum',
'description' => 'Pflichtangaben. Der Inhalt ist HTML.', 'path' => '/impressum'],
['group' => 'legal', 'key' => 'privacy', 'anchor' => null, 'title' => 'Datenschutz',
'description' => 'Datenschutzerklärung. Der Inhalt ist HTML.', 'path' => '/privacy'],
['group' => 'legal', 'key' => 'terms', 'anchor' => null, 'title' => 'AGB',
'description' => 'Allgemeine Geschäftsbedingungen. Der Inhalt ist HTML.', 'path' => '/terms'],
['group' => 'legal', 'key' => 'cookie_policy', 'anchor' => null, 'title' => 'Cookie-Hinweise',
'description' => 'Cookie-Richtlinie. Der Inhalt ist HTML.', 'path' => '/cookie-policy'],
],
],
],
/*
|--------------------------------------------------------------------------
| Sammelpunkt für alles, was in der Datenbank liegt, aber oben nicht steht
|--------------------------------------------------------------------------
*/
'unlisted' => [
'title' => 'Nicht mehr verwendet',
'description' => 'Sektionen stillgelegter Seiten (Netzwerk, Partner, alte Immobilien-Seite). Bleiben als Archiv liegen, erscheinen nirgends.',
],
];

View file

@ -0,0 +1,62 @@
<?php
namespace Database\Factories;
use App\Models\CmsReference;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CmsReference>
*/
class CmsReferenceFactory extends Factory
{
protected $model = CmsReference::class;
public function definition(): array
{
$title = fake()->unique()->words(3, true);
return [
'title' => ['de' => ucfirst($title), 'en' => ucfirst($title)],
'location' => fake()->city(),
'year' => (string) fake()->numberBetween(2022, 2026),
'description' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
'image' => 'referenzen/'.fake()->unique()->slug(2).'.webp',
'gallery' => [],
'brand_slugs' => [],
'is_published' => true,
'order' => fake()->numberBetween(1, 20),
];
}
public function published(): static
{
return $this->state(fn () => ['is_published' => true]);
}
public function unpublished(): static
{
return $this->state(fn () => ['is_published' => false]);
}
/**
* @param list<string> $slugs
*/
public function forBrands(array $slugs): static
{
return $this->state(fn () => ['brand_slugs' => $slugs]);
}
public function withGallery(int $count = 2): static
{
return $this->state(function () use ($count) {
$items = [];
for ($i = 1; $i <= $count; $i++) {
$items[] = ['image' => 'referenzen/bild-'.$i.'.webp', 'caption' => 'Bild '.$i];
}
return ['gallery' => $items];
});
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Fertigungsort je Kernmarke (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
*
* Die Herkunfts-Sektion „Aus der Region gefertigt“ holt ihre Standorte nicht mehr aus einem
* JSON-Eintrag im generischen Inhalte-Editor, sondern aus der Markenverwaltung: Marcel pflegt
* Ort, Entfernung und Rolle direkt bei der Marke. Die Kartenpositionen bleiben im Code.
*
* - `origin_city` Fertigungsort, z. B. „Bünde“ – leer bedeutet: Marke erscheint nicht in der Sektion
* - `origin_distance` Entfernung ab Bielefeld als Text, z. B. „rund 30 km“
* - `origin_role` Ein Satz zur Rolle des Standorts
*
* Alle drei sind übersetzbar wie die übrigen Textfelder der Marke.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->json('origin_city')->nullable()->after('showroom');
$table->json('origin_distance')->nullable()->after('origin_city');
$table->json('origin_role')->nullable()->after('origin_distance');
});
}
public function down(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->dropColumn(['origin_city', 'origin_distance', 'origin_role']);
});
}
};

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Referenzprojekte der Interior-Welt (Nachtrag 13).
*
* AP5 hatte die Referenzen aus den Markengalerien zusammengesammelt, um keine zweite Pflegestelle
* zu schaffen. Abstimmung 15.09.: Marcel soll Referenzen als eigene Einträge pflegen – ein
* realisiertes Projekt hat mehr als ein Bild und mehr als eine Marke. Solange kein Eintrag
* existiert, greift das Frontend weiter auf die Markengalerien zurück.
*
* - `title`, `description` übersetzbar
* - `location`, `year` Ort und Jahr als Text („Bielefeld“, „2025“)
* - `image` Titelbild (Dateiname aus der Medienbibliothek)
* - `gallery` weitere Bilder mit Bildunterschrift
* - `brand_slugs` beteiligte Kernmarken (Slugs aus cms_brands), verlinkt auf die Markenseiten
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('cms_references', function (Blueprint $table) {
$table->id();
$table->json('title');
$table->string('location')->nullable();
$table->string('year', 20)->nullable();
$table->json('description')->nullable();
$table->string('image')->nullable();
$table->json('gallery')->nullable();
$table->json('brand_slugs')->nullable();
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('cms_references');
}
};

View file

@ -0,0 +1,87 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Trägt den Fertigungsort der Kernmarken nach (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
*
* Die Herkunfts-Sektion „Aus der Region gefertigt“ liest Ort, Entfernung und Rolle aus der
* Marke. {@see CmsBrandSeeder} bringt dieselben Werte für frische Installationen mit,
* überspringt aber bereits angelegte Marken. Dieser Seeder fasst nur die drei Felder an und
* auch nur bei Marken, deren Fertigungsort noch leer ist – ein im Admin eingetragener Ort
* bleibt stehen. Mehrfaches Ausführen ist unbedenklich.
*
* Entfernungen gerundet, Straße ab Bielefeld Mitte. ⚠️ Rolle CABINET nach Kevins Verständnis
* (Fertigung Köln, Beratung im Store Bielefeld) – von Marcel bestätigen lassen.
*
* php artisan db:seed --class=BrandOriginSeeder
*/
class BrandOriginSeeder extends Seeder
{
/** @var array<string, array{origin_city: array<string, string>, origin_distance: array<string, string>, origin_role: array<string, string>}> */
private const ORIGINS = [
'cabinet' => [
'origin_city' => ['de' => 'Köln', 'en' => 'Cologne'],
'origin_distance' => ['de' => 'rund 200 km', 'en' => 'about 200 km'],
'origin_role' => [
'de' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
'en' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
],
],
'rotpunkt' => [
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
'origin_role' => [
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
],
],
'februe' => [
'origin_city' => ['de' => 'Herford', 'en' => 'Herford'],
'origin_distance' => ['de' => 'rund 20 km', 'en' => 'about 20 km'],
'origin_role' => [
'de' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
'en' => 'Office and contract furniture from their own production in Herford.',
],
],
'tischlerei' => [
'origin_city' => ['de' => 'Rheda-Wiedenbrück', 'en' => 'Rheda-Wiedenbrück'],
'origin_distance' => ['de' => 'rund 35 km', 'en' => 'about 35 km'],
'origin_role' => [
'de' => 'Unsere Werkstatt für Aufbau, Sonderlösungen und Veredelung.',
'en' => 'Our workshop for assembly, custom solutions and finishing.',
],
],
];
public function run(): void
{
$set = 0;
$kept = 0;
foreach (self::ORIGINS as $slug => $origin) {
$brand = CmsBrand::query()->where('slug', $slug)->first();
if (! $brand) {
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
continue;
}
if (trim((string) $brand->getTranslation('origin_city', 'de', false)) !== '') {
$kept++;
continue;
}
$brand->fill($origin);
$brand->save();
$set++;
}
$this->command?->info("Fertigungsorte gesetzt: {$set}, vorhandene behalten: {$kept}.");
}
}

View file

@ -15,7 +15,8 @@ use Illuminate\Database\Seeder;
* Partnerbetriebs gehört laut Plan auf die Detailseite, nicht in die Marke.
*
* Bestehende Marken werden nicht überschrieben – Marcel pflegt sie im Admin. Für bereits
* angelegte Marken setzt {@see BrandLogoSeeder} die Logos nach.
* angelegte Marken setzt {@see BrandLogoSeeder} die Logos nach und {@see BrandOriginSeeder}
* den Fertigungsort für die Herkunfts-Sektion (Nachtrag 11).
*
* Stimmungsbilder und Galerien fehlen als Zulieferung und bleiben leer; die Kacheln und
* Detailseiten zeigen so lange einen Platzhalter.
@ -77,6 +78,12 @@ Experience the possibilities in our Bielefeld showroom.',
['title' => 'Montage durch Fachleute', 'description' => 'Wir liefern und montieren – bis zum letzten Detail.'],
],
'showroom' => 'Showroom Bielefeld',
'origin_city' => ['de' => 'Köln', 'en' => 'Cologne'],
'origin_distance' => ['de' => 'rund 200 km', 'en' => 'about 200 km'],
'origin_role' => [
'de' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
'en' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
],
'is_published' => true,
],
[
@ -112,6 +119,12 @@ Consultation and planning in our kitchen studio.',
['title' => 'Lieferung und Aufbau', 'description' => 'Koordiniert aus einer Hand, inklusive Anschluss.'],
],
'showroom' => 'Küchenstudio',
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
'origin_role' => [
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
],
'is_published' => true,
],
[
@ -143,6 +156,12 @@ Consultation and planning in our kitchen studio.',
['title' => 'Akustik und Ordnung', 'description' => 'Stauraum und Raumgliederung, die Konzentration ermöglichen.'],
],
'showroom' => '',
'origin_city' => ['de' => 'Herford', 'en' => 'Herford'],
'origin_distance' => ['de' => 'rund 20 km', 'en' => 'about 20 km'],
'origin_role' => [
'de' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
'en' => 'Office and contract furniture from their own production in Herford.',
],
'is_published' => true,
],
[
@ -223,6 +242,12 @@ It complements CABINET and Rotpunkt wherever making to drawing is required: asse
],
],
'showroom' => '',
'origin_city' => ['de' => 'Rheda-Wiedenbrück', 'en' => 'Rheda-Wiedenbrück'],
'origin_distance' => ['de' => 'rund 35 km', 'en' => 'about 35 km'],
'origin_role' => [
'de' => 'Unsere Werkstatt für Aufbau, Sonderlösungen und Veredelung.',
'en' => 'Our workshop for assembly, custom solutions and finishing.',
],
'is_published' => true,
],
];

View file

@ -2,13 +2,21 @@
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Herkunfts-Sektion „Aus der Region gefertigt“ ins CMS legen (Arbeitsplan 14.09.2026, Punkt 1.3).
*
* Legt die Sektion nur an, wenn sie fehlt. Ein bestehender Eintrag bleibt unangetastet,
* damit Korrekturen aus dem Admin (Entfernungen, Ablauf CABINET) nicht verloren gehen.
* Legt die Sektion nur an, wenn sie fehlt. Ein bestehender Eintrag bleibt inhaltlich unangetastet,
* damit Korrekturen aus dem Admin (Ablauf CABINET) nicht verloren gehen.
*
* Einzige Ausnahme (Nachtrag 11): Der Schlüssel `locations` stammt aus dem ersten Stand, als die
* Standorte noch im Sektions-JSON lagen. Seitdem kommen sie aus den Marken
* ({@see BrandOriginSeeder}), das Frontend liest den Schlüssel nicht mehr. Er wird aus einem
* bestehenden Eintrag entfernt, damit er im Inhalte-Editor nicht als Pflegefeld auftaucht.
* Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=OriginSectionCmsSeeder
*/
@ -24,6 +32,38 @@ class OriginSectionCmsSeeder extends Seeder
$seeder->onlySections = self::SECTIONS;
$seeder->run();
$this->dropLegacyLocations();
$this->command?->info('Herkunfts-Sektion: angelegt, falls sie fehlte.');
}
private function dropLegacyLocations(): void
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'origin_section')->first();
if (! $row) {
return;
}
$value = $row->getTranslations('value');
$changed = false;
foreach ($value as $locale => $section) {
if (is_array($section) && array_key_exists('locations', $section)) {
unset($section['locations']);
$value[$locale] = $section;
$changed = true;
}
}
if (! $changed) {
return;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache('home');
$this->command?->info('Herkunfts-Sektion: veralteten Schlüssel „locations“ entfernt – die Standorte kommen aus den Marken.');
}
}

View file

@ -40,7 +40,7 @@ ausführlichen Text. `php artisan test --compact`: **873 grün** (3217 Assertion
| Welten-Trennung: Navigation, Magazin, Über uns, FAQ, Widget je Welt | ✅ (Nachtrag 7, Erweiterung zu 1.1) |
| Arbeitsplan 14.09. · 1.2 Tischlerei als vierte Marke (Platzhalter) | ✅ (Nachtrag 9) |
| Arbeitsplan 14.09. · 1.4 Akkordeon und Widget-Beschriftung je Marke | ✅ (bereits seit 03.09., CABINET-Text fehlt im CMS) |
| Arbeitsplan 14.09. · 1.3 Herkunfts-Sektion „Aus der Region gefertigt“ | ✅ (Nachtrag 10, Ablauf CABINET von Marcel bestätigen lassen) |
| Arbeitsplan 14.09. · 1.3 Herkunfts-Sektion „Aus der Region gefertigt“ | ✅ (Nachtrag 10 + 11: Standorte an der Marke, Ablauf CABINET von Marcel bestätigen lassen) |
### Nächste Schritte
@ -849,7 +849,7 @@ als Datenquelle". → **Einbettung des vorhandenen Players im 9:16-Container.**
**Tests:** `DisplayWebFeedTest` (10 Fälle: Embed liefert die Datei, kein Token im Ausgabe-Inhalt,
404 bei inaktiv/Entwurf/leer/unbekannt, Einbindung auf der Startseite, CMS-Auswahl und Fallback).
### AP5 · Rubrik-Seite „Referenzen" — ✅ umgesetzt
### AP5 · Rubrik-Seite „Referenzen" — ✅ umgesetzt (Pflege seit Nachtrag 13 als eigenes Modul)
**Korrektur gegenüber Revision 1:** Revision 1 schlug einen neuen `DisplayVersionType`-Case vor.
Die Umsetzungsliste sagt „zweites, separates **Display** im Display-CMS" – das ist ein weiterer
@ -1270,9 +1270,14 @@ php artisan db:seed --class=WorldSplitCmsSeeder --force
# 8. Vierte Kernmarke „Tischlerei“ als Platzhalter (Nachtrag 9) – legt nur an, was fehlt.
php artisan db:seed --class=CmsBrandSeeder --force
# 9. Herkunfts-Sektion „Aus der Region gefertigt“ (Nachtrag 10) – legt die Sektion nur an, wenn sie fehlt.
# 9. Herkunfts-Sektion „Aus der Region gefertigt“ (Nachtrag 10) – legt die Sektion nur an, wenn sie
# fehlt. Entfernt aus einem bestehenden Eintrag nur den veralteten Schlüssel „locations“ (Nachtrag 11).
php artisan db:seed --class=OriginSectionCmsSeeder --force
# 10. Fertigungsort je Kernmarke (Nachtrag 11) – setzt Ort, Entfernung und Rolle nur bei Marken,
# deren Ort noch leer ist. Die Migration davor legt die drei Spalten an.
php artisan db:seed --class=BrandOriginSeeder --force
php artisan config:cache
php artisan view:clear
@ -1598,13 +1603,12 @@ Tischlerei?). Die Sektion ist so formuliert, dass sie diesem Verständnis folgt.
Markenübersicht und „Mehr als Marken: unser Netzwerk“. Anker `#herkunft`.
- **Schematische Karte** (SVG, ab `md`): Bielefeld als Drehscheibe in Ocker, die vier Standorte
als Punkte in Tanne mit gestrichelten Speichen und Entfernungsangabe, gestrichelte Ellipse
„Ostwestfalen“ um die drei OWL-Standorte. Keine Geografie, nur Richtung und Entfernung –
Positionen (`x`/`y` in der viewBox 640×400) und Beschriftungsseite (`label`) stehen im
Inhalt und sind im Admin änderbar. Unter `md` entfällt die Karte, die Kacheln tragen alles.
„Ostwestfalen“ um die drei OWL-Standorte. Keine Geografie, nur Richtung und Entfernung.
Positionen und Beschriftungsseite lagen zunächst im Inhalt; seit Nachtrag 11 stehen sie im
Code, die Standorte kommen aus den Marken. Unter `md` entfällt die Karte, die Kacheln tragen alles.
- **Vier Standort-Kacheln** (2×2 ab `sm`): Stadt, Marke, Rolle, Entfernung „von Bielefeld“ und
Link „Zur Marke“. Der Link kommt **nicht** aus dem CMS, sondern aus den veröffentlichten
Marken (`brand_slug`, ersatzweise Namensabgleich): eine unveröffentlichte Marke verliert den
Link, ihr Standort bleibt sichtbar.
Link „Zur Marke“. Ursprünglich lagen die Standorte im Sektions-JSON, der Link kam aus den
veröffentlichten Marken. Seit Nachtrag 11 gilt: Standort = Marke, siehe dort.
- **Ablauf-Block „Gefertigt in Köln, geplant in Bielefeld“**: drei nummerierte Schritte am
Beispiel CABINET (Beratung und Planung in Bielefeld → Fertigung in Köln → Aufbau vor Ort mit
Sonderwünschen aus der Tischlerei). Das ist die saubere Trennung „gefertigt“ / „vertrieben“
@ -1620,4 +1624,169 @@ fehlt, überschreibt nichts). Beim Deployment mit erledigen (Abschnitt 8, Schrit
**Offen:** Bestätigung des CABINET-Ablaufs durch Marcel; Wortlaut der Tischlerei-Kachel folgt
dem Platzhalter aus Nachtrag 9 und wandert mit dem echten Markennamen.
**Tests:** `tests/Feature/OriginSectionTest.php` (12 Fälle).
**Tests:** `tests/Feature/OriginSectionTest.php` (12 Fälle, mit Nachtrag 11 umgeschrieben).
---
## Nachtrag 11 · Standorte an der Marke, Marken in der Sidebar (15.09.2026)
Prüfung der Inhaltspflege im Admin auf Kevins Frage, ob Marken wie das Magazin einen eigenen
Punkt brauchen. Ergebnis: **Die Markenverwaltung gab es schon** („Kernmarken verwalten“, AP2),
sie war nur nicht in der Sidebar verlinkt – erreichbar allein über die Kachel auf der
CMS-Übersicht. Die Herkunfts-Sektion aus Nachtrag 10 lag dagegen komplett im generischen
Inhalte-Editor, inklusive der Kartenkoordinaten `x`/`y` und der Beschriftungsseite als nackte
Felder. Das war für Marcel nicht pflegbar. Abgestimmt mit Kevin: **Weg 1**, Standort an die Marke.
### Was sich ändert
- **Sidebar:** neuer Eintrag „Marken“ unter CMS → Website-Inhalte, zwischen Projekte und
Magazin. Die Gruppe klappt auf der Markenseite jetzt auch auf.
- **Drei neue Felder je Marke** (Migration `add_origin_to_cms_brands_table`, übersetzbar wie die
übrigen Textfelder): `origin_city` (Fertigungsort), `origin_distance` (Entfernung als Text,
„rund 30 km“), `origin_role` (ein Satz zur Rolle). Im Admin als Block „Fertigungsort“ unter
den Stammdaten, mit Hinweis: ohne Ort erscheint die Marke nicht in der Sektion.
- **`OriginSection` liest die Standorte aus `CmsBrand::forFrontend()`**: nur veröffentlichte
Marken mit Ort, in der Reihenfolge der Marken. Eine unveröffentlichte Marke fällt damit ganz
heraus (vorher blieb ihr Standort ohne Link stehen – jetzt gibt es keinen Standort ohne
Marke mehr). Der Link „Zur Marke“ hängt an `has_detail_page`, der Text `link_text` liegt
einmal auf Sektionsebene statt je Standort.
- **Kartenpositionen im Code:** `OriginSection::MAP_POSITIONS`, je Slug `x`/`y`/`label`. Eine
neue Marke mit Ort, aber ohne Position, erscheint in den Kacheln, nicht in der Karte – dafür
muss ein Entwickler eine Position ergänzen. Das ist gewollt: die Karte ist Zeichnung, kein
Inhalt.
- **Sektions-JSON `home/origin_section`** enthält nur noch Überschrift, Untertitel, Alt-Text,
Regionslabel, Entfernungs-Suffix, Drehscheibe, Link-Text und Ablauf-Block. Der alte Schlüssel
`locations` wird von `OriginSectionCmsSeeder` aus einem bestehenden Eintrag entfernt, damit er
im Inhalte-Editor nicht mehr als Pflegefeld auftaucht (idempotent, sonst weiterhin nur
anlegen-wenn-fehlt).
- **Lang-Fallback:** die vier Einträge in `core_brands.brands` tragen jetzt `slug` und die drei
Herkunftsfelder, damit die Sektion auch ohne gepflegte Marke steht – dann ohne Links, wie die
Kacheln der Kernmarken.
- **Seeder:** `CmsBrandSeeder` bringt die Herkunft für frische Installationen mit; neu
`BrandOriginSeeder` trägt sie bei bestehenden Marken nach, nur wenn der Ort leer ist (Muster
wie `BrandLogoSeeder`). Keine Dubletten möglich, beide arbeiten über den Slug.
- Ohne Marke mit Ort bleiben Überschrift und Ablauf-Block stehen, Karte und Kacheln entfallen.
**Lokal ausgeführt:** `php artisan migrate`, `db:seed --class=BrandOriginSeeder` (4 gesetzt),
`db:seed --class=OriginSectionCmsSeeder` (`locations` entfernt). Beim Deployment: Abschnitt 8,
Schritte 9 und 10, die Migration läuft mit `migrate` vorweg.
**Offen:** wie Nachtrag 10 (Bestätigung des CABINET-Ablaufs durch Marcel). Der generische
Inhalte-Editor bleibt für die übrigen Sektionen ohne Schema; wo Listen mit technischen Feldern
auftauchen, ist derselbe Weg möglich: Daten an ein Modell hängen, Darstellung in den Code.
**Tests:** `tests/Feature/OriginSectionTest.php` (15 Fälle), drei neue Fälle in
`tests/Feature/Admin/Cms/CmsBrandsIndexTest.php` (Herkunft je Sprache speichern, Ort leeren,
Sidebar-Link).
---
## Nachtrag 12 · Inhalte-Editor: Seitenbaum und Vorschau (15.09.2026)
Kevins Rückmeldung nach der Prüfung des CMS: der Punkt „Inhalte“ zeigt Datenbank-Gruppen
alphabetisch, ohne Bezug zur Seite, mit JSON-Schlüsseln als Beschriftung – zu abstrakt für
Marcel. Abgestimmt: Stufe 1 (Seitenbaum, Klartext) und Stufe 2 (Vorschau im Rahmen) umsetzen,
Stufe 3 (Stift-Markierungen in der Vorschau, die den Editor öffnen) erst nach Marcels Feedback.
### Stufe 1 · Seitenbaum statt Gruppen
- **Registry `config/cms_pages.php`:** je Seite (Startseite, Marken, Referenzen, Magazin, Über
uns, Kontakt, FAQ, „Auf jeder Seite“, Immobilien-Welt, Rechtliches) die Sektionen in der
Reihenfolge, wie sie auf der Website stehen, mit Titel, einem Satz Beschreibung, Pfad und
Sprungmarke. Dazu ein Wörterbuch `field_labels` (`button_text` → „Button-Beschriftung“,
`x`/`y` → „Position (Entwickler)“ …), je Sektion überschreibbar über `fields`.
- **`App\Services\CmsPageTree`** verbindet die Registry mit der Datenbank: welche Sektion
existiert (`content_id`), welche fehlt, was in der Datenbank liegt, aber auf keiner Seite
eingetragen ist. Letzteres landet als Sammelpunkt **„Nicht mehr verwendet“** ganz unten
(Netzwerk-, Partner- und alte Immobilien-Sektionen, `home/synergie_section` & Co.) – nichts
geht verloren, nichts steht im Weg.
- **Editor (`admin.cms.content-index`):** linke Spalte zeigt den Seitenbaum, nummeriert von
oben nach unten. Ein Klick wählt genau eine Sektion (`selectSection`), der Kopf zeigt Titel,
Beschreibung, Seite und den technischen Schlüssel klein darunter. Felder tragen die
Klartext-Beschriftung, der Schlüssel steht als `code` daneben; Typ-Badges heißen „Text“,
„Formatierter Text“, „Bild“, „Liste“. Der Listen-Editor (Overlay) beschriftet seine Felder
genauso. `selectGroup` bleibt für die Gruppenansicht erhalten.
- **Fehlende Sektion anlegen:** steht eine Sektion in der Registry, aber nicht in der
Datenbank (heute z. B. das Terminanfrage-Widget), erscheint sie grau mit „+“. Ein Klick
legt sie über `CmsContentSeeder` mit den Texten aus `b2in.php` an. Rechtliches und die
Kontaktdaten (`global/contact`) haben keine Vorlage, dort erscheint ein Hinweis.
### Stufe 2 · Vorschau
- Rechts neben dem Editor ein **iframe mit der echten Website** (Domain aus
`config('domains')`, Pfad der Seite, `?cms-preview=<n>` als Cache-Brecher, `#anker`). Beim
Wechsel der Sektion springt die Vorschau zur Sprungmarke, nach jedem Speichern lädt sie
neu (`previewVersion`). Buttons: neu laden, in neuem Tab öffnen, Vorschau aus/ein.
Keine Anmeldung nötig, die Website ist öffentlich; Frame-Header setzt die App keine.
- **Sprungmarken nachgezogen:** `id` an den Wurzelelementen der lebenden Sektionen (`hero`,
`gruender`, `wege`, `marken`, `herkunft`, `vision`, `engagement`, `displays`, `immobilien`,
`cta`, `seitenkopf`, `netzwerk`, `wachstum`, `marken-cta`, `projekte`, `magazin`,
`geschichte`, `bild`, `werte`, `kontakt`, `faq`, `header`, `footer`). Beim Display-Feed
sitzt der Anker am Wurzel-`div`, weil die Sektion ohne veröffentlichtes Display leer bleibt.
- Sektionen ohne Seite (Beitragsseite, Widget, Rechtliches) zeigen die Seite ohne Sprung.
### Erkenntnisse beim Kartieren
- `/immobilien` rendert `web/dev/immobilien-azizi.blade.php` mit statischen Texten aus
`immobilien-azizi.json`; die CMS-Sektionen `immobilien/hero`, `warum_dubai`, `kaufprozess`
usw. gehören zur stillgelegten `web/immobilien.blade.php` und stehen deshalb unter „Nicht
mehr verwendet“. Gleiches gilt für `about/leadership_team`.
- `display_feed` liegt in der Gruppe `referenzen`, steht aber zuerst auf der Startseite –
im Baum unter Startseite mit Hinweis auf /referenzen.
**Tests:** `tests/Feature/Cms/CmsPageTreeTest.php` (9 Fälle, darunter: jede Sprungmarke der
Registry existiert auf der jeweiligen Seite, jede Sektion mit Vorlage ist in `b2in.php`
auffindbar) und sechs neue Fälle in `tests/Feature/Cms/CmsAdminTest.php` (Seitenbaum,
Sektion wählen mit Klartext-Labels und Vorschau-Adresse, Sammelpunkt, Anlegen aus Vorlage,
Anlegen ohne Vorlage, Vorschau ausblenden). Kein Deployment-Schritt nötig: Registry und
Editor sind Code, die Datenbank bleibt unverändert.
**Offen:** Stufe 3 nach Marcels Rückmeldung. Neue Sektionen müssen in `cms_pages.php`
eingetragen werden, sonst landen sie unter „Nicht mehr verwendet“ – als Erinnerung steht das
im Kopf der Datei.
---
## Nachtrag 13 · Referenzen als eigenes Modul, Immobilien-Projekte ausgeblendet (15.09.2026)
Kevin nach der Abnahme von Nachtrag 12: „Projekte“ im Portal (die Immobilienprojekte aus
Dubai) ist nicht freigegeben und kommt raus; an die Stelle gehören die Projekte/Referenzen der
Interior-Welt. Das ist eine **Korrektur zu AP5**, das die Referenzen bewusst aus den
Markengalerien zusammengesammelt hatte, um keine zweite Pflegestelle zu schaffen. Ein
realisiertes Projekt hat aber mehr als ein Bild und oft mehr als eine Marke (CABINET plus
Tischlerei), darum jetzt ein eigener Eintrag.
### Was sich ändert
- **Neues Modell `CmsReference`** (Tabelle `cms_references`): Titel und Beschreibung
übersetzbar, Ort und Jahr als Text, Titelbild, weitere Bilder mit Bildunterschrift,
beteiligte Marken als Liste von Slugs aus `cms_brands`, Veröffentlicht, Sortierung.
- **Portal → CMS → „Referenzen“** (`admin.cms.references-index`, Route
`cms.references.index`): Liste mit Vorschaubild, Marken-Badges und Bildanzahl; Formular
nach dem Muster des Magazins mit Sprachumschalter, Marken als Checkboxen, Titelbild und
Galerie über den Media-Picker, Galeriebilder sortierbar. Der Kopf der Seite sagt die Regel:
nur ausgeführte Aufträge, Entwürfe gehören in die Galerie der Marke.
- **Sidebar und CMS-Übersicht:** „Referenzen“ ersetzt „Projekte“. Die Route
`cms.projects.index` bleibt bestehen und ist per Adresse erreichbar, nur nicht mehr
verlinkt – die Dubai-Daten bleiben unangetastet, falls die Freigabe später kommt.
- **`/referenzen`** zeigt die veröffentlichten Referenzen: Titelbild, Titel, „Ort · Jahr“,
Beschreibung, weitere Bilder als kleine Reihe, darunter die beteiligten Marken verlinkt auf
ihre Markenseiten (unveröffentlichte Marken fallen heraus). Ohne Titelbild dient das erste
Galeriebild als Titelbild, ohne jedes Bild wird die Referenz übersprungen.
- **Fallback bleibt:** Solange keine Referenz veröffentlicht ist, greift der Stand aus AP5
(Bilder der Rubrik „Realisiertes Projekt“ aus den Markengalerien). Der Leerzustand von AP5
gilt weiter, wenn beides leer ist. So sieht die Preview beim Deployment keinen Unterschied,
bis Marcel die erste Referenz anlegt.
- Registry `cms_pages.php`: Beschreibung von „Realisierte Projekte“ verweist auf das neue Modul.
**Deployment:** nur `php artisan migrate --force` (Abschnitt 8.2, Schritt 1 deckt die neue
Tabelle ab). Kein Seeder, keine Daten.
**Offen:** Marcel legt die ersten Referenzen selbst an; die bisherigen Galeriebilder der Marken
bleiben dort, wo sie sind. Ob die Bilder aus den Markengalerien später automatisch als Referenz
übernommen werden sollen, klärt sich mit den ersten echten Einträgen.
**Tests:** `tests/Feature/Admin/Cms/CmsReferencesIndexTest.php` (9 Fälle), vier neue Fälle in
`tests/Feature/ReferenzenPageTest.php` (Referenzen vor Galerien, Entwurf ausgeblendet und
Fallback, unveröffentlichte Marke und Referenz ohne Bild, Galeriebild als Titelbild),
Dashboard-Statistik in `CmsAdminTest` auf Referenzen umgestellt.

View file

@ -214,42 +214,58 @@ return [
'subtitle' => 'Wir arbeiten nicht mit beliebigen Katalogen, sondern mit ausgewählten Herstellern, hinter denen wir stehen – jede Marke ein Spezialist auf ihrem Gebiet.',
'brands' => [
[
'slug' => 'cabinet',
'name' => 'CABINET',
'tagline' => 'Einbauschränke nach Maß.',
'teaser' => 'Maßgefertigte Schranklösungen, geplant bis auf den Millimeter – gefertigt für Räume, in denen Standard nicht genügt. Erleben Sie die Möglichkeiten in unserem Showroom in Bielefeld.',
'image' => '',
'logo' => '',
'link_text' => 'Mehr zu CABINET',
'origin_city' => 'Köln',
'origin_distance' => 'rund 200 km',
'origin_role' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
],
[
'slug' => 'rotpunkt',
'name' => 'Rotpunkt Küchen',
'tagline' => 'Küchen, die Haltung zeigen.',
'teaser' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant, nachhaltig gedacht und gebaut für den Mittelpunkt Ihres Zuhauses. Beratung und Planung in unserem Küchenstudio.',
'image' => '',
'logo' => '',
'link_text' => 'Mehr zu Rotpunkt',
'origin_city' => 'Bünde',
'origin_distance' => 'rund 30 km',
'origin_role' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
],
[
'slug' => 'februe',
'name' => 'Febrü',
'tagline' => 'Arbeiten, wo man gerne bleibt.',
'teaser' => 'Möbel für konzentriertes Arbeiten – vom durchdachten Home-Office bis zur kompletten Büroeinrichtung. Ergonomie und Design für Zuhause und fürs Unternehmen.',
'image' => '',
'logo' => '',
'link_text' => 'Mehr zu Febrü',
'origin_city' => 'Herford',
'origin_distance' => 'rund 20 km',
'origin_role' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
],
[
'slug' => 'tischlerei',
'name' => 'Tischlerei',
'tagline' => 'Maßarbeit, die den Raum vollendet.',
'teaser' => 'Aufbau, Sonderlösungen und Veredelung aus einer Tischlerei in Ostwestfalen – dort, wo Serienmöbel aufhören und Ihr Raum anfängt.',
'image' => '',
'logo' => '',
'link_text' => 'Mehr zur Tischlerei',
'origin_city' => 'Rheda-Wiedenbrück',
'origin_distance' => 'rund 35 km',
'origin_role' => 'Unsere Werkstatt für Aufbau, Sonderlösungen und Veredelung.',
],
],
],
// Herkunfts-Sektion (Arbeitsplan 14.09., Punkt 1.3). Koordinaten sind Positionen in der
// schematischen Karte (viewBox 640×400), keine Geografie. Entfernungen gerundet, Straße ab
// Bielefeld Mitte. ⚠️ Ablauf CABINET (steps) nach Kevins Verständnis – von Marcel bestätigen lassen.
// Herkunfts-Sektion (Arbeitsplan 14.09., Punkt 1.3). Die Standorte kommen aus den Marken
// (origin_city, origin_distance, origin_role – Nachtrag 11), die Kartenpositionen aus
// OriginSection::MAP_POSITIONS. ⚠️ Ablauf CABINET (steps) nach Kevins Verständnis – von Marcel bestätigen lassen.
'origin_section' => [
'title' => 'Aus der Region <span class="text-secondary">gefertigt</span>',
'subtitle' => 'Vier deutsche Manufakturen, drei davon in Ostwestfalen. Kurze Wege, Menschen, die wir kennen, und Qualität, die man besuchen kann.',
@ -262,52 +278,7 @@ return [
'x' => 400,
'y' => 175,
],
'locations' => [
[
'city' => 'Bünde',
'brand' => 'Rotpunkt Küchen',
'role' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
'distance' => 'rund 30 km',
'brand_slug' => 'rotpunkt',
'link_text' => 'Zur Marke',
'x' => 330,
'y' => 55,
'label' => 'left',
],
[
'city' => 'Herford',
'brand' => 'Febrü',
'role' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
'distance' => 'rund 20 km',
'brand_slug' => 'februe',
'link_text' => 'Zur Marke',
'x' => 480,
'y' => 90,
'label' => 'right',
],
[
'city' => 'Rheda-Wiedenbrück',
'brand' => 'Tischlerei',
'role' => 'Unsere Werkstatt für Aufbau, Sonderlösungen und Veredelung.',
'distance' => 'rund 35 km',
'brand_slug' => 'tischlerei',
'link_text' => 'Zur Marke',
'x' => 295,
'y' => 285,
'label' => 'left',
],
[
'city' => 'Köln',
'brand' => 'CABINET',
'role' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
'distance' => 'rund 200 km',
'brand_slug' => 'cabinet',
'link_text' => 'Zur Marke',
'x' => 80,
'y' => 345,
'label' => 'right',
],
],
'link_text' => 'Zur Marke',
'steps_title' => 'Gefertigt in Köln, <span class="text-secondary">geplant in Bielefeld</span>',
'steps_text' => 'Am Beispiel CABINET: Die Möbel entstehen in der Kölner Fertigung. Beraten, aufgemessen und geplant wird bei uns im CABINET Store Bielefeld. Aufgebaut wird vor Ort – mit unserer Tischlerei, die auf Wunsch auch das umsetzt, was im Katalog nicht steht.',
'steps' => [

View file

@ -211,36 +211,52 @@ return [
'subtitle' => 'We do not work with arbitrary catalogues, but with selected manufacturers we stand behind – each brand a specialist in its field.',
'brands' => [
[
'slug' => 'cabinet',
'name' => 'CABINET',
'tagline' => 'Fitted wardrobes made to measure.',
'teaser' => 'Bespoke wardrobe solutions, planned to the millimetre – built for rooms where standard will not do. Experience the possibilities in our Bielefeld showroom.',
'image' => '',
'logo' => '',
'link_text' => 'More about CABINET',
'origin_city' => 'Cologne',
'origin_distance' => 'about 200 km',
'origin_role' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
],
[
'slug' => 'rotpunkt',
'name' => 'Rotpunkt Küchen',
'tagline' => 'Kitchens with character.',
'teaser' => 'Premium kitchens made in Germany – individually planned, sustainably conceived and built for the heart of your home. Consultation and planning in our kitchen studio.',
'image' => '',
'logo' => '',
'link_text' => 'More about Rotpunkt',
'origin_city' => 'Bünde',
'origin_distance' => 'about 30 km',
'origin_role' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
],
[
'slug' => 'februe',
'name' => 'Febrü',
'tagline' => 'Work where you like to be.',
'teaser' => 'Furniture for focused work – from a well-considered home office to a complete office fit-out. Ergonomics and design for home and company alike.',
'image' => '',
'logo' => '',
'link_text' => 'More about Febrü',
'origin_city' => 'Herford',
'origin_distance' => 'about 20 km',
'origin_role' => 'Office and contract furniture from their own production in Herford.',
],
[
'slug' => 'tischlerei',
'name' => 'Joinery',
'tagline' => 'Craftsmanship that completes the room.',
'teaser' => 'Assembly, bespoke solutions and finishing from a joinery in East Westphalia – where series furniture ends and your room begins.',
'image' => '',
'logo' => '',
'link_text' => 'More about the joinery',
'origin_city' => 'Rheda-Wiedenbrück',
'origin_distance' => 'about 35 km',
'origin_role' => 'Our workshop for assembly, custom solutions and finishing.',
],
],
],
@ -256,52 +272,7 @@ return [
'x' => 400,
'y' => 175,
],
'locations' => [
[
'city' => 'Bünde',
'brand' => 'Rotpunkt Küchen',
'role' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
'distance' => 'about 30 km',
'brand_slug' => 'rotpunkt',
'link_text' => 'To the brand',
'x' => 330,
'y' => 55,
'label' => 'left',
],
[
'city' => 'Herford',
'brand' => 'Febrü',
'role' => 'Office and contract furniture from their own production in Herford.',
'distance' => 'about 20 km',
'brand_slug' => 'februe',
'link_text' => 'To the brand',
'x' => 480,
'y' => 90,
'label' => 'right',
],
[
'city' => 'Rheda-Wiedenbrück',
'brand' => 'Joinery',
'role' => 'Our workshop for assembly, custom solutions and finishing.',
'distance' => 'about 35 km',
'brand_slug' => 'tischlerei',
'link_text' => 'To the brand',
'x' => 295,
'y' => 285,
'label' => 'left',
],
[
'city' => 'Cologne',
'brand' => 'CABINET',
'role' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
'distance' => 'about 200 km',
'brand_slug' => 'cabinet',
'link_text' => 'To the brand',
'x' => 80,
'y' => 345,
'label' => 'right',
],
],
'link_text' => 'To the brand',
'steps_title' => 'Made in Cologne, <span class="text-secondary">planned in Bielefeld</span>',
'steps_text' => 'Take CABINET: the furniture is made in Cologne. Consulting, measuring and planning happen with us at the CABINET Store Bielefeld. Assembly takes place on site – with our joinery, which on request also builds what the catalogue does not offer.',
'steps' => [

View file

@ -161,7 +161,7 @@
<flux:navlist.group :heading="__('CMS')" class="grid mb-4">
<flux:navlist.group expandable
:expanded="request()->routeIs(['cms.dashboard', 'cms.content.index', 'cms.projects.index', 'cms.articles.index', 'cms.media.index'])"
:expanded="request()->routeIs(['cms.dashboard', 'cms.content.index', 'cms.references.index', 'cms.brands.index', 'cms.articles.index', 'cms.media.index'])"
heading="Website-Inhalte" class="grid">
<flux:navlist.item icon="rectangle-group" :href="route('cms.dashboard')"
:current="request()->routeIs('cms.dashboard')" wire:navigate>{{ __('Übersicht') }}
@ -169,8 +169,12 @@
<flux:navlist.item icon="document-text" :href="route('cms.content.index')"
:current="request()->routeIs('cms.content.index')" wire:navigate>{{ __('Inhalte') }}
</flux:navlist.item>
<flux:navlist.item icon="building-office" :href="route('cms.projects.index')"
:current="request()->routeIs('cms.projects.index')" wire:navigate>{{ __('Projekte') }}
{{-- Immobilien-Projekte (cms.projects.index) sind nicht freigegeben und bewusst nicht verlinkt (Nachtrag 13). --}}
<flux:navlist.item icon="photo" :href="route('cms.references.index')"
:current="request()->routeIs('cms.references.index')" wire:navigate>{{ __('Referenzen') }}
</flux:navlist.item>
<flux:navlist.item icon="tag" :href="route('cms.brands.index')"
:current="request()->routeIs('cms.brands.index')" wire:navigate>{{ __('Marken') }}
</flux:navlist.item>
<flux:navlist.item icon="newspaper" :href="route('cms.articles.index')"
:current="request()->routeIs('cms.articles.index')" wire:navigate>{{ __('Magazin') }}

View file

@ -32,6 +32,9 @@ state([
'gallery' => [],
'website_url' => '',
'showroom' => '',
'originCity' => '',
'originDistance' => '',
'originRole' => '',
'is_published' => true,
'order' => 0,
]);
@ -70,7 +73,7 @@ $brands = computed(
$openCreate = function () {
$this->editingId = null;
$this->reset(['slug', 'brandName', 'tagline', 'teaser', 'summary', 'description', 'logo', 'image', 'website_url', 'showroom', 'widgetLabel', 'ctaTitle', 'ctaText']);
$this->reset(['slug', 'brandName', 'tagline', 'teaser', 'summary', 'description', 'logo', 'image', 'website_url', 'showroom', 'originCity', 'originDistance', 'originRole', 'widgetLabel', 'ctaTitle', 'ctaText']);
$this->sections = [];
$this->highlights = ['', '', ''];
$this->services = [['title' => '', 'description' => '']];
@ -89,6 +92,9 @@ $loadLocaleFields = function (CmsBrand $brand, string $locale) {
$this->widgetLabel = $brand->getTranslation('widget_label', $locale) ?? '';
$this->ctaTitle = $brand->getTranslation('cta_title', $locale) ?? '';
$this->ctaText = $brand->getTranslation('cta_text', $locale) ?? '';
$this->originCity = $brand->getTranslation('origin_city', $locale) ?? '';
$this->originDistance = $brand->getTranslation('origin_distance', $locale) ?? '';
$this->originRole = $brand->getTranslation('origin_role', $locale) ?? '';
};
$openEdit = function (int $id) {
@ -194,6 +200,9 @@ $save = function () {
'summary' => $this->summary,
'website_url' => $this->website_url,
'showroom' => $this->showroom,
'originCity' => $this->originCity,
'originDistance' => $this->originDistance,
'originRole' => $this->originRole,
'is_published' => $this->is_published,
'order' => $this->order,
], [
@ -204,6 +213,9 @@ $save = function () {
'summary' => 'nullable|string|max:4000',
'website_url' => 'nullable|url|max:500',
'showroom' => 'nullable|string|max:255',
'originCity' => 'nullable|string|max:255',
'originDistance' => 'nullable|string|max:255',
'originRole' => 'nullable|string|max:500',
'is_published' => 'boolean',
'order' => 'integer|min:0',
], [
@ -255,6 +267,11 @@ $save = function () {
$brand->setTranslation('cta_title', $this->editLocale, trim((string) $this->ctaTitle));
$brand->setTranslation('cta_text', $this->editLocale, trim((string) $this->ctaText));
// Herkunfts-Sektion: ohne Fertigungsort erscheint die Marke dort nicht.
$brand->setTranslation('origin_city', $this->editLocale, trim((string) ($validated['originCity'] ?? '')));
$brand->setTranslation('origin_distance', $this->editLocale, trim((string) ($validated['originDistance'] ?? '')));
$brand->setTranslation('origin_role', $this->editLocale, trim((string) ($validated['originRole'] ?? '')));
// normalizeSections() nimmt die Textblöcke entgegen und trennt sie an den Leerzeilen.
$brand->sections = CmsBrand::normalizeSections(collect($this->sections)->map(fn (array $s) => [
'title' => $s['title'] ?? '',
@ -365,6 +382,24 @@ $cancelForm = function () {
placeholder="Ausführlicher Text für die Markenübersicht. Leerzeile trennt Absätze." />
</div>
{{-- Fertigungsort für „Aus der Region gefertigt“ (Startseite und /marken) --}}
<div class="mt-6">
<flux:heading size="sm" class="mb-1">Fertigungsort</flux:heading>
<flux:text class="mb-3 text-sm">
Erscheint in der Sektion „Aus der Region gefertigt“ auf der Startseite und unter /marken.
Ohne Ort bleibt die Marke dort weg. Die Position in der Karte ist fest hinterlegt.
</flux:text>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="originCity" label="Ort ({{ strtoupper($editLocale) }})" placeholder="Bünde" />
<flux:input wire:model="originDistance" label="Entfernung von Bielefeld ({{ strtoupper($editLocale) }})"
placeholder="rund 30 km" />
<div class="md:col-span-2">
<flux:input wire:model="originRole" label="Rolle des Standorts ({{ strtoupper($editLocale) }})"
placeholder="Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio." />
</div>
</div>
</div>
{{-- Drei Merkmale für die Übersichtsseite --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">

View file

@ -1,6 +1,8 @@
<?php
use App\Services\CmsFluxEditorHtmlTransformer;
use App\Services\CmsPageTree;
use Database\Seeders\CmsContentSeeder;
use Flux\Flux;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
@ -56,11 +58,21 @@ if (! function_exists('_cmsFieldLooksLikeImage')) {
}
}
if (! function_exists('_cmsFieldLabel')) {
function _cmsFieldLabel(string $field, ?string $group = null, ?string $key = null): string
{
return app(CmsPageTree::class)->fieldLabel($field, $group, $key);
}
}
layout('components.layouts.app');
title('CMS Inhalte');
state([
'selectedGroup' => null,
'selectedKey' => null,
'showPreview' => true,
'previewVersion' => 0,
'search' => '',
'editingId' => null,
'editingField' => null,
@ -112,12 +124,21 @@ $groups = computed(fn () => CmsContent::query()
->pluck('count', 'group')
->toArray());
$tree = computed(fn () => app(CmsPageTree::class)->pages());
$currentSection = computed(fn () => $this->selectedGroup && $this->selectedKey
? app(CmsPageTree::class)->section($this->selectedGroup, $this->selectedKey)
: null);
$previewUrl = computed(fn () => app(CmsPageTree::class)->previewUrl($this->currentSection, $this->previewVersion));
$flatContents = computed(function () {
if (! $this->selectedGroup) {
return collect();
}
$contents = CmsContent::forGroup($this->selectedGroup)
->when($this->selectedKey, fn ($q, $key) => $q->where('key', $key))
->orderBy('order')
->get();
@ -186,8 +207,40 @@ $selectGroup = function (string $group) {
$this->editMediaId = null;
$this->editingFieldType = 'text';
$this->selectedGroup = $group;
$this->selectedKey = null;
};
$selectSection = function (string $group, string $key) {
$this->selectGroup($group);
$this->selectedKey = $key;
};
// Sektion aus der Registry, die noch nicht in der Datenbank liegt: aus der Lang-Datei anlegen.
$createSection = function (string $group, string $key) {
$themeKey = app(CmsPageTree::class)->themeKey($group, $key);
if ($themeKey === null) {
Flux::toast(variant: 'danger', heading: 'Keine Vorlage', text: 'Für diese Sektion gibt es keine Vorlage in der Sprachdatei.');
return;
}
$seeder = app(CmsContentSeeder::class);
$seeder->onlySections = [$themeKey];
$seeder->run();
app(CmsContentService::class)->clearCache($group);
$this->selectSection($group, $key);
$this->previewVersion++;
Flux::toast(variant: 'success', heading: 'Angelegt', text: 'Die Sektion wurde mit den Texten der Website angelegt.');
};
$togglePreview = fn () => $this->showPreview = ! $this->showPreview;
$reloadPreview = fn () => $this->previewVersion++;
$startFieldEdit = function (int $contentId, ?string $fieldKey = null) {
$content = CmsContent::find($contentId);
if (! $content) {
@ -375,6 +428,7 @@ $saveEdit = function () {
$this->editValue = '';
$this->editingFieldType = 'text';
$this->editMediaId = null;
$this->previewVersion++;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
};
@ -439,6 +493,7 @@ $saveJsonModal = function () {
$this->editingId = null;
$this->editingField = null;
$this->jsonItems = [];
$this->previewVersion++;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
};
@ -461,9 +516,8 @@ $cancelJsonModal = function () {
?>
@php
$cmsGroupLabels = [
'legal' => 'Rechtliches',
];
$section = $this->currentSection;
$pageTitle = $selectedGroup ? ($this->tree[$section['page'] ?? '']['title'] ?? $selectedGroup) : null;
@endphp
<div>
@ -474,30 +528,85 @@ $cancelJsonModal = function () {
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<div class="lg:col-span-1">
<flux:card>
<flux:heading size="sm" class="mb-3">Seiten / Gruppen</flux:heading>
<div class="flex flex-col gap-1">
@foreach ($this->groups as $group => $count)
<button wire:click="selectGroup('{{ $group }}')"
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedGroup === $group ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
<span>{{ $cmsGroupLabels[$group] ?? $group }}</span>
<flux:badge size="sm">{{ $count }}</flux:badge>
</button>
@endforeach
</div>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-12">
{{-- Seitenbaum: Seiten in Menü-Reihenfolge, Sektionen von oben nach unten (config/cms_pages.php) --}}
<div class="xl:col-span-3">
<flux:card class="space-y-5">
@foreach ($this->tree as $pageKey => $page)
<div wire:key="cms-page-{{ $pageKey }}">
<div class="mb-1 flex items-center justify-between px-3">
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $page['title'] }}</span>
@if ($page['path'])
<span class="text-xs text-zinc-400">{{ $page['path'] }}</span>
@endif
</div>
@if ($pageKey === \App\Services\CmsPageTree::UNLISTED)
<details class="px-3">
<summary class="cursor-pointer py-1 text-sm text-zinc-500">{{ count($page['sections']) }} Sektionen stillgelegter Seiten</summary>
<div class="mt-1 flex flex-col gap-0.5">
@foreach ($page['sections'] as $item)
<button wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
wire:click="selectSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
class="rounded-lg px-2 py-1 text-left text-xs transition-colors {{ $selectedGroup === $item['group'] && $selectedKey === $item['key'] ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
{{ $item['title'] }}
</button>
@endforeach
</div>
</details>
@else
<div class="flex flex-col gap-0.5">
@foreach ($page['sections'] as $index => $item)
@php $isActive = $selectedGroup === $item['group'] && $selectedKey === $item['key']; @endphp
@if ($item['content_id'])
<button wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
wire:click="selectSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm transition-colors {{ $isActive ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
<span class="w-5 shrink-0 text-xs text-zinc-400">{{ $index + 1 }}</span>
<span class="truncate">{{ $item['title'] }}</span>
</button>
@else
<div wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-zinc-400">
<span class="w-5 shrink-0 text-xs">{{ $index + 1 }}</span>
<span class="truncate">{{ $item['title'] }}</span>
<flux:button size="xs" variant="ghost" icon="plus" class="ml-auto"
wire:click="createSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
title="Aus den Texten der Website anlegen" />
</div>
@endif
@endforeach
</div>
@endif
</div>
@endforeach
</flux:card>
</div>
<div class="lg:col-span-3" wire:key="cms-content-panel-{{ $selectedGroup ?? 'empty' }}">
{{-- Editor --}}
<div class="{{ $showPreview && $this->previewUrl ? 'xl:col-span-4' : 'xl:col-span-9' }}"
wire:key="cms-content-panel-{{ $selectedGroup ?? 'empty' }}-{{ $selectedKey ?? 'all' }}">
@if ($selectedGroup)
<flux:card>
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $cmsGroupLabels[$selectedGroup] ?? $selectedGroup }}</flux:heading>
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<flux:heading size="lg">{{ $section['title'] ?? ($selectedKey ?? $selectedGroup) }}</flux:heading>
@if (! empty($section['description']))
<flux:text class="mt-1 text-sm">{{ $section['description'] }}</flux:text>
@endif
<div class="mt-1 flex items-center gap-2 text-xs text-zinc-400">
@if ($pageTitle && $pageTitle !== ($section['title'] ?? null))
<span>{{ $pageTitle }}</span>
<span aria-hidden="true">·</span>
@endif
<code>{{ $selectedGroup }}{{ $selectedKey ? '/' . $selectedKey : '' }}</code>
</div>
</div>
<div class="flex items-center gap-3">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
icon="magnifying-glass" class="w-48" />
@if (! $selectedKey)
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
icon="magnifying-glass" class="w-48" />
@endif
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
@ -506,13 +615,17 @@ $cancelJsonModal = function () {
</flux:button>
@endforeach
</div>
@if ($this->previewUrl)
<flux:button size="xs" variant="ghost" :icon="$showPreview ? 'eye-slash' : 'eye'"
wire:click="togglePreview">{{ $showPreview ? 'Vorschau aus' : 'Vorschau' }}</flux:button>
@endif
</div>
</div>
@php $lastSection = null; @endphp
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->flatContents as $row)
@if ($row->section_key !== $lastSection)
@if (! $selectedKey && $row->section_key !== $lastSection)
@php $lastSection = $row->section_key; @endphp
<div class="bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $row->section_key }}</span>
@ -526,18 +639,23 @@ $cancelJsonModal = function () {
$editKey = $row->field_key !== null
? $row->content_id . ",'" . $row->field_key . "'"
: $row->content_id . ',null';
$fieldLabel = $row->field_key !== null
? _cmsFieldLabel($row->field_key, $row->section_key ? $selectedGroup : null, $row->section_key)
: ($section['title'] ?? $row->section_key);
@endphp
<div wire:key="row-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}" class="py-3 px-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<div class="mb-1 flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-zinc-800 dark:text-zinc-100">{{ $fieldLabel }}</span>
<code class="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{{ $row->field_key ?? $row->section_key }}</code>
@php
$typeColors = ['html' => 'amber', 'legal_html' => 'blue', 'image' => 'green', 'json' => 'violet', 'link' => 'rose'];
$typeLabels = ['html' => 'Formatierter Text', 'legal_html' => 'HTML', 'image' => 'Bild', 'json' => 'Liste', 'text' => 'Text'];
$badgeColor = $typeColors[$row->type] ?? 'zinc';
@endphp
<flux:badge size="sm" :color="$badgeColor">{{ $row->type }}</flux:badge>
<flux:badge size="sm" :color="$badgeColor">{{ $typeLabels[$row->type] ?? $row->type }}</flux:badge>
</div>
<div
@ -643,12 +761,31 @@ $cancelJsonModal = function () {
<flux:card>
<div class="py-12 text-center">
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Seite auswählen</flux:heading>
<flux:text>Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten.</flux:text>
<flux:heading>Sektion auswählen</flux:heading>
<flux:text>Links stehen die Seiten der Website mit ihren Abschnitten von oben nach unten. Wähle einen Abschnitt, um seine Texte und Bilder zu bearbeiten.</flux:text>
</div>
</flux:card>
@endif
</div>
{{-- Vorschau: die echte Website im Rahmen, springt zur gewählten Sektion und lädt nach dem Speichern neu --}}
@if ($selectedGroup && $showPreview && $this->previewUrl)
<div class="xl:col-span-5">
<flux:card class="sticky top-4 overflow-hidden p-0!">
<div class="flex items-center justify-between gap-2 border-b border-zinc-200 px-4 py-2 dark:border-zinc-700">
<div class="min-w-0 truncate text-xs text-zinc-500">
Vorschau · {{ $section['path'] ?? '' }}{{ ! empty($section['anchor']) ? '#' . $section['anchor'] : '' }}
</div>
<div class="flex shrink-0 items-center gap-1">
<flux:button size="xs" variant="ghost" icon="arrow-path" wire:click="reloadPreview" title="Neu laden" />
<flux:button size="xs" variant="ghost" icon="arrow-top-right-on-square" :href="$this->previewUrl" target="_blank" title="In neuem Tab öffnen" />
</div>
</div>
<iframe wire:key="cms-preview-{{ md5($this->previewUrl) }}" src="{{ $this->previewUrl }}"
class="h-[78vh] w-full bg-white" title="Vorschau der Website" loading="lazy"></iframe>
</flux:card>
</div>
@endif
</div>
{{-- JSON-Editor: kein flux:modal — vermeidet ReferenceError fluxModal bei jedem Livewire-Render (z. B. bei legal_html). --}}
@ -668,8 +805,9 @@ $cancelJsonModal = function () {
wire:click.stop
>
<div>
<flux:heading size="lg">{{ $jsonEditingKey }}</flux:heading>
<flux:heading size="lg">{{ $section['title'] ?? $selectedGroup }}: {{ $editingField !== null ? _cmsFieldLabel($editingField, $selectedGroup, $selectedKey) : ($section['title'] ?? $jsonEditingKey) }}</flux:heading>
<flux:text class="mt-1">
<code class="text-xs">{{ $jsonEditingKey }}</code> ·
{{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }}
({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }}
</flux:text>
@ -711,7 +849,7 @@ $cancelJsonModal = function () {
<div class="flex items-end gap-3">
<div class="flex-1">
<flux:select wire:model="jsonItems.{{ $idx }}.{{ $field }}"
variant="listbox" searchable label="{{ ucfirst($field) }}"
variant="listbox" searchable label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}"
placeholder="Icon auswählen...">
<flux:select.option value="">— Kein Icon —</flux:select.option>
@foreach ($this->availableIcons as $iconName)
@ -728,7 +866,7 @@ $cancelJsonModal = function () {
</div>
@elseif ($isImageField)
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ ucfirst($field) }}</label>
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}</label>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
@if (is_string($fieldValue) && $fieldValue !== '')
<div class="h-20 w-32 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
@ -751,18 +889,18 @@ $cancelJsonModal = function () {
@elseif ($isRichText)
<div class="md:col-span-2">
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ ucfirst($field) }}" toolbar="bold italic highlight"
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}" toolbar="bold italic highlight"
class="**:data-[slot=content]:min-h-[60px]!" />
</div>
@elseif ($isNestedJson)
<div class="md:col-span-2">
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ ucfirst($field) }} (JSON)" rows="3"
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }} (JSON)" rows="3"
class="font-mono text-xs" />
</div>
@else
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ ucfirst($field) }}" />
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}" />
@endif
@endforeach
</div>

View file

@ -2,7 +2,7 @@
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use App\Models\CmsProject;
use App\Models\CmsReference;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use function Livewire\Volt\{layout, title, computed};
@ -13,8 +13,8 @@ title('CMS Dashboard');
$stats = computed(fn () => [
'contents' => CmsContent::count(),
'groups' => CmsContent::distinct()->pluck('group')->count(),
'projects' => CmsProject::count(),
'projects_published' => CmsProject::published()->count(),
'references' => CmsReference::count(),
'references_published' => CmsReference::published()->count(),
'articles' => CmsArticle::count(),
'articles_published' => CmsArticle::published()->count(),
'brands' => CmsBrand::count(),
@ -27,7 +27,7 @@ $stats = computed(fn () => [
<div>
<div class="mb-6">
<flux:heading size="xl">CMS</flux:heading>
<flux:text class="mt-1">Inhalte, Projekte und Medien verwalten.</flux:text>
<flux:text class="mt-1">Inhalte, Referenzen, Marken, Magazin und Medien verwalten.</flux:text>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
@ -43,13 +43,13 @@ $stats = computed(fn () => [
</flux:card>
</a>
<a href="{{ route('cms.projects.index') }}" wire:navigate>
<a href="{{ route('cms.references.index') }}" wire:navigate>
<flux:card class="hover:border-emerald-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="building-office" class="text-emerald-500" />
<flux:icon name="photo" class="text-emerald-500" />
<div>
<flux:heading size="lg">{{ $this->stats['projects'] }}</flux:heading>
<flux:text class="text-sm">Projekte ({{ $this->stats['projects_published'] }} veröffentlicht)</flux:text>
<flux:heading size="lg">{{ $this->stats['references'] }}</flux:heading>
<flux:text class="text-sm">Referenzen ({{ $this->stats['references_published'] }} veröffentlicht)</flux:text>
</div>
</div>
</flux:card>
@ -107,7 +107,7 @@ $stats = computed(fn () => [
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte</strong> – Texte, Abschnitte und strukturierte Daten der Website (z.&nbsp;B. Startseite, Netzwerk, rechtliche Texte). Inhalte sind in <em>Gruppen</em> organisiert, sodass zusammengehörige Felder gebündelt bearbeitet werden können.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Projekte</strong> – Immobilien-Referenzobjekte mit Bildern, Preisen und Beschreibungen. Jedes Projekt kann als Entwurf angelegt und später veröffentlicht werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Referenzen</strong> – Realisierte Projekte für die Seite /referenzen mit Titelbild, weiteren Bildern und den beteiligten Marken. Nur ausgeführte Aufträge; Entwürfe gehören in die Galerie der Marke.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Magazin</strong> – Redaktionelle Artikel (z.&nbsp;B. über Dubai-Immobilien, Supply-Chain oder Einrichtung). Artikel unterstützen Kategorien, Autoren und Lesezeiten.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien</strong> – Zentrale Bild- und Dateiverwaltung. Alle Uploads landen hier und können in jedem Modul wiederverwendet werden.</li>
</ul>

View file

@ -0,0 +1,367 @@
<?php
use App\Models\CmsBrand;
use App\Models\CmsReference;
use Flux\Flux;
use FluxCms\Core\Models\CmsMedia;
use function Livewire\Volt\{computed, layout, on, state, title};
layout('components.layouts.app');
title('Referenzen verwalten');
state([
'search' => '',
'showForm' => false,
'editingId' => null,
'editLocale' => 'de',
'referenceTitle' => '',
'description' => '',
'location' => '',
'year' => '',
'image' => '',
'gallery' => [],
'brandSlugs' => [],
'is_published' => true,
'order' => 0,
]);
on(['media-selected' => function ($mediaId, $url, $field) {
$media = $mediaId ? CmsMedia::find($mediaId) : null;
if (! $media) {
return;
}
if ($field === 'reference_image') {
$this->image = $media->filename;
}
if (str_starts_with($field, 'reference_gallery:')) {
$index = (int) substr($field, strlen('reference_gallery:'));
if (isset($this->gallery[$index])) {
$this->gallery[$index]['image'] = $media->filename;
}
}
}]);
$references = computed(
fn () => CmsReference::query()
->when($this->search, fn ($q, $s) => $q->where('location', 'like', "%{$s}%")
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(title, '$.de')) LIKE ?", ["%{$s}%"]))
->ordered()
->get(),
);
$brands = computed(fn () => CmsBrand::query()->ordered()->get());
$openCreate = function () {
$this->editingId = null;
$this->reset(['referenceTitle', 'description', 'location', 'year', 'image']);
$this->gallery = [];
$this->brandSlugs = [];
$this->is_published = true;
$this->order = (CmsReference::max('order') ?? 0) + 1;
$this->showForm = true;
};
$loadLocaleFields = function (CmsReference $reference, string $locale) {
$this->referenceTitle = $reference->getTranslation('title', $locale) ?? '';
$this->description = $reference->getTranslation('description', $locale) ?? '';
};
$openEdit = function (int $id) {
$reference = CmsReference::find($id);
if (! $reference) {
return;
}
$this->editingId = $id;
$this->location = $reference->location ?? '';
$this->year = $reference->year ?? '';
$this->image = $reference->image ?? '';
$this->gallery = CmsReference::normalizeGallery($reference->gallery);
$this->brandSlugs = array_values(array_map('strval', $reference->brand_slugs ?? []));
$this->is_published = $reference->is_published;
$this->order = $reference->order ?? 0;
$this->loadLocaleFields($reference, $this->editLocale);
$this->showForm = true;
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$reference = CmsReference::find($this->editingId);
if ($reference) {
$this->loadLocaleFields($reference, $locale);
}
}
};
$addGalleryImage = fn () => $this->gallery[] = ['image' => '', 'caption' => ''];
$removeGalleryImage = function (int $index) {
unset($this->gallery[$index]);
$this->gallery = array_values($this->gallery);
};
$moveGalleryImage = function (int $index, int $direction) {
$target = $index + $direction;
if (! isset($this->gallery[$index], $this->gallery[$target])) {
return;
}
[$this->gallery[$index], $this->gallery[$target]] = [$this->gallery[$target], $this->gallery[$index]];
};
$save = function () {
$validated = validator([
'referenceTitle' => $this->referenceTitle,
'description' => $this->description,
'location' => $this->location,
'year' => $this->year,
'image' => $this->image,
'brandSlugs' => $this->brandSlugs,
'is_published' => $this->is_published,
'order' => $this->order,
], [
'referenceTitle' => 'required|string|max:255',
'description' => 'nullable|string|max:4000',
'location' => 'nullable|string|max:255',
'year' => 'nullable|string|max:20',
'image' => 'nullable|string|max:500',
'brandSlugs' => 'array',
'brandSlugs.*' => 'string|exists:cms_brands,slug',
'is_published' => 'boolean',
'order' => 'integer|min:0',
], [
'referenceTitle.required' => 'Bitte einen Titel angeben.',
'brandSlugs.*.exists' => 'Eine der gewählten Marken gibt es nicht mehr.',
])->validate();
$reference = $this->editingId
? CmsReference::findOrFail($this->editingId)
: CmsReference::query()->make();
$reference->setTranslation('title', $this->editLocale, trim($validated['referenceTitle']));
$reference->setTranslation('description', $this->editLocale, trim((string) ($validated['description'] ?? '')));
$reference->location = trim((string) ($validated['location'] ?? '')) ?: null;
$reference->year = trim((string) ($validated['year'] ?? '')) ?: null;
$reference->image = trim((string) ($validated['image'] ?? '')) ?: null;
$reference->gallery = CmsReference::normalizeGallery($this->gallery);
$reference->brand_slugs = array_values(array_unique($validated['brandSlugs']));
$reference->is_published = $validated['is_published'];
$reference->order = $validated['order'];
$reference->save();
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Referenz wurde erfolgreich gespeichert.');
};
$togglePublished = function (int $id) {
$reference = CmsReference::findOrFail($id);
$reference->update(['is_published' => ! $reference->is_published]);
Flux::toast(heading: 'Status geändert', text: $reference->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$deleteReference = function (int $id) {
CmsReference::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Referenz wurde entfernt.');
};
$cancelForm = function () {
$this->showForm = false;
$this->editingId = null;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<flux:heading size="xl">Referenzen</flux:heading>
<flux:text class="mt-1">Realisierte Projekte für die Seite /referenzen. Nur ausgeführte Aufträge – Entwürfe und Visualisierungen gehören in die Galerie der jeweiligen Marke.</flux:text>
</div>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="openCreate">Neue Referenz</flux:button>
</div>
</div>
<div class="mb-4">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Referenz suchen..." icon="magnifying-glass"
size="sm" class="w-64" />
</div>
@if ($showForm)
<flux:card class="mb-6">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $editingId ? 'Referenz bearbeiten' : 'Neue Referenz' }}</flux:heading>
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<flux:input wire:model="referenceTitle" label="Titel ({{ strtoupper($editLocale) }})"
placeholder="Ankleide unter der Dachschräge" />
@error('referenceTitle') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
</div>
<flux:input wire:model="location" label="Ort (optional)" placeholder="Bielefeld" />
<flux:input wire:model="year" label="Jahr (optional)" placeholder="2025" />
<div class="md:col-span-2">
<flux:textarea wire:model="description" label="Beschreibung ({{ strtoupper($editLocale) }})" rows="4"
placeholder="Was wurde gebaut, was war die Aufgabe, was ist das Besondere?" />
</div>
<flux:input wire:model="order" label="Sortierung" type="number" />
</div>
{{-- Beteiligte Marken --}}
<div class="mt-6">
<flux:heading size="sm" class="mb-1">Beteiligte Marken</flux:heading>
<flux:text class="mb-3 text-sm">Jede gewählte Marke wird unter der Referenz genannt und auf ihre Markenseite verlinkt.</flux:text>
<div class="flex flex-wrap gap-4">
@foreach ($this->brands as $brand)
<flux:checkbox wire:model="brandSlugs" value="{{ $brand->slug }}"
label="{{ $brand->getTranslation('name', 'de') }}" wire:key="brand-check-{{ $brand->slug }}" />
@endforeach
</div>
@error('brandSlugs.*') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
</div>
{{-- Titelbild --}}
<div class="mt-6">
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Titelbild</label>
<div class="flex items-start gap-3">
@if ($image)
<div class="h-20 w-28 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" alt="" />
</div>
@endif
<div class="flex-1 space-y-2">
<livewire:admin.cms.media-picker :value="null" field="reference_image" type="image" profile="card"
label="Bild wählen" :key="'reference-img-' . ($editingId ?? 'new')" />
<flux:input wire:model="image" size="sm" placeholder="Oder Dateiname manuell..." />
</div>
</div>
</div>
{{-- Weitere Bilder --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">
<flux:heading size="sm">Weitere Bilder</flux:heading>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addGalleryImage">Bild hinzufügen</flux:button>
</div>
@if (empty($gallery))
<p class="text-sm text-zinc-400">Noch keine weiteren Bilder.</p>
@else
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
@foreach ($gallery as $i => $item)
<div wire:key="reference-gallery-{{ $i }}" class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
@if (!empty($item['image']))
<div class="mb-2 h-24 w-full overflow-hidden rounded-md">
<img src="{{ media_url($item['image']) }}" class="h-full w-full object-cover" alt="" />
</div>
@endif
<flux:input wire:model="gallery.{{ $i }}.caption" size="sm" class="mb-2"
placeholder="Bildunterschrift (optional)" />
<div class="flex items-center justify-between gap-2">
<livewire:admin.cms.media-picker :value="null" field="reference_gallery:{{ $i }}"
type="image" profile="card" label="Wählen"
:key="'reference-gallery-' . ($editingId ?? 'new') . '-' . $i" />
<div class="flex gap-1">
<flux:button size="xs" variant="ghost" icon="chevron-left" wire:click="moveGalleryImage({{ $i }}, -1)" />
<flux:button size="xs" variant="ghost" icon="chevron-right" wire:click="moveGalleryImage({{ $i }}, 1)" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeGalleryImage({{ $i }})" />
</div>
</div>
<flux:input wire:model="gallery.{{ $i }}.image" size="sm" class="mt-2"
placeholder="Oder Dateiname manuell..." />
</div>
@endforeach
</div>
@endif
</div>
<div class="mt-4">
<flux:switch wire:model="is_published" label="Veröffentlicht" />
</div>
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancelForm">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->references as $reference)
<div wire:key="reference-{{ $reference->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
@if ($reference->image)
<div class="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($reference->image) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
</div>
@else
<div class="flex h-12 w-20 shrink-0 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-700">
<x-heroicon-o-photo class="h-6 w-6 text-zinc-400" />
</div>
@endif
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-zinc-800 dark:text-zinc-200">
{{ $reference->getTranslation('title', $editLocale) ?: $reference->getTranslation('title', 'de') }}
</span>
@foreach ($reference->brand_slugs ?? [] as $slug)
<flux:badge size="sm" color="blue">{{ $slug }}</flux:badge>
@endforeach
@unless ($reference->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<p class="text-sm text-zinc-500">
{{ $reference->location ?: '–' }} · {{ $reference->year ?: '–' }} · {{ count(CmsReference::normalizeGallery($reference->gallery)) + ($reference->image ? 1 : 0) }} Bilder
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $reference->id }})" />
<flux:button size="sm" variant="ghost" :icon="$reference->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $reference->id }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="deleteReference({{ $reference->id }})"
wire:confirm="Referenz wirklich löschen?" />
</div>
</div>
@empty
<div class="py-12 text-center">
<x-heroicon-o-photo class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Keine Referenzen</flux:heading>
<flux:text>Solange hier nichts steht, zeigt /referenzen die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien.</flux:text>
</div>
@endforelse
</div>
</flux:card>
</div>

View file

@ -1,4 +1,4 @@
<section class="section-padding flex items-center relative border-b border-border/30">
<section id="seitenkopf" class="section-padding flex items-center relative border-b border-border/30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-[hsl(var(--hero-container))] rounded-[20px] w-[95%]">
<div class="grid lg:grid-cols-2 gap-16 items-center">
<div class="space-y-8 slide-right delay-200">

View file

@ -1,4 +1,4 @@
<section class="{{ $bg }} text-secondary-foreground section-padding">
<section id="cta" class="{{ $bg }} text-secondary-foreground section-padding">
<div class="container-narrow text-center">
<div class="spacing-section">
<h2 class="text-section-title text-white slide-up delay-300">

View file

@ -1,6 +1,6 @@
{{-- Livewire braucht ein Wurzelelement. Solange kein Display veröffentlicht ist, bleibt es leer –
lieber gar keine Sektion als ein Rahmen mit ewigem Ladebalken. --}}
<div>
<div id="displays">
@if ($displayId !== null)
<section class="section-padding {{ $bg }}">
<div class="container-padding">

View file

@ -1,6 +1,6 @@
@php($faqCounter = 0)
<div x-data="{ openIndex: null }">
<section class="section-padding {{ $bg }}">
<section id="faq" class="section-padding {{ $bg }}">
<div class="container-padding">
{{-- Section Title --}}
<div class="text-center mb-16">

View file

@ -1,4 +1,4 @@
<section class="py-8 bg-background border-b border-border/30">
<section id="gruender" class="py-8 bg-background border-b border-border/30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col sm:flex-row items-center gap-6 sm:gap-8">
@if(isset($content['image']))

View file

@ -1,4 +1,4 @@
<section class="section-padding flex items-center relative border-b border-border/30">
<section id="hero" class="section-padding flex items-center relative border-b border-border/30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-hero-container rounded-[20px] w-[95%]">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
{{-- Left Content --}}

View file

@ -1,5 +1,5 @@
@if (!empty($content))
<section class="relative h-[30vh] min-h-[220px] overflow-hidden">
<section id="bild" class="relative h-[30vh] min-h-[220px] overflow-hidden">
<x-web-picture
src="{{ theme_image_url($content['image'] ?? '') }}"
alt="{{ $content['image_alt'] ?? 'Dekoratives Bild' }}"

View file

@ -1,6 +1,6 @@
{{-- Bewusst klein und dezent: wer gezielt sucht, findet den Weg; wer wegen Einrichtung
kommt, wird nicht abgelenkt. Keine Preise, keine Renditeversprechen. --}}
<section class="py-12 {{ $bg }}">
<section id="immobilien" class="py-12 {{ $bg }}">
<div class="container-narrow">
<div class="border-t border-border pt-10 text-center">
<h2 class="text-xl font-medium">{!! $content['title'] ?? '' !!}</h2>

View file

@ -1,4 +1,4 @@
<section class="section-padding">
<section id="magazin" class="section-padding">
<div class="container-padding">
<div class="text-center mb-16">
<h1 class="text-hero mb-6">

View file

@ -1,4 +1,4 @@
<section class="section-padding bg-secondary text-secondary-foreground">
<section id="marken-cta" class="section-padding bg-secondary text-secondary-foreground">
<div class="container-narrow text-center spacing-section">
<h2 class="text-section-title text-white">{!! $content['title'] ?? '' !!}</h2>
<p class="text-large max-w-2xl mx-auto leading-relaxed">{{ $content['text'] ?? '' }}</p>

View file

@ -1,6 +1,6 @@
{{-- Optionaler Block laut Spec – über das CMS-Feld `enabled` abschaltbar. --}}
@if ($content['enabled'] ?? true)
<section class="py-16 {{ $bg }}">
<section id="wachstum" class="py-16 {{ $bg }}">
<div class="container-narrow text-center">
<h2 class="text-2xl font-medium">{!! $content['title'] ?? '' !!}</h2>
<p class="text-muted-foreground leading-relaxed mt-4">{{ $content['text'] ?? '' }}</p>

View file

@ -18,7 +18,7 @@
}
@endphp
<section class="section-padding {{ $bg }}">
<section id="netzwerk" class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="container-narrow text-center">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>

View file

@ -14,9 +14,12 @@
@endif
</div>
{{-- Ohne gepflegten Fertigungsort bleibt nur die Überschrift und der Ablauf-Block. --}}
@if ($this->locations !== [])
<div class="grid lg:grid-cols-2 gap-10 lg:gap-16 items-center">
{{-- Schematische Karte: Bielefeld als Drehscheibe, die vier Standorte als Speichen.
Keine Geografie, nur Richtung und Entfernung. Unter md tragen die Kacheln alles. --}}
{{-- Schematische Karte: Bielefeld als Drehscheibe, die Standorte als Speichen.
Keine Geografie, nur Richtung und Entfernung. Positionen aus OriginSection::MAP_POSITIONS,
eine Marke ohne Position steht nur in den Kacheln. Unter md tragen die Kacheln alles. --}}
<div class="hidden md:block card-elevated rounded-2xl p-6 lg:p-8 slide-left delay-400">
<svg class="origin-map" viewBox="0 0 640 400" role="img"
aria-label="{{ $content['map_alt'] ?? '' }}">
@ -27,10 +30,10 @@
@endif
@foreach ($this->locations as $index => $location)
@continue(empty($location['map']))
@php
$x = (int) ($location['x'] ?? 0);
$y = (int) ($location['y'] ?? 0);
$left = ($location['label'] ?? 'right') === 'left';
$x = $location['map']['x'];
$y = $location['map']['y'];
$midX = (int) round(($hubX + $x) / 2);
$midY = (int) round(($hubY + $y) / 2);
@endphp
@ -41,10 +44,11 @@
@endforeach
@foreach ($this->locations as $index => $location)
@continue(empty($location['map']))
@php
$x = (int) ($location['x'] ?? 0);
$y = (int) ($location['y'] ?? 0);
$left = ($location['label'] ?? 'right') === 'left';
$x = $location['map']['x'];
$y = $location['map']['y'];
$left = $location['map']['label'] === 'left';
$labelX = $left ? $x - 14 : $x + 14;
@endphp
<circle class="origin-map-dot" cx="{{ $x }}" cy="{{ $y }}" r="7" />
@ -63,10 +67,10 @@
</svg>
</div>
{{-- Die vier Standorte --}}
{{-- Eine Kachel je Marke mit Fertigungsort --}}
<div class="grid sm:grid-cols-2 gap-6 slide-right delay-400">
@foreach ($this->locations as $index => $location)
<div class="card-elevated rounded-2xl p-6 flex flex-col gap-2" wire:key="origin-{{ $index }}">
@foreach ($this->locations as $location)
<div class="card-elevated rounded-2xl p-6 flex flex-col gap-2" wire:key="origin-{{ $location['slug'] }}">
<span class="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-secondary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"
aria-hidden="true">
@ -84,9 +88,9 @@
@if (!empty($location['distance']))
<span class="text-clay">{{ $location['distance'] }} {{ $content['distance_suffix'] ?? '' }}</span>
@endif
@if (!empty($location['link']) && !empty($location['link_text']))
@if (!empty($location['link']) && !empty($content['link_text']))
<a href="{{ $location['link'] }}" class="font-medium text-primary hover:text-secondary transition-colors">
{{ $location['link_text'] }}
{{ $content['link_text'] }}
</a>
@endif
</div>
@ -94,6 +98,7 @@
@endforeach
</div>
</div>
@endif
{{-- Gefertigt oder vertrieben? Der Weg eines Maßmöbels am Beispiel CABINET. --}}
@if ($steps !== [])

View file

@ -1,4 +1,4 @@
<section class="section-padding">
<section id="geschichte" class="section-padding">
<div class="container-padding text-center">
<div class="text-center mb-16 slide-up delay-200">
<h2 class="text-section-title text-foreground mb-12">

View file

@ -21,7 +21,7 @@ function renderHeroIcon($iconName, $style = 'outline') {
}
}
@endphp
<section class="section-padding bg-accent">
<section id="werte" class="section-padding bg-accent">
<div class="container-padding">
<div class="text-center mb-16 slide-up delay-300">
<h2 class="text-section-title">{!! $content['title'] !!}</h2>

View file

@ -1,6 +1,6 @@
{{-- Seitenkopf für Unterseiten – abgesetzte graue Fläche wie der Hero auf Startseite und
Netzwerkseite, damit er sich klar von den Blöcken darunter absetzt. --}}
<section class="section-padding flex items-center relative border-b border-border/30 {{ $bg }}">
<section id="seitenkopf" class="section-padding flex items-center relative border-b border-border/30 {{ $bg }}">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20 bg-hero-container rounded-[20px] w-[95%]">
{{-- Breadcrumb: Home → Marken --}}
<nav aria-label="Breadcrumb" class="mb-8">

View file

@ -1,4 +1,4 @@
<section class="{{ $bg }} text-secondary-foreground section-padding">
<section id="cta" class="{{ $bg }} text-secondary-foreground section-padding">
<div class="container-narrow text-center">
<div class="spacing-section">
<h2 class="text-section-title text-white slide-up delay-300">

View file

@ -1,4 +1,4 @@
<section class="section-padding {{ $bg }}">
<section id="projekte" class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="text-center max-w-2xl mx-auto">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
@ -13,7 +13,7 @@
<figure class="card-elevated rounded-2xl overflow-hidden group" wire:key="ref-{{ $project['key'] }}">
<div class="relative overflow-hidden">
<img src="{{ theme_image_url($project['image']) }}"
alt="{{ $project['caption'] ?: $project['brand'] }}" loading="lazy"
alt="{{ $project['caption'] ?: ($project['brands'][0]['name'] ?? '') }}" loading="lazy"
class="w-full aspect-[4/3] object-cover transition-transform duration-300 group-hover:scale-105" />
<span class="absolute top-3 left-3 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
{{ __('ui.brand_gallery_badge_project') }}
@ -23,13 +23,32 @@
@if (!empty($project['caption']))
<p class="font-medium">{{ $project['caption'] }}</p>
@endif
@if ($project['has_detail_page'])
<a href="{{ route('marken.show', ['slug' => $project['brand_slug']]) }}"
class="text-sm text-secondary underline underline-offset-2 hover:no-underline mt-1 inline-block">
{{ $project['brand'] }}
</a>
@else
<p class="text-sm text-muted-foreground mt-1">{{ $project['brand'] }}</p>
@if (!empty($project['meta']))
<p class="text-xs uppercase tracking-wider text-clay mt-1">{{ $project['meta'] }}</p>
@endif
@if (!empty($project['description']))
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{{ $project['description'] }}</p>
@endif
@if (!empty($project['gallery']))
{{-- Weitere Bilder der Referenz als kleine Reihe --}}
<div class="flex gap-2 mt-3 overflow-x-auto">
@foreach ($project['gallery'] as $item)
<img src="{{ theme_image_url($item['image']) }}" alt="{{ $item['caption'] ?: $project['caption'] }}"
loading="lazy" class="h-14 w-20 shrink-0 rounded-md object-cover" />
@endforeach
</div>
@endif
@if (!empty($project['brands']))
<p class="text-sm mt-2 flex flex-wrap gap-x-3 gap-y-1">
@foreach ($project['brands'] as $brand)
@if (!empty($brand['link']))
<a href="{{ $brand['link'] }}"
class="text-secondary underline underline-offset-2 hover:no-underline">{{ $brand['name'] }}</a>
@else
<span class="text-muted-foreground">{{ $brand['name'] }}</span>
@endif
@endforeach
</p>
@endif
</figcaption>
</figure>

View file

@ -1,4 +1,4 @@
<section class="section-padding {{ $bg }}">
<section id="engagement" class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="text-center mb-12 slide-up delay-300">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>

View file

@ -1,4 +1,4 @@
<section class="section-padding {{ $bg }}">
<section id="vision" class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">

View file

@ -1,6 +1,6 @@
<div>
<!-- Hero Section with Contact Form -->
<section class="py-10 flex items-center relative overflow-hidden">
<section id="kontakt" class="py-10 flex items-center relative overflow-hidden">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-[hsl(var(--hero-container))] rounded-[20px] w-[95%]">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Left Side - Hero Text -->

View file

@ -2,7 +2,7 @@
Immobilien dunkel mit negativem Logo (Tokens `--footer-*` in theme-b2in.css, Logo in mount()).
pb-28 auf kleinen Schirmen: darunter schwebt das Terminanfrage-Widget, das sonst die
letzten Zeilen des Footers verdecken wuerde. --}}
<footer class="section-footer section-padding pb-28 sm:pb-16">
<footer id="footer" class="section-footer section-padding pb-28 sm:pb-16">
<div class="container-padding">
{{-- Main Content --}}
<div class="text-center spacing-section">

View file

@ -92,6 +92,7 @@ Route::middleware(['auth', 'partner.setup'])->group(function () {
Volt::route('admin/flux-cms/media', 'admin.cms.media-index')->name('cms.media.index');
Volt::route('admin/flux-cms/articles', 'admin.cms.articles-index')->name('cms.articles.index');
Volt::route('admin/flux-cms/brands', 'admin.cms.brands-index')->name('cms.brands.index');
Volt::route('admin/flux-cms/references', 'admin.cms.references-index')->name('cms.references.index');
// Documentation
Volt::route('admin/documentation', 'admin.documentation')->name('admin.documentation');

View file

@ -327,3 +327,69 @@ it('saves the widget label and the closing block', function () {
->and($brand->getTranslation('cta_title', 'de'))->toBe('Ihre Küche. Persönlich geplant.')
->and($brand->getTranslation('cta_text', 'de'))->toBe('Jeder Raum ist anders.');
});
// ========================================
// Fertigungsort für „Aus der Region gefertigt“ (Nachtrag 11)
// ========================================
it('saves the origin of a brand per language', function () {
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.brands-index')
->call('openCreate')
->set('slug', 'mit-herkunft')
->set('brandName', 'Mit Herkunft')
->set('originCity', 'Bünde')
->set('originDistance', 'rund 30 km')
->set('originRole', 'Küchen aus eigener Fertigung.')
->call('save')
->assertHasNoErrors();
$brand = CmsBrand::where('slug', 'mit-herkunft')->firstOrFail();
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
->and($brand->getTranslation('origin_distance', 'de'))->toBe('rund 30 km')
->and($brand->getTranslation('origin_role', 'de'))->toBe('Küchen aus eigener Fertigung.');
Volt::test('admin.cms.brands-index')
->call('openEdit', $brand->id)
->assertSet('originCity', 'Bünde')
->call('switchLocale', 'en')
->assertSet('originCity', '')
->set('brandName', 'With origin')
->set('originCity', 'Buende')
->call('save')
->assertHasNoErrors();
$brand->refresh();
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
->and($brand->getTranslation('origin_city', 'en'))->toBe('Buende');
});
it('clears the origin when the city is emptied', function () {
$this->actingAs(User::factory()->create());
$brand = CmsBrand::factory()->create(['origin_city' => ['de' => 'Herford']]);
Volt::test('admin.cms.brands-index')
->call('openEdit', $brand->id)
->set('originCity', '')
->call('save')
->assertHasNoErrors();
expect($brand->fresh()->getTranslation('origin_city', 'de', false))->toBe('');
});
it('is reachable from the sidebar for admins', function () {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
$user = User::factory()->create();
$user->assignRole('Admin');
$html = $this->actingAs($user)
->get(route('cms.brands.index'))
->assertSuccessful()
->getContent();
expect($html)->toContain(route('cms.brands.index'))
->toMatch('#data-content>\s*Marken\s*</div>#');
});

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
use App\Models\CmsReference;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
uses(RefreshDatabase::class);
/**
* Referenzen verwalten (Nachtrag 13).
*/
beforeEach(function () {
url()->forceRootUrl('https://'.config('domains.domain_portal'));
});
it('requires authentication', function () {
$this->get(route('cms.references.index'))->assertRedirect('/login');
});
it('renders for authenticated users', function () {
$this->actingAs(User::factory()->create())
->get(route('cms.references.index'))
->assertSuccessful()
->assertSee('Referenzen')
->assertSee('Keine Referenzen');
});
it('creates a reference with brands, cover and gallery', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.references-index')
->call('openCreate')
->set('referenceTitle', 'Ankleide unter der Dachschräge')
->set('location', 'Bielefeld')
->set('year', '2025')
->set('description', 'Maßschrank bis unter den First.')
->set('image', 'referenzen/ankleide.webp')
->set('brandSlugs', ['cabinet', 'tischlerei'])
->call('addGalleryImage')
->set('gallery.0.image', 'referenzen/ankleide-2.webp')
->set('gallery.0.caption', 'Innenausbau')
->call('addGalleryImage')
->call('save')
->assertHasNoErrors();
$reference = CmsReference::firstOrFail();
expect($reference->getTranslation('title', 'de'))->toBe('Ankleide unter der Dachschräge')
->and($reference->location)->toBe('Bielefeld')
->and($reference->year)->toBe('2025')
->and($reference->image)->toBe('referenzen/ankleide.webp')
->and($reference->brand_slugs)->toBe(['cabinet', 'tischlerei'])
->and($reference->gallery)->toBe([['image' => 'referenzen/ankleide-2.webp', 'caption' => 'Innenausbau']])
->and($reference->is_published)->toBeTrue();
});
it('requires a title', function () {
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.references-index')
->call('openCreate')
->set('referenceTitle', '')
->call('save')
->assertHasErrors(['referenceTitle']);
expect(CmsReference::count())->toBe(0);
});
it('rejects a brand slug that does not exist', function () {
$this->actingAs(User::factory()->create());
Volt::test('admin.cms.references-index')
->call('openCreate')
->set('referenceTitle', 'Test')
->set('brandSlugs', ['gibt-es-nicht'])
->call('save')
->assertHasErrors(['brandSlugs.0']);
});
it('edits an existing reference and keeps both languages', function () {
$this->actingAs(User::factory()->create());
$reference = CmsReference::factory()->create(['title' => ['de' => 'Küche', 'en' => 'Kitchen']]);
Volt::test('admin.cms.references-index')
->call('openEdit', $reference->id)
->assertSet('referenceTitle', 'Küche')
->set('referenceTitle', 'Küche in Hiddenhausen')
->call('save')
->assertHasNoErrors();
$reference->refresh();
expect($reference->getTranslation('title', 'de'))->toBe('Küche in Hiddenhausen')
->and($reference->getTranslation('title', 'en'))->toBe('Kitchen');
});
it('reorders and removes gallery images', function () {
$this->actingAs(User::factory()->create());
$reference = CmsReference::factory()->withGallery(3)->create();
Volt::test('admin.cms.references-index')
->call('openEdit', $reference->id)
->call('moveGalleryImage', 2, -1)
->call('moveGalleryImage', 0, -1)
->call('removeGalleryImage', 2)
->call('save')
->assertHasNoErrors();
expect(array_column($reference->fresh()->gallery, 'image'))
->toBe(['referenzen/bild-1.webp', 'referenzen/bild-3.webp']);
});
it('toggles the published state and deletes', function () {
$this->actingAs(User::factory()->create());
$reference = CmsReference::factory()->create();
Volt::test('admin.cms.references-index')
->call('togglePublished', $reference->id);
expect($reference->fresh()->is_published)->toBeFalse();
Volt::test('admin.cms.references-index')
->call('deleteReference', $reference->id);
expect(CmsReference::count())->toBe(0);
});
it('is linked from the sidebar while the real estate projects are not', function () {
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
$user = User::factory()->create();
$user->assignRole('Admin');
$html = $this->actingAs($user)
->get(route('cms.references.index'))
->assertSuccessful()
->getContent();
expect($html)->toMatch('#data-content>\s*Referenzen\s*</div>#')
->not->toContain(route('cms.projects.index'));
});

View file

@ -52,8 +52,8 @@ test('cms dashboard renders for authenticated users', function () {
test('cms dashboard shows correct stats', function () {
$user = User::factory()->create();
CmsProject::factory()->published()->count(2)->create();
CmsProject::factory()->unpublished()->create();
\App\Models\CmsReference::factory()->published()->count(2)->create();
\App\Models\CmsReference::factory()->unpublished()->create();
Volt::actingAs($user)
->test('admin.cms.dashboard')
@ -408,3 +408,129 @@ test('cms articles index can toggle published status', function () {
expect($article->refresh()->is_published)->toBeFalse();
});
// ========================================
// Inhalte-Editor: Seitenbaum, Beschriftungen, Vorschau (Nachtrag 12)
// ========================================
test('cms content index shows the pages of the website instead of raw groups', function () {
$user = User::factory()->create();
CmsContent::create([
'group' => 'home',
'key' => 'hero',
'type' => 'json',
'value' => ['de' => ['title' => 'Test']],
]);
Volt::actingAs($user)
->test('admin.cms.content-index')
->assertSee('Startseite')
->assertSee('Aus der Region gefertigt')
->assertSee('Auf jeder Seite')
->assertSee('Rechtliches')
->assertDontSee('Nicht mehr verwendet');
});
test('cms content index selects one section and labels its fields in plain language', function () {
$user = User::factory()->create();
$hero = CmsContent::create([
'group' => 'home',
'key' => 'hero',
'type' => 'json',
'value' => ['de' => ['title' => 'Hero Title', 'button_text' => 'Los']],
]);
CmsContent::create([
'group' => 'home',
'key' => 'sponsoring',
'type' => 'json',
'value' => ['de' => ['title' => 'Sponsoring Title']],
]);
$component = Volt::actingAs($user)
->test('admin.cms.content-index')
->call('selectSection', 'home', 'hero')
->assertSet('selectedGroup', 'home')
->assertSet('selectedKey', 'hero')
->assertSee('Überschrift')
->assertSee('Button-Beschriftung')
->assertSee('Hero Title')
->assertDontSee('Sponsoring Title');
expect($component->instance()->previewUrl)->toEndWith('/?cms-preview=0#hero');
$component->call('startFieldEdit', $hero->id, 'title')
->set('editValue', 'Neu')
->call('saveEdit')
->assertSet('previewVersion', 1);
});
test('cms content index lists unregistered rows under "not used any more"', function () {
$user = User::factory()->create();
CmsContent::create([
'group' => 'netzwerk',
'key' => 'teasers',
'type' => 'json',
'value' => ['de' => ['title' => 'Alt']],
]);
Volt::actingAs($user)
->test('admin.cms.content-index')
->assertSee('Nicht mehr verwendet')
->assertSee('netzwerk / teasers')
->call('selectSection', 'netzwerk', 'teasers')
->assertSee('Alt');
expect(Volt::actingAs($user)->test('admin.cms.content-index')
->call('selectSection', 'netzwerk', 'teasers')
->instance()->previewUrl)->toBeNull();
});
test('cms content index creates a missing section from the lang template', function () {
$user = User::factory()->create();
expect(CmsContent::where('group', 'global')->where('key', 'appointment_widget')->exists())->toBeFalse();
Volt::actingAs($user)
->test('admin.cms.content-index')
->call('createSection', 'global', 'appointment_widget')
->assertSet('selectedGroup', 'global')
->assertSet('selectedKey', 'appointment_widget');
$row = CmsContent::where('group', 'global')->where('key', 'appointment_widget')->first();
expect($row)->not->toBeNull();
expect($row->getTranslation('value', 'de'))->toBe(cms_theme_section('appointment_widget'));
});
test('cms content index refuses to create a section without a lang template', function () {
$user = User::factory()->create();
Volt::actingAs($user)
->test('admin.cms.content-index')
->call('createSection', 'global', 'contact')
->assertSet('selectedKey', null);
expect(CmsContent::where('group', 'global')->where('key', 'contact')->exists())->toBeFalse();
});
test('cms content index can hide the preview', function () {
$user = User::factory()->create();
CmsContent::create([
'group' => 'home',
'key' => 'hero',
'type' => 'json',
'value' => ['de' => ['title' => 'Test']],
]);
Volt::actingAs($user)
->test('admin.cms.content-index')
->call('selectSection', 'home', 'hero')
->assertSee('Vorschau der Website')
->call('togglePreview')
->assertSet('showPreview', false)
->assertDontSee('Vorschau der Website');
});

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use App\Services\CmsPageTree;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Seitenbaum des Inhalte-Editors (config/cms_pages.php, Nachtrag 12).
*/
function cmsRow(string $group, string $key, array $value = ['title' => 'x']): CmsContent
{
return CmsContent::create([
'group' => $group,
'key' => $key,
'type' => 'json',
'value' => ['de' => $value],
'order' => 0,
]);
}
it('lists the pages in menu order with the homepage sections top to bottom', function () {
$pages = app(CmsPageTree::class)->pages();
expect(array_slice(array_keys($pages), 0, 7))->toBe(['home', 'marken', 'referenzen', 'magazin', 'about', 'contact', 'faq']);
expect($pages['home']['title'])->toBe('Startseite')
->and($pages['home']['path'])->toBe('/');
$keys = array_map(fn (array $s) => $s['group'].'/'.$s['key'], $pages['home']['sections']);
expect(array_slice($keys, 0, 5))->toBe(['home/hero', 'home/founder_bar', 'home/audience_switch', 'home/core_brands', 'home/origin_section'])
->and(array_slice($keys, -2))->toBe(['home/immobilien_teaser', 'global/cta_section']);
});
it('marks sections that exist in the database and leaves the rest without content id', function () {
$hero = cmsRow('home', 'hero');
$pages = app(CmsPageTree::class)->pages();
$sections = collect($pages['home']['sections'])->keyBy('key');
expect($sections['hero']['content_id'])->toBe($hero->id)
->and($sections['founder_bar']['content_id'])->toBeNull()
->and($sections['hero']['theme_key'])->toBe('hero')
->and($sections['hero']['anchor'])->toBe('hero');
});
it('collects database rows that no page lists under "not used any more"', function () {
cmsRow('netzwerk', 'teasers');
cmsRow('home', 'synergie_section');
$pages = app(CmsPageTree::class)->pages();
expect($pages)->toHaveKey(CmsPageTree::UNLISTED);
expect(array_key_last($pages))->toBe(CmsPageTree::UNLISTED);
$titles = array_column($pages[CmsPageTree::UNLISTED]['sections'], 'title');
expect($titles)->toBe(['home / synergie_section', 'netzwerk / teasers']);
});
it('omits the collection point while every row is listed on a page', function () {
cmsRow('home', 'hero');
expect(app(CmsPageTree::class)->pages())->not->toHaveKey(CmsPageTree::UNLISTED);
});
it('resolves a section by group and key', function () {
cmsRow('marken', 'network');
$section = app(CmsPageTree::class)->section('marken', 'network');
expect($section['title'])->toBe('Mehr als Marken: unser Netzwerk')
->and($section['page'])->toBe('marken')
->and($section['path'])->toBe('/marken')
->and($section['content_id'])->not->toBeNull();
expect(app(CmsPageTree::class)->section('marken', 'gibt-es-nicht'))->toBeNull();
});
it('labels fields from the dictionary, a section override or a readable fallback', function () {
config()->set('cms_pages.pages.home.sections.0.fields', ['title' => 'Große Überschrift']);
$tree = app(CmsPageTree::class);
expect($tree->fieldLabel('title', 'home', 'hero'))->toBe('Große Überschrift')
->and($tree->fieldLabel('title', 'home', 'sponsoring'))->toBe('Überschrift')
->and($tree->fieldLabel('button_text'))->toBe('Button-Beschriftung')
->and($tree->fieldLabel('some_unknown_key'))->toBe('Some unknown key');
});
it('builds the preview url from the frontend domain, path, version and anchor', function () {
config()->set('domains.protocol', 'https://');
config()->set('domains.domain_b2in', 'b2in.test');
$tree = app(CmsPageTree::class);
expect($tree->previewUrl($tree->section('home', 'origin_section'), 3))
->toBe('https://b2in.test/?cms-preview=3#herkunft');
expect($tree->previewUrl($tree->section('faq', 'faq_immobilien')))
->toBe('https://b2in.test/immobilien/faq?cms-preview=0#faq');
expect($tree->previewUrl($tree->section('magazin', 'detail')))
->toBe('https://b2in.test/magazin?cms-preview=0');
expect($tree->previewUrl(null))->toBeNull();
expect($tree->previewUrl(['path' => null]))->toBeNull();
});
it('finds the lang template for every registered section that has one', function () {
$tree = app(CmsPageTree::class);
foreach (config('cms_pages.pages') as $page) {
foreach ($page['sections'] as $section) {
$themeKey = $tree->themeKey($section['group'], $section['key']);
// Kontaktdaten und die rechtlichen Seiten liegen nur in der Datenbank (CmsLegalSeeder), nicht in b2in.php.
if ($section['group'] === 'legal' || ($section['group'] === 'global' && $section['key'] === 'contact')) {
expect($themeKey)->toBeNull();
continue;
}
expect($themeKey)->not->toBeNull("Kein Theme-Key für {$section['group']}/{$section['key']}");
expect(cms_theme_section($themeKey))->toBeArray("Keine Lang-Vorlage für {$themeKey}");
}
}
});
it('renders an anchor on the website for every section the registry points to', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$expected = [];
foreach (config('cms_pages.pages') as $page) {
foreach ($page['sections'] as $section) {
$path = $section['path'] ?? $page['path'] ?? null;
if ($path === null || empty($section['anchor'])) {
continue;
}
$expected[$path][$section['anchor']] = true;
}
}
foreach ($expected as $path => $anchors) {
$html = $this->get($path)->assertSuccessful()->getContent();
foreach (array_keys($anchors) as $anchor) {
expect(str_contains($html, 'id="'.$anchor.'"'))->toBeTrue("Anker #{$anchor} fehlt auf {$path}");
}
}
});

View file

@ -3,6 +3,10 @@
declare(strict_types=1);
use App\Livewire\Web\Components\Sections\OriginSection;
use App\Models\CmsBrand;
use Database\Seeders\BrandOriginSeeder;
use Database\Seeders\CmsBrandSeeder;
use Database\Seeders\OriginSectionCmsSeeder;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -10,7 +14,10 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3).
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
*
* Die Standorte kommen aus den Marken (origin_city, origin_distance, origin_role),
* die Kartenpositionen aus OriginSection::MAP_POSITIONS.
*/
function originBody(string $html): string
{
@ -19,7 +26,21 @@ function originBody(string $html): string
return substr($html, (int) strpos($html, '<main'));
}
it('shows the four locations on the homepage', function () {
function originSection(string $html): string
{
$html = originBody($html);
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
return substr($section, 0, (int) strpos($section, '</section>'));
}
// ========================================
// Standorte aus den Marken
// ========================================
it('shows the four locations from the seeded brands on the homepage', function () {
$this->seed(CmsBrandSeeder::class);
$this->get('/')
->assertSuccessful()
->assertSee('id="herkunft"', false)
@ -27,9 +48,96 @@ it('shows the four locations on the homepage', function () {
->assertSee('Bünde', false)
->assertSee('Herford')
->assertSee('Rheda-Wiedenbrück', false)
->assertSee('Köln', false);
->assertSee('Köln', false)
->assertSee('rund 200 km');
});
it('links every location to its brand page', function () {
$this->seed(CmsBrandSeeder::class);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
expect($section)->toContain('href="'.$link.'"');
}
});
it('drops an unpublished brand from the locations entirely', function () {
$this->seed(CmsBrandSeeder::class);
CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
// „Herford“ steht auch im Alt-Text der Karte – geprüft wird die Kachel.
expect($section)->toContain('href="/marken/cabinet"')
->toContain('wire:key="origin-cabinet"')
->not->toContain('wire:key="origin-februe"')
->not->toContain('/marken/februe');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
});
it('leaves out a brand whose origin city is empty', function () {
$this->seed(CmsBrandSeeder::class);
CmsBrand::query()->where('slug', 'februe')->update(['origin_city' => ['de' => '', 'en' => '']]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->toContain('wire:key="origin-rotpunkt"')
->not->toContain('wire:key="origin-februe"');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
});
it('shows a brand without a map position in the tiles only', function () {
$this->seed(CmsBrandSeeder::class);
CmsBrand::factory()->create([
'slug' => 'neue-marke',
'name' => ['de' => 'Neue Marke'],
'origin_city' => ['de' => 'Verl'],
'is_published' => true,
'order' => 9,
]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->toContain('Verl')
->toContain('wire:key="origin-neue-marke"');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
});
it('uses the lang fallback brands without links while no brand is maintained', function () {
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->toContain('Bünde')
->toContain('Köln')
->not->toContain('href="/marken/');
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
});
it('draws Bielefeld as the hub with one spoke per location', function () {
$this->seed(CmsBrandSeeder::class);
$html = $this->get('/')->assertSuccessful()->getContent();
expect($html)->toContain('class="origin-map"')
->toContain('origin-map-hub')
->toContain('Ostwestfalen');
expect(substr_count($html, 'class="origin-map-spoke"'))->toBe(4);
expect(substr_count($html, 'class="origin-map-dot"'))->toBe(4);
});
it('hides map and tiles but keeps the steps when no brand has an origin', function () {
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null, 'is_published' => true]);
$section = originSection($this->get('/')->assertSuccessful()->getContent());
expect($section)->not->toContain('origin-map')
->toContain('Fertigung in Köln');
});
// ========================================
// Platzierung und Ablauf-Block
// ========================================
it('sits between the core brands and the founder story on the homepage', function () {
$html = originBody($this->get('/')->assertSuccessful()->getContent());
@ -43,7 +151,7 @@ it('sits between the core brands and the founder story on the homepage', functio
});
it('appears on the brands page after the brand overview and before the network', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$this->seed(CmsBrandSeeder::class);
$html = originBody($this->get('/marken')->assertSuccessful()->getContent());
@ -56,50 +164,6 @@ it('appears on the brands page after the brand overview and before the network',
expect($network)->toBeGreaterThan($origin);
});
it('links every location to its brand page', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
$html = originBody($this->get('/')->assertSuccessful()->getContent());
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
$section = substr($section, 0, (int) strpos($section, '</section>'));
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
expect($section)->toContain('href="'.$link.'"');
}
});
it('drops the link of an unpublished brand but keeps its location', function () {
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
\App\Models\CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
$html = originBody($this->get('/')->assertSuccessful()->getContent());
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
$section = substr($section, 0, (int) strpos($section, '</section>'));
expect($section)->toContain('Herford')
->toContain('href="/marken/cabinet"')
->not->toContain('/marken/februe');
});
it('finds the brand page by name when the lang fallback uses other slugs', function () {
$html = originBody($this->get('/')->assertSuccessful()->getContent());
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
$section = substr($section, 0, (int) strpos($section, '</section>'));
expect($section)->toContain('href="/marken/cabinet"')
->toContain('href="/marken/tischlerei"');
});
it('draws Bielefeld as the hub with one spoke per location', function () {
$html = $this->get('/')->assertSuccessful()->getContent();
expect($html)->toContain('class="origin-map"')
->toContain('origin-map-hub')
->toContain('Ostwestfalen');
expect(substr_count($html, 'class="origin-map-spoke"'))->toBe(4);
expect(substr_count($html, 'class="origin-map-dot"'))->toBe(4);
});
it('separates manufacturing from sales for CABINET', function () {
$this->get('/')
->assertSuccessful()
@ -109,17 +173,16 @@ it('separates manufacturing from sales for CABINET', function () {
->assertSee('Aufbau vor Ort', false);
});
it('prefers the CMS content over the lang fallback', function () {
it('prefers the CMS content for the texts but keeps the locations from the brands', function () {
$this->seed(CmsBrandSeeder::class);
CmsContent::create([
'group' => 'home',
'key' => 'origin_section',
'type' => 'json',
'value' => ['de' => [
'title' => 'Gefertigt in <span class="text-secondary">OWL</span>',
'link_text' => 'Zur Manufaktur',
'hub' => ['city' => 'Bielefeld', 'x' => 400, 'y' => 175],
'locations' => [
['city' => 'Verl', 'brand' => 'Testmarke', 'x' => 300, 'y' => 100],
],
]],
'order' => 0,
]);
@ -127,43 +190,47 @@ it('prefers the CMS content over the lang fallback', function () {
$this->get('/')
->assertSuccessful()
->assertSee('Gefertigt in <span class="text-secondary">OWL</span>', false)
->assertSee('Verl')
->assertSee('Testmarke')
->assertDontSee('Rheda-Wiedenbrück');
});
it('skips locations without brand or city instead of breaking the map', function () {
Livewire::test(OriginSection::class)
->set('content', [
'title' => 'Herkunft',
'locations' => [
['city' => 'Bünde', 'brand' => 'Rotpunkt Küchen', 'x' => 1, 'y' => 1],
['city' => '', 'brand' => 'Ohne Stadt'],
'kaputt',
['brand' => '', 'city' => 'Ohne Marke'],
],
])
->assertSee('Rotpunkt Küchen', false)
->assertDontSee('Ohne Stadt')
->assertDontSee('Ohne Marke');
->assertSee('Zur Manufaktur')
->assertSee('Rheda-Wiedenbrück', false);
});
it('renders without the steps block when the CMS leaves it empty', function () {
Livewire::test(OriginSection::class)
->set('content', ['title' => 'Herkunft', 'locations' => [], 'steps' => []])
->set('content', ['title' => 'Herkunft', 'steps' => []])
->assertSee('Herkunft')
->assertDontSee('<ol', false);
});
it('creates the CMS section only when it is missing', function () {
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
// ========================================
// Seeder
// ========================================
it('creates the CMS section only when it is missing and strips the legacy locations', function () {
$this->seed(OriginSectionCmsSeeder::class);
$row = CmsContent::query()->where('group', 'home')->where('key', 'origin_section')->first();
expect($row)->not->toBeNull();
expect($row->getTranslation('value', 'de')['locations'])->toHaveCount(4);
expect($row->getTranslation('value', 'de'))->not->toHaveKey('locations');
$row->update(['value' => ['de' => ['title' => 'Vom Admin geändert']]]);
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
$row->update(['value' => ['de' => [
'title' => 'Vom Admin geändert',
'locations' => [['city' => 'Alt', 'brand' => 'Alt']],
]]]);
$this->seed(OriginSectionCmsSeeder::class);
expect($row->fresh()->getTranslation('value', 'de')['title'])->toBe('Vom Admin geändert');
$value = $row->fresh()->getTranslation('value', 'de');
expect($value['title'])->toBe('Vom Admin geändert');
expect($value)->not->toHaveKey('locations');
});
it('fills the origin of existing brands only where it is empty', function () {
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null]);
CmsBrand::factory()->create(['slug' => 'rotpunkt', 'origin_city' => ['de' => 'Eigener Ort']]);
$this->seed(BrandOriginSeeder::class);
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_city', 'de'))->toBe('Köln');
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_distance', 'en'))->toBe('about 200 km');
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_city', 'de'))->toBe('Eigener Ort');
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_distance', 'de', false))->toBeEmpty();
});

View file

@ -73,3 +73,65 @@ it('says so plainly when there is nothing to show yet', function () {
->assertSee('sobald wir sie zeigen dürfen', false)
->assertSee('Termin anfragen');
});
// ========================================
// Eigene Referenzen aus dem Portal (Nachtrag 13)
// ========================================
it('prefers maintained references over the brand galleries', function () {
CmsBrand::where('slug', 'cabinet')->firstOrFail()->update([
'gallery' => [['image' => 'a.webp', 'category' => 'project', 'caption' => 'Aus der Galerie']],
]);
\App\Models\CmsReference::factory()->forBrands(['cabinet', 'tischlerei'])->withGallery(2)->create([
'title' => ['de' => 'Ankleide unter der Dachschräge'],
'location' => 'Bielefeld',
'year' => '2025',
'description' => ['de' => 'Maßschrank bis unter den First.'],
'image' => 'referenzen/ankleide.webp',
]);
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
expect($html)->toContain('Ankleide unter der Dachschräge')
->toContain('Bielefeld · 2025')
->toContain('Maßschrank bis unter den First.')
->toContain('referenzen/bild-1.webp')
->toContain('href="/marken/cabinet"')
->toContain('href="/marken/tischlerei"')
->not->toContain('Aus der Galerie');
});
it('hides unpublished references and falls back to the galleries when none is published', function () {
CmsBrand::where('slug', 'rotpunkt')->firstOrFail()->update([
'gallery' => [['image' => 'c.webp', 'category' => 'project', 'caption' => 'Küche in Hiddenhausen']],
]);
\App\Models\CmsReference::factory()->unpublished()->create(['title' => ['de' => 'Noch nicht freigegeben']]);
$this->get('/referenzen')
->assertSuccessful()
->assertDontSee('Noch nicht freigegeben')
->assertSee('Küche in Hiddenhausen', false);
});
it('drops a brand that is unpublished and skips a reference without any image', function () {
CmsBrand::where('slug', 'februe')->firstOrFail()->update(['is_published' => false]);
\App\Models\CmsReference::factory()->forBrands(['februe', 'cabinet'])->create(['title' => ['de' => 'Büro in Herford']]);
\App\Models\CmsReference::factory()->create(['title' => ['de' => 'Ohne Bild'], 'image' => null, 'gallery' => []]);
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
expect($html)->toContain('Büro in Herford')
->toContain('href="/marken/cabinet"')
->not->toContain('/marken/februe')
->not->toContain('Ohne Bild');
});
it('uses the first gallery image as cover when no cover is set', function () {
\App\Models\CmsReference::factory()->withGallery(2)->create(['title' => ['de' => 'Nur Galerie'], 'image' => null]);
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
expect($html)->toContain('Nur Galerie')
->toContain('referenzen/bild-1.webp');
expect(substr_count($html, 'referenzen/bild-1.webp'))->toBe(1);
});