From 90aa079cf785cd95acb81c9a11310d44ce086ca0 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 15 Sep 2026 14:58:45 +0000 Subject: [PATCH] CMS: Standorte an der Marke, Seitenbaum mit Vorschau, Referenzen-Modul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 4 + .../Web/Components/Sections/OriginSection.php | 78 ++-- .../Components/Sections/ReferenceProjects.php | 70 +++- app/Models/CmsBrand.php | 12 + app/Models/CmsReference.php | 115 ++++++ app/Services/CmsPageTree.php | 187 +++++++++ config/cms_pages.php | 294 ++++++++++++++ database/factories/CmsReferenceFactory.php | 62 +++ ..._132909_add_origin_to_cms_brands_table.php | 37 ++ ..._15_150000_create_cms_references_table.php | 44 +++ database/seeders/BrandOriginSeeder.php | 87 +++++ database/seeders/CmsBrandSeeder.php | 27 +- database/seeders/OriginSectionCmsSeeder.php | 44 ++- dev/2026-08-28/entwicklungsplan.md | 189 ++++++++- resources/lang/de/b2in.php | 69 +--- resources/lang/en/b2in.php | 63 +-- .../components/layouts/app/sidebar.blade.php | 10 +- .../livewire/admin/cms/brands-index.blade.php | 37 +- .../admin/cms/content-index.blade.php | 202 ++++++++-- .../livewire/admin/cms/dashboard.blade.php | 18 +- .../admin/cms/references-index.blade.php | 367 ++++++++++++++++++ .../components/sections/about-hero.blade.php | 2 +- .../sections/c-t-a-section.blade.php | 2 +- .../sections/display-feed.blade.php | 2 +- .../web/components/sections/f-a-q.blade.php | 2 +- .../components/sections/founder-bar.blade.php | 2 +- .../web/components/sections/hero.blade.php | 2 +- .../components/sections/image-break.blade.php | 2 +- .../sections/immobilien-teaser.blade.php | 2 +- .../sections/magazin-list.blade.php | 2 +- .../components/sections/marken-cta.blade.php | 2 +- .../sections/marken-growth.blade.php | 2 +- .../sections/marken-network.blade.php | 2 +- .../sections/origin-section.blade.php | 31 +- .../components/sections/our-story.blade.php | 2 +- .../components/sections/our-values.blade.php | 2 +- .../components/sections/page-hero.blade.php | 2 +- .../sections/partner-c-t-a.blade.php | 2 +- .../sections/reference-projects.blade.php | 37 +- .../components/sections/sponsoring.blade.php | 2 +- .../sections/vision-section.blade.php | 2 +- .../web/components/ui/contact-form.blade.php | 2 +- .../web/components/ui/footer.blade.php | 2 +- routes/admin.php | 1 + .../Feature/Admin/Cms/CmsBrandsIndexTest.php | 66 ++++ .../Admin/Cms/CmsReferencesIndexTest.php | 144 +++++++ tests/Feature/Cms/CmsAdminTest.php | 130 ++++++- tests/Feature/Cms/CmsPageTreeTest.php | 154 ++++++++ tests/Feature/OriginSectionTest.php | 223 +++++++---- tests/Feature/ReferenzenPageTest.php | 62 +++ 50 files changed, 2580 insertions(+), 324 deletions(-) create mode 100644 app/Models/CmsReference.php create mode 100644 app/Services/CmsPageTree.php create mode 100644 config/cms_pages.php create mode 100644 database/factories/CmsReferenceFactory.php create mode 100644 database/migrations/2026_09_15_132909_add_origin_to_cms_brands_table.php create mode 100644 database/migrations/2026_09_15_150000_create_cms_references_table.php create mode 100644 database/seeders/BrandOriginSeeder.php create mode 100644 resources/views/livewire/admin/cms/references-index.blade.php create mode 100644 tests/Feature/Admin/Cms/CmsReferencesIndexTest.php create mode 100644 tests/Feature/Cms/CmsPageTreeTest.php diff --git a/.gitignore b/.gitignore index 427f4ebf..26072276 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/app/Livewire/Web/Components/Sections/OriginSection.php b/app/Livewire/Web/Components/Sections/OriginSection.php index 835e09ed..9705013a 100644 --- a/app/Livewire/Web/Components/Sections/OriginSection.php +++ b/app/Livewire/Web/Components/Sections/OriginSection.php @@ -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 + */ + 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 */ 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> + * @return list */ 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 $location - * @param array> $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'); diff --git a/app/Livewire/Web/Components/Sections/ReferenceProjects.php b/app/Livewire/Web/Components/Sections/ReferenceProjects.php index 1de9186d..7e91df8c 100644 --- a/app/Livewire/Web/Components/Sections/ReferenceProjects.php +++ b/app/Livewire/Web/Components/Sections/ReferenceProjects.php @@ -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> */ - 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> + */ + 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() diff --git a/app/Models/CmsBrand.php b/app/Models/CmsBrand.php index c027a41c..b06b312a 100644 --- a/app/Models/CmsBrand.php +++ b/app/Models/CmsBrand.php @@ -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, ]; } diff --git a/app/Models/CmsReference.php b/app/Models/CmsReference.php new file mode 100644 index 00000000..f5b7492d --- /dev/null +++ b/app/Models/CmsReference.php @@ -0,0 +1,115 @@ + */ + 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 + */ + 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> $brands Ergebnis von CmsBrand::forFrontend() + * @return list + */ + 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; + } +} diff --git a/app/Services/CmsPageTree.php b/app/Services/CmsPageTree.php new file mode 100644 index 00000000..92ffc905 --- /dev/null +++ b/app/Services/CmsPageTree.php @@ -0,0 +1,187 @@ +>}> + */ + 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|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 $ids + * @param \Illuminate\Support\Collection $rows + * @return list> + */ + 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; + } +} diff --git a/config/cms_pages.php b/config/cms_pages.php new file mode 100644 index 00000000..366ac0e5 --- /dev/null +++ b/config/cms_pages.php @@ -0,0 +1,294 @@ + [ + '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.', + ], +]; diff --git a/database/factories/CmsReferenceFactory.php b/database/factories/CmsReferenceFactory.php new file mode 100644 index 00000000..32697d74 --- /dev/null +++ b/database/factories/CmsReferenceFactory.php @@ -0,0 +1,62 @@ + + */ +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 $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]; + }); + } +} diff --git a/database/migrations/2026_09_15_132909_add_origin_to_cms_brands_table.php b/database/migrations/2026_09_15_132909_add_origin_to_cms_brands_table.php new file mode 100644 index 00000000..59bcb806 --- /dev/null +++ b/database/migrations/2026_09_15_132909_add_origin_to_cms_brands_table.php @@ -0,0 +1,37 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_09_15_150000_create_cms_references_table.php b/database/migrations/2026_09_15_150000_create_cms_references_table.php new file mode 100644 index 00000000..7f3f6806 --- /dev/null +++ b/database/migrations/2026_09_15_150000_create_cms_references_table.php @@ -0,0 +1,44 @@ +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'); + } +}; diff --git a/database/seeders/BrandOriginSeeder.php b/database/seeders/BrandOriginSeeder.php new file mode 100644 index 00000000..8e83fe4f --- /dev/null +++ b/database/seeders/BrandOriginSeeder.php @@ -0,0 +1,87 @@ +, origin_distance: array, origin_role: array}> */ + 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}."); + } +} diff --git a/database/seeders/CmsBrandSeeder.php b/database/seeders/CmsBrandSeeder.php index 7c7c6f57..9c2862c6 100644 --- a/database/seeders/CmsBrandSeeder.php +++ b/database/seeders/CmsBrandSeeder.php @@ -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, ], ]; diff --git a/database/seeders/OriginSectionCmsSeeder.php b/database/seeders/OriginSectionCmsSeeder.php index f3d7de44..3a9dabde 100644 --- a/database/seeders/OriginSectionCmsSeeder.php +++ b/database/seeders/OriginSectionCmsSeeder.php @@ -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.'); + } } diff --git a/dev/2026-08-28/entwicklungsplan.md b/dev/2026-08-28/entwicklungsplan.md index 60cb250e..d846c0bf 100644 --- a/dev/2026-08-28/entwicklungsplan.md +++ b/dev/2026-08-28/entwicklungsplan.md @@ -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=` 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. diff --git a/resources/lang/de/b2in.php b/resources/lang/de/b2in.php index cf65503f..896e2c6a 100644 --- a/resources/lang/de/b2in.php +++ b/resources/lang/de/b2in.php @@ -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 gefertigt', '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, geplant in Bielefeld', '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' => [ diff --git a/resources/lang/en/b2in.php b/resources/lang/en/b2in.php index 84f17d9d..4e829a6a 100644 --- a/resources/lang/en/b2in.php +++ b/resources/lang/en/b2in.php @@ -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, planned in Bielefeld', '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' => [ diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index e7c44b9d..ac1f3c3d 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -161,7 +161,7 @@ {{ __('Übersicht') }} @@ -169,8 +169,12 @@ {{ __('Inhalte') }} - {{ __('Projekte') }} + {{-- Immobilien-Projekte (cms.projects.index) sind nicht freigegeben und bewusst nicht verlinkt (Nachtrag 13). --}} + {{ __('Referenzen') }} + + {{ __('Marken') }} {{ __('Magazin') }} diff --git a/resources/views/livewire/admin/cms/brands-index.blade.php b/resources/views/livewire/admin/cms/brands-index.blade.php index 07d0cba5..a99a5fc4 100644 --- a/resources/views/livewire/admin/cms/brands-index.blade.php +++ b/resources/views/livewire/admin/cms/brands-index.blade.php @@ -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." /> + {{-- Fertigungsort für „Aus der Region gefertigt“ (Startseite und /marken) --}} +
+ Fertigungsort + + 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. + +
+ + +
+ +
+
+
+ {{-- Drei Merkmale für die Übersichtsseite --}}
diff --git a/resources/views/livewire/admin/cms/content-index.blade.php b/resources/views/livewire/admin/cms/content-index.blade.php index 3c549ca2..4d818745 100644 --- a/resources/views/livewire/admin/cms/content-index.blade.php +++ b/resources/views/livewire/admin/cms/content-index.blade.php @@ -1,6 +1,8 @@ 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
@@ -474,30 +528,85 @@ $cancelJsonModal = function () {
-
-
- - Seiten / Gruppen -
- @foreach ($this->groups as $group => $count) - - @endforeach -
+
+ {{-- Seitenbaum: Seiten in Menü-Reihenfolge, Sektionen von oben nach unten (config/cms_pages.php) --}} +
+ + @foreach ($this->tree as $pageKey => $page) +
+
+ {{ $page['title'] }} + @if ($page['path']) + {{ $page['path'] }} + @endif +
+ + @if ($pageKey === \App\Services\CmsPageTree::UNLISTED) +
+ {{ count($page['sections']) }} Sektionen stillgelegter Seiten +
+ @foreach ($page['sections'] as $item) + + @endforeach +
+
+ @else +
+ @foreach ($page['sections'] as $index => $item) + @php $isActive = $selectedGroup === $item['group'] && $selectedKey === $item['key']; @endphp + @if ($item['content_id']) + + @else +
+ {{ $index + 1 }} + {{ $item['title'] }} + +
+ @endif + @endforeach +
+ @endif +
+ @endforeach
-
+ {{-- Editor --}} +
@if ($selectedGroup) -
- {{ $cmsGroupLabels[$selectedGroup] ?? $selectedGroup }} +
+
+ {{ $section['title'] ?? ($selectedKey ?? $selectedGroup) }} + @if (! empty($section['description'])) + {{ $section['description'] }} + @endif +
+ @if ($pageTitle && $pageTitle !== ($section['title'] ?? null)) + {{ $pageTitle }} + + @endif + {{ $selectedGroup }}{{ $selectedKey ? '/' . $selectedKey : '' }} +
+
- + @if (! $selectedKey) + + @endif
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) @endforeach
+ @if ($this->previewUrl) + {{ $showPreview ? 'Vorschau aus' : 'Vorschau' }} + @endif
@php $lastSection = null; @endphp
@forelse ($this->flatContents as $row) - @if ($row->section_key !== $lastSection) + @if (! $selectedKey && $row->section_key !== $lastSection) @php $lastSection = $row->section_key; @endphp
{{ $row->section_key }} @@ -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
-
+
+ {{ $fieldLabel }} {{ $row->field_key ?? $row->section_key }} @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 - {{ $row->type }} + {{ $typeLabels[$row->type] ?? $row->type }}
- Seite auswählen - Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten. + Sektion auswählen + Links stehen die Seiten der Website mit ihren Abschnitten von oben nach unten. Wähle einen Abschnitt, um seine Texte und Bilder zu bearbeiten.
@endif
+ + {{-- Vorschau: die echte Website im Rahmen, springt zur gewählten Sektion und lädt nach dem Speichern neu --}} + @if ($selectedGroup && $showPreview && $this->previewUrl) +
+ +
+
+ Vorschau · {{ $section['path'] ?? '' }}{{ ! empty($section['anchor']) ? '#' . $section['anchor'] : '' }} +
+
+ + +
+
+ +
+
+ @endif
{{-- 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 >
- {{ $jsonEditingKey }} + {{ $section['title'] ?? $selectedGroup }}: {{ $editingField !== null ? _cmsFieldLabel($editingField, $selectedGroup, $selectedKey) : ($section['title'] ?? $jsonEditingKey) }} + {{ $jsonEditingKey }} · {{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }} ({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }} @@ -711,7 +849,7 @@ $cancelJsonModal = function () {
— Kein Icon — @foreach ($this->availableIcons as $iconName) @@ -728,7 +866,7 @@ $cancelJsonModal = function () {
@elseif ($isImageField)
- +
@if (is_string($fieldValue) && $fieldValue !== '')
@@ -751,18 +889,18 @@ $cancelJsonModal = function () { @elseif ($isRichText)
@elseif ($isNestedJson)
@else + label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}" /> @endif @endforeach
diff --git a/resources/views/livewire/admin/cms/dashboard.blade.php b/resources/views/livewire/admin/cms/dashboard.blade.php index 8a45d2a0..685a6d0d 100644 --- a/resources/views/livewire/admin/cms/dashboard.blade.php +++ b/resources/views/livewire/admin/cms/dashboard.blade.php @@ -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 () => [
CMS - Inhalte, Projekte und Medien verwalten. + Inhalte, Referenzen, Marken, Magazin und Medien verwalten.
@@ -43,13 +43,13 @@ $stats = computed(fn () => [ - +
- +
- {{ $this->stats['projects'] }} - Projekte ({{ $this->stats['projects_published'] }} veröffentlicht) + {{ $this->stats['references'] }} + Referenzen ({{ $this->stats['references_published'] }} veröffentlicht)
@@ -107,7 +107,7 @@ $stats = computed(fn () => [

  • Inhalte – Texte, Abschnitte und strukturierte Daten der Website (z. B. Startseite, Netzwerk, rechtliche Texte). Inhalte sind in Gruppen organisiert, sodass zusammengehörige Felder gebündelt bearbeitet werden können.
  • -
  • Projekte – Immobilien-Referenzobjekte mit Bildern, Preisen und Beschreibungen. Jedes Projekt kann als Entwurf angelegt und später veröffentlicht werden.
  • +
  • Referenzen – 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.
  • Magazin – Redaktionelle Artikel (z. B. über Dubai-Immobilien, Supply-Chain oder Einrichtung). Artikel unterstützen Kategorien, Autoren und Lesezeiten.
  • Medien – Zentrale Bild- und Dateiverwaltung. Alle Uploads landen hier und können in jedem Modul wiederverwendet werden.
diff --git a/resources/views/livewire/admin/cms/references-index.blade.php b/resources/views/livewire/admin/cms/references-index.blade.php new file mode 100644 index 00000000..bf7c6de3 --- /dev/null +++ b/resources/views/livewire/admin/cms/references-index.blade.php @@ -0,0 +1,367 @@ + '', + '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; +}; + +?> + +
+
+
+ Referenzen + Realisierte Projekte für die Seite /referenzen. Nur ausgeführte Aufträge – Entwürfe und Visualisierungen gehören in die Galerie der jeweiligen Marke. +
+
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach + Neue Referenz +
+
+ +
+ +
+ + @if ($showForm) + +
+ {{ $editingId ? 'Referenz bearbeiten' : 'Neue Referenz' }} +
+ @foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label) + {{ strtoupper($code) }} + @endforeach +
+
+ +
+
+ + @error('referenceTitle') {{ $message }} @enderror +
+ + +
+ +
+ +
+ + {{-- Beteiligte Marken --}} +
+ Beteiligte Marken + Jede gewählte Marke wird unter der Referenz genannt und auf ihre Markenseite verlinkt. +
+ @foreach ($this->brands as $brand) + + @endforeach +
+ @error('brandSlugs.*') {{ $message }} @enderror +
+ + {{-- Titelbild --}} +
+ +
+ @if ($image) +
+ +
+ @endif +
+ + +
+
+
+ + {{-- Weitere Bilder --}} +
+
+ Weitere Bilder + Bild hinzufügen +
+ + @if (empty($gallery)) +

Noch keine weiteren Bilder.

+ @else +
+ @foreach ($gallery as $i => $item) +
+ @if (!empty($item['image'])) +
+ +
+ @endif + + + +
+ +
+ + + +
+
+ + +
+ @endforeach +
+ @endif +
+ +
+ +
+ +
+ Speichern + Abbrechen +
+
+ @endif + + +
+ @forelse ($this->references as $reference) +
+
+ @if ($reference->image) +
+ +
+ @else +
+ +
+ @endif +
+
+ + {{ $reference->getTranslation('title', $editLocale) ?: $reference->getTranslation('title', 'de') }} + + @foreach ($reference->brand_slugs ?? [] as $slug) + {{ $slug }} + @endforeach + @unless ($reference->is_published) + Entwurf + @endunless +
+

+ {{ $reference->location ?: '–' }} · {{ $reference->year ?: '–' }} · {{ count(CmsReference::normalizeGallery($reference->gallery)) + ($reference->image ? 1 : 0) }} Bilder +

+
+
+
+ + + +
+
+ @empty +
+ + Keine Referenzen + Solange hier nichts steht, zeigt /referenzen die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien. +
+ @endforelse +
+
+
diff --git a/resources/views/livewire/web/components/sections/about-hero.blade.php b/resources/views/livewire/web/components/sections/about-hero.blade.php index 05cecd8f..896130cf 100644 --- a/resources/views/livewire/web/components/sections/about-hero.blade.php +++ b/resources/views/livewire/web/components/sections/about-hero.blade.php @@ -1,4 +1,4 @@ -
+
diff --git a/resources/views/livewire/web/components/sections/c-t-a-section.blade.php b/resources/views/livewire/web/components/sections/c-t-a-section.blade.php index 1c6f13b0..c1ad03c0 100644 --- a/resources/views/livewire/web/components/sections/c-t-a-section.blade.php +++ b/resources/views/livewire/web/components/sections/c-t-a-section.blade.php @@ -1,4 +1,4 @@ -
+

diff --git a/resources/views/livewire/web/components/sections/display-feed.blade.php b/resources/views/livewire/web/components/sections/display-feed.blade.php index 8aa98b68..332506d5 100644 --- a/resources/views/livewire/web/components/sections/display-feed.blade.php +++ b/resources/views/livewire/web/components/sections/display-feed.blade.php @@ -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. --}} -
+
@if ($displayId !== null)
diff --git a/resources/views/livewire/web/components/sections/f-a-q.blade.php b/resources/views/livewire/web/components/sections/f-a-q.blade.php index 98909124..8d9e84d4 100644 --- a/resources/views/livewire/web/components/sections/f-a-q.blade.php +++ b/resources/views/livewire/web/components/sections/f-a-q.blade.php @@ -1,6 +1,6 @@ @php($faqCounter = 0)
-
+
{{-- Section Title --}}
diff --git a/resources/views/livewire/web/components/sections/founder-bar.blade.php b/resources/views/livewire/web/components/sections/founder-bar.blade.php index 123ff86a..91c7e677 100644 --- a/resources/views/livewire/web/components/sections/founder-bar.blade.php +++ b/resources/views/livewire/web/components/sections/founder-bar.blade.php @@ -1,4 +1,4 @@ -
+
@if(isset($content['image'])) diff --git a/resources/views/livewire/web/components/sections/hero.blade.php b/resources/views/livewire/web/components/sections/hero.blade.php index 0cdd7703..ff45714b 100644 --- a/resources/views/livewire/web/components/sections/hero.blade.php +++ b/resources/views/livewire/web/components/sections/hero.blade.php @@ -1,4 +1,4 @@ -
+
{{-- Left Content --}} diff --git a/resources/views/livewire/web/components/sections/image-break.blade.php b/resources/views/livewire/web/components/sections/image-break.blade.php index 93ff62fa..2885e62e 100644 --- a/resources/views/livewire/web/components/sections/image-break.blade.php +++ b/resources/views/livewire/web/components/sections/image-break.blade.php @@ -1,5 +1,5 @@ @if (!empty($content)) -
+
+

{!! $content['title'] ?? '' !!}

diff --git a/resources/views/livewire/web/components/sections/magazin-list.blade.php b/resources/views/livewire/web/components/sections/magazin-list.blade.php index a6c320aa..f0851100 100644 --- a/resources/views/livewire/web/components/sections/magazin-list.blade.php +++ b/resources/views/livewire/web/components/sections/magazin-list.blade.php @@ -1,4 +1,4 @@ -
+

diff --git a/resources/views/livewire/web/components/sections/marken-cta.blade.php b/resources/views/livewire/web/components/sections/marken-cta.blade.php index 4626b4ba..55732b6e 100644 --- a/resources/views/livewire/web/components/sections/marken-cta.blade.php +++ b/resources/views/livewire/web/components/sections/marken-cta.blade.php @@ -1,4 +1,4 @@ -
+

{!! $content['title'] ?? '' !!}

{{ $content['text'] ?? '' }}

diff --git a/resources/views/livewire/web/components/sections/marken-growth.blade.php b/resources/views/livewire/web/components/sections/marken-growth.blade.php index 0fd93c4f..617c654f 100644 --- a/resources/views/livewire/web/components/sections/marken-growth.blade.php +++ b/resources/views/livewire/web/components/sections/marken-growth.blade.php @@ -1,6 +1,6 @@ {{-- Optionaler Block laut Spec – über das CMS-Feld `enabled` abschaltbar. --}} @if ($content['enabled'] ?? true) -
+

{!! $content['title'] ?? '' !!}

{{ $content['text'] ?? '' }}

diff --git a/resources/views/livewire/web/components/sections/marken-network.blade.php b/resources/views/livewire/web/components/sections/marken-network.blade.php index febf31ba..0fd737b3 100644 --- a/resources/views/livewire/web/components/sections/marken-network.blade.php +++ b/resources/views/livewire/web/components/sections/marken-network.blade.php @@ -18,7 +18,7 @@ } @endphp -
+

{!! $content['title'] ?? '' !!}

diff --git a/resources/views/livewire/web/components/sections/origin-section.blade.php b/resources/views/livewire/web/components/sections/origin-section.blade.php index a0598d4c..dcaf0d18 100644 --- a/resources/views/livewire/web/components/sections/origin-section.blade.php +++ b/resources/views/livewire/web/components/sections/origin-section.blade.php @@ -14,9 +14,12 @@ @endif
+ {{-- Ohne gepflegten Fertigungsort bleibt nur die Überschrift und der Ablauf-Block. --}} + @if ($this->locations !== [])
- {{-- 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. --}} - {{-- Die vier Standorte --}} + {{-- Eine Kachel je Marke mit Fertigungsort --}}
- @foreach ($this->locations as $index => $location) -
+ @foreach ($this->locations as $location) +
{{ $location['distance'] }} {{ $content['distance_suffix'] ?? '' }} @endif - @if (!empty($location['link']) && !empty($location['link_text'])) + @if (!empty($location['link']) && !empty($content['link_text'])) - {{ $location['link_text'] }} + {{ $content['link_text'] }} @endif
@@ -94,6 +98,7 @@ @endforeach
+ @endif {{-- Gefertigt oder vertrieben? Der Weg eines Maßmöbels am Beispiel CABINET. --}} @if ($steps !== []) diff --git a/resources/views/livewire/web/components/sections/our-story.blade.php b/resources/views/livewire/web/components/sections/our-story.blade.php index d5793a94..633caacc 100644 --- a/resources/views/livewire/web/components/sections/our-story.blade.php +++ b/resources/views/livewire/web/components/sections/our-story.blade.php @@ -1,4 +1,4 @@ -
+

diff --git a/resources/views/livewire/web/components/sections/our-values.blade.php b/resources/views/livewire/web/components/sections/our-values.blade.php index d0d059dc..fb058f8b 100644 --- a/resources/views/livewire/web/components/sections/our-values.blade.php +++ b/resources/views/livewire/web/components/sections/our-values.blade.php @@ -21,7 +21,7 @@ function renderHeroIcon($iconName, $style = 'outline') { } } @endphp -
+

{!! $content['title'] !!}

diff --git a/resources/views/livewire/web/components/sections/page-hero.blade.php b/resources/views/livewire/web/components/sections/page-hero.blade.php index 712068d7..6d200f9f 100644 --- a/resources/views/livewire/web/components/sections/page-hero.blade.php +++ b/resources/views/livewire/web/components/sections/page-hero.blade.php @@ -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. --}} -
+
{{-- Breadcrumb: Home → Marken --}}