CMS: Standorte an der Marke, Seitenbaum mit Vorschau, Referenzen-Modul
Nachtrag 11: Herkunfts-Sektion liest Ort, Entfernung und Rolle aus der Markenverwaltung (drei neue Felder an cms_brands, BrandOriginSeeder), Kartenpositionen im Code, „Marken“ in der Sidebar. Nachtrag 12: Inhalte-Editor zeigt die Seiten der Website mit ihren Sektionen in Reihenfolge (config/cms_pages.php, CmsPageTree), Felder in Klartext, fehlende Sektionen aus der Sprachdatei anlegbar, Vorschau der echten Seite im iframe mit Sprung zur Sektion; Anker an allen lebenden Sektionen nachgezogen. Nachtrag 13: Referenzen als eigenes Modul (CmsReference, Portal-Seite, /referenzen mit Fallback auf die Markengalerien); Immobilien-Projekte aus Sidebar und CMS-Übersicht genommen, Route bleibt. Dazu .gitignore für _b2in_interior_shop/ und docs/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7953a8c9a1
commit
90aa079cf7
50 changed files with 2580 additions and 324 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -60,3 +60,7 @@ _storage/
|
||||||
dev/immobilien 07-05-2026/AZIZI SharePoint
|
dev/immobilien 07-05-2026/AZIZI SharePoint
|
||||||
# Deployment-Archive werden bei Bedarf neu erzeugt
|
# Deployment-Archive werden bei Bedarf neu erzeugt
|
||||||
dev/**/deploy/*.tar.gz
|
dev/**/deploy/*.tar.gz
|
||||||
|
|
||||||
|
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
|
||||||
|
/_b2in_interior_shop/
|
||||||
|
/docs/
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,30 @@ use App\Models\CmsBrand;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* „Aus der Region gefertigt“: die vier Standorte der Kernmarken rund um Bielefeld
|
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
|
||||||
* (Arbeitsplan 14.09.2026, Punkt 1.3). Inhalte aus dem CMS, Fallback Lang-Datei.
|
* (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
|
class OriginSection extends Component
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
|
||||||
|
*
|
||||||
|
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
|
||||||
|
*
|
||||||
|
* @var array<string, array{x: int, y: int, label: string}>
|
||||||
|
*/
|
||||||
|
public const MAP_POSITIONS = [
|
||||||
|
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
|
||||||
|
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
|
||||||
|
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
|
||||||
|
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
|
||||||
|
];
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
/** @var array<string, mixed> */
|
||||||
public array $content = [];
|
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 fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
|
||||||
* eine unveröffentlichte Marke bekommt keinen Link, ihr Standort bleibt aber sichtbar.
|
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>>
|
* @return list<array{slug: string, city: string, brand: string, role: string, distance: string, link: ?string, map: ?array{x: int, y: int, label: string}}>
|
||||||
*/
|
*/
|
||||||
public function getLocationsProperty(): array
|
public function getLocationsProperty(): array
|
||||||
{
|
{
|
||||||
$locations = $this->content['locations'] ?? [];
|
|
||||||
|
|
||||||
if (! is_array($locations)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$brands = CmsBrand::forFrontend();
|
|
||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
|
|
||||||
foreach ($locations as $location) {
|
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||||
if (! is_array($location) || empty($location['brand']) || empty($location['city'])) {
|
$city = trim((string) ($brand['origin_city'] ?? ''));
|
||||||
|
$name = trim((string) ($brand['name'] ?? ''));
|
||||||
|
|
||||||
|
if ($city === '' || $name === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$location['link'] = $this->brandLink($location, $brands);
|
$out[] = [
|
||||||
$out[] = $location;
|
'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;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $location
|
|
||||||
* @param array<string, array<string, mixed>> $brands
|
|
||||||
*/
|
|
||||||
private function brandLink(array $location, array $brands): ?string
|
|
||||||
{
|
|
||||||
$slug = $location['brand_slug'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($slug) && isset($brands[$slug])) {
|
|
||||||
return '/marken/'.$slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($brands as $brandSlug => $brand) {
|
|
||||||
if (($brand['name'] ?? null) === $location['brand']) {
|
|
||||||
return '/marken/'.$brandSlug;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.web.components.sections.origin-section');
|
return view('livewire.web.components.sections.origin-section');
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
namespace App\Livewire\Web\Components\Sections;
|
||||||
|
|
||||||
use App\Models\CmsBrand;
|
use App\Models\CmsBrand;
|
||||||
|
use App\Models\CmsReference;
|
||||||
use Livewire\Component;
|
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.
|
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
|
||||||
* Hier erscheinen ausschließlich Einträge der Rubrik „Realisiertes Projekt" – Visualisierungen
|
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
|
||||||
* und Entwürfe bleiben bewusst draußen, damit „Referenzen" hält, was das Wort verspricht.
|
* 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
|
class ReferenceProjects extends Component
|
||||||
{
|
{
|
||||||
|
|
@ -30,13 +32,56 @@ class ReferenceProjects extends Component
|
||||||
$this->content = is_array($section) ? $section : [];
|
$this->content = is_array($section) ? $section : [];
|
||||||
$this->bg = $bg;
|
$this->bg = $bg;
|
||||||
$this->limit = $limit;
|
$this->limit = $limit;
|
||||||
$this->projects = $this->collectProjects();
|
|
||||||
|
$projects = $this->fromReferences();
|
||||||
|
|
||||||
|
if ($projects === []) {
|
||||||
|
$projects = $this->fromBrandGalleries();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function collectProjects(): array
|
private function fromReferences(): array
|
||||||
|
{
|
||||||
|
$references = CmsReference::query()->published()->ordered()->get();
|
||||||
|
|
||||||
|
if ($references->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$brands = CmsBrand::forFrontend();
|
||||||
|
$projects = [];
|
||||||
|
|
||||||
|
foreach ($references as $reference) {
|
||||||
|
$gallery = CmsReference::normalizeGallery($reference->gallery);
|
||||||
|
$image = (string) ($reference->image ?: ($gallery[0]['image'] ?? ''));
|
||||||
|
|
||||||
|
if ($image === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projects[] = [
|
||||||
|
'key' => 'ref-'.$reference->id,
|
||||||
|
'image' => $image,
|
||||||
|
'caption' => (string) $reference->title,
|
||||||
|
'meta' => implode(' · ', array_filter([$reference->location, $reference->year])),
|
||||||
|
'description' => (string) $reference->description,
|
||||||
|
'gallery' => array_values(array_filter($gallery, fn (array $item) => $item['image'] !== $image)),
|
||||||
|
'brands' => $reference->resolveBrands($brands),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function fromBrandGalleries(): array
|
||||||
{
|
{
|
||||||
$projects = [];
|
$projects = [];
|
||||||
|
|
||||||
|
|
@ -46,14 +91,19 @@ class ReferenceProjects extends Component
|
||||||
'key' => $slug.'-'.$index,
|
'key' => $slug.'-'.$index,
|
||||||
'image' => $item['image'],
|
'image' => $item['image'],
|
||||||
'caption' => $item['caption'],
|
'caption' => $item['caption'],
|
||||||
'brand' => (string) ($brand['name'] ?? ''),
|
'meta' => '',
|
||||||
'brand_slug' => $slug,
|
'description' => '',
|
||||||
'has_detail_page' => (bool) ($brand['has_detail_page'] ?? false),
|
'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()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ class CmsBrand extends Model
|
||||||
'gallery',
|
'gallery',
|
||||||
'website_url',
|
'website_url',
|
||||||
'showroom',
|
'showroom',
|
||||||
|
'origin_city',
|
||||||
|
'origin_distance',
|
||||||
|
'origin_role',
|
||||||
'is_published',
|
'is_published',
|
||||||
'order',
|
'order',
|
||||||
];
|
];
|
||||||
|
|
@ -59,6 +62,9 @@ class CmsBrand extends Model
|
||||||
'widget_label',
|
'widget_label',
|
||||||
'cta_title',
|
'cta_title',
|
||||||
'cta_text',
|
'cta_text',
|
||||||
|
'origin_city',
|
||||||
|
'origin_distance',
|
||||||
|
'origin_role',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|
@ -139,6 +145,9 @@ class CmsBrand extends Model
|
||||||
'gallery' => [],
|
'gallery' => [],
|
||||||
'website_url' => '',
|
'website_url' => '',
|
||||||
'showroom' => '',
|
'showroom' => '',
|
||||||
|
'origin_city' => '',
|
||||||
|
'origin_distance' => '',
|
||||||
|
'origin_role' => '',
|
||||||
'has_detail_page' => false,
|
'has_detail_page' => false,
|
||||||
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
|
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
|
||||||
|
|
||||||
|
|
@ -311,6 +320,9 @@ class CmsBrand extends Model
|
||||||
'gallery' => self::normalizeGallery($this->gallery),
|
'gallery' => self::normalizeGallery($this->gallery),
|
||||||
'website_url' => $this->website_url,
|
'website_url' => $this->website_url,
|
||||||
'showroom' => $this->showroom,
|
'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,
|
'has_detail_page' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
115
app/Models/CmsReference.php
Normal file
115
app/Models/CmsReference.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Spatie\Translatable\HasTranslations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenzprojekt der Interior-Welt: ein realisierter Auftrag mit Titelbild, weiteren Bildern
|
||||||
|
* und den beteiligten Kernmarken (Nachtrag 13). Erscheint auf /referenzen.
|
||||||
|
*
|
||||||
|
* Nicht zu verwechseln mit {@see CmsProject} – das sind die Immobilienprojekte.
|
||||||
|
*/
|
||||||
|
class CmsReference extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasTranslations;
|
||||||
|
|
||||||
|
protected $table = 'cms_references';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'location',
|
||||||
|
'year',
|
||||||
|
'description',
|
||||||
|
'image',
|
||||||
|
'gallery',
|
||||||
|
'brand_slugs',
|
||||||
|
'is_published',
|
||||||
|
'order',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
public array $translatable = ['title', 'description'];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'gallery' => 'array',
|
||||||
|
'brand_slugs' => 'array',
|
||||||
|
'is_published' => 'boolean',
|
||||||
|
'order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_published', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->orderBy('order')->orderBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Galerie in eine feste Form bringen: nur Einträge mit Bild, Bildunterschrift getrimmt.
|
||||||
|
*
|
||||||
|
* @param mixed $gallery
|
||||||
|
* @return list<array{image: string, caption: string}>
|
||||||
|
*/
|
||||||
|
public static function normalizeGallery($gallery): array
|
||||||
|
{
|
||||||
|
if (! is_array($gallery)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($gallery as $item) {
|
||||||
|
$image = trim((string) (is_array($item) ? ($item['image'] ?? '') : $item));
|
||||||
|
|
||||||
|
if ($image === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = [
|
||||||
|
'image' => $image,
|
||||||
|
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beteiligte Marken als Liste mit Name und Link, aufgelöst gegen die veröffentlichten Marken.
|
||||||
|
*
|
||||||
|
* Eine unveröffentlichte oder gelöschte Marke fällt heraus.
|
||||||
|
*
|
||||||
|
* @param array<string, array<string, mixed>> $brands Ergebnis von CmsBrand::forFrontend()
|
||||||
|
* @return list<array{slug: string, name: string, link: ?string}>
|
||||||
|
*/
|
||||||
|
public function resolveBrands(array $brands): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($this->brand_slugs ?? [] as $slug) {
|
||||||
|
$slug = (string) $slug;
|
||||||
|
|
||||||
|
if (! isset($brands[$slug])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = [
|
||||||
|
'slug' => $slug,
|
||||||
|
'name' => (string) ($brands[$slug]['name'] ?? $slug),
|
||||||
|
'link' => ! empty($brands[$slug]['has_detail_page']) ? '/marken/'.$slug : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Services/CmsPageTree.php
Normal file
187
app/Services/CmsPageTree.php
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use FluxCms\Core\Models\CmsContent;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seitenbaum für den Inhalte-Editor (config/cms_pages.php, Nachtrag 12).
|
||||||
|
*
|
||||||
|
* Verbindet die Registry mit dem Datenbestand: je Sektion, ob sie in flux_cms_contents liegt
|
||||||
|
* (dann mit `content_id`), und was sonst noch in der Datenbank steht, aber in keiner Seite
|
||||||
|
* eingetragen ist („Nicht mehr verwendet“).
|
||||||
|
*/
|
||||||
|
class CmsPageTree
|
||||||
|
{
|
||||||
|
/** Schlüssel des Sammelpunkts für nicht eingetragene Sektionen. */
|
||||||
|
public const UNLISTED = 'unlisted';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Seiten mit ihren Sektionen in Website-Reihenfolge.
|
||||||
|
*
|
||||||
|
* @return array<string, array{title: string, path: ?string, sections: list<array<string, mixed>>}>
|
||||||
|
*/
|
||||||
|
public function pages(): array
|
||||||
|
{
|
||||||
|
$rows = CmsContent::query()
|
||||||
|
->get(['id', 'group', 'key'])
|
||||||
|
->keyBy(fn (CmsContent $row) => $row->group.'/'.$row->key);
|
||||||
|
|
||||||
|
$pages = [];
|
||||||
|
|
||||||
|
foreach (config('cms_pages.pages', []) as $pageKey => $page) {
|
||||||
|
$sections = [];
|
||||||
|
|
||||||
|
foreach ($page['sections'] ?? [] as $section) {
|
||||||
|
$id = $section['group'].'/'.$section['key'];
|
||||||
|
|
||||||
|
$sections[] = array_merge($section, [
|
||||||
|
'page' => $pageKey,
|
||||||
|
'path' => $section['path'] ?? $page['path'] ?? null,
|
||||||
|
'anchor' => $section['anchor'] ?? null,
|
||||||
|
'content_id' => $rows[$id]->id ?? null,
|
||||||
|
'theme_key' => $this->themeKey($section['group'], $section['key']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages[$pageKey] = [
|
||||||
|
'title' => $page['title'],
|
||||||
|
'path' => $page['path'] ?? null,
|
||||||
|
'sections' => $sections,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unlisted = $this->unlistedSections($rows->keys()->all(), $rows);
|
||||||
|
|
||||||
|
if ($unlisted !== []) {
|
||||||
|
$pages[self::UNLISTED] = [
|
||||||
|
'title' => config('cms_pages.unlisted.title', 'Nicht mehr verwendet'),
|
||||||
|
'path' => null,
|
||||||
|
'sections' => $unlisted,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Sektion aus der Registry samt Datenbankbezug, oder null.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function section(string $group, string $key): ?array
|
||||||
|
{
|
||||||
|
foreach ($this->pages() as $page) {
|
||||||
|
foreach ($page['sections'] as $section) {
|
||||||
|
if ($section['group'] === $group && $section['key'] === $key) {
|
||||||
|
return $section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klartext-Beschriftung eines Feldes: Sektions-Override, dann Wörterbuch, sonst lesbar gemacht.
|
||||||
|
*/
|
||||||
|
public function fieldLabel(string $field, ?string $group = null, ?string $key = null): string
|
||||||
|
{
|
||||||
|
if ($group !== null && $key !== null) {
|
||||||
|
foreach (config('cms_pages.pages', []) as $page) {
|
||||||
|
foreach ($page['sections'] ?? [] as $section) {
|
||||||
|
if ($section['group'] === $group && $section['key'] === $key && isset($section['fields'][$field])) {
|
||||||
|
return $section['fields'][$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = config('cms_pages.field_labels', []);
|
||||||
|
|
||||||
|
if (isset($labels[$field])) {
|
||||||
|
return $labels[$field];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::ucfirst(str_replace('_', ' ', $field));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adresse der Vorschau für eine Sektion: Frontend-Domain, Pfad, Cache-Brecher und Sprungmarke.
|
||||||
|
*/
|
||||||
|
public function previewUrl(?array $section, int $version = 0): ?string
|
||||||
|
{
|
||||||
|
$path = $section['path'] ?? null;
|
||||||
|
|
||||||
|
if ($path === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = rtrim(config('domains.protocol', 'https://').config('domains.domain_b2in'), '/')
|
||||||
|
.'/'.ltrim($path, '/')
|
||||||
|
.'?cms-preview='.$version;
|
||||||
|
|
||||||
|
if (! empty($section['anchor'])) {
|
||||||
|
$url .= '#'.$section['anchor'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme-Section-Schlüssel (Lang-Datei) zu Gruppe/Key, für „Aus Vorlage anlegen“.
|
||||||
|
*/
|
||||||
|
public function themeKey(string $group, string $key): ?string
|
||||||
|
{
|
||||||
|
foreach (config('cms_section_map.sections', []) as $themeKey => $target) {
|
||||||
|
if ($target === [$group, $key]) {
|
||||||
|
return $themeKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $ids
|
||||||
|
* @param \Illuminate\Support\Collection<string, CmsContent> $rows
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function unlistedSections(array $ids, $rows): array
|
||||||
|
{
|
||||||
|
$listed = [];
|
||||||
|
|
||||||
|
foreach (config('cms_pages.pages', []) as $page) {
|
||||||
|
foreach ($page['sections'] ?? [] as $section) {
|
||||||
|
$listed[$section['group'].'/'.$section['key']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (isset($listed[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $rows[$id];
|
||||||
|
|
||||||
|
$out[] = [
|
||||||
|
'group' => $row->group,
|
||||||
|
'key' => $row->key,
|
||||||
|
'page' => self::UNLISTED,
|
||||||
|
'path' => null,
|
||||||
|
'anchor' => null,
|
||||||
|
'title' => $row->group.' / '.$row->key,
|
||||||
|
'description' => null,
|
||||||
|
'content_id' => $row->id,
|
||||||
|
'theme_key' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($out, fn (array $a, array $b) => [$a['group'], $a['key']] <=> [$b['group'], $b['key']]);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
config/cms_pages.php
Normal file
294
config/cms_pages.php
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seitenbaum für den Inhalte-Editor im Portal (Nachtrag 12).
|
||||||
|
*
|
||||||
|
* Der Editor zeigt die CMS-Sektionen nicht mehr nach Datenbank-Gruppe, sondern so, wie sie auf
|
||||||
|
* der Website stehen: je Seite in der Reihenfolge von oben nach unten, mit Klartext-Titel und
|
||||||
|
* einem Satz, der sagt, was die Sektion ist. `anchor` ist die Sprungmarke in der Vorschau
|
||||||
|
* (das `id` des Abschnitts im Frontend).
|
||||||
|
*
|
||||||
|
* Eine Sektion, die auf mehreren Seiten steht, ist einmal eingetragen – dort, wo sie zuerst
|
||||||
|
* auftaucht – und nennt die anderen Seiten in der Beschreibung.
|
||||||
|
*
|
||||||
|
* `group`/`key` sind Gruppe und Schlüssel in flux_cms_contents (siehe cms_section_map.php).
|
||||||
|
* Sektionen, die in der Datenbank liegen, aber hier nicht eingetragen sind, sammelt der Editor
|
||||||
|
* unter „Nicht mehr verwendet“ – sie gehen also nicht verloren, stehen aber auch nicht im Weg.
|
||||||
|
*
|
||||||
|
* `fields` überschreibt Feldbeschriftungen je Sektion; alles andere kommt aus `field_labels`.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Feldbeschriftungen
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Gilt für Felder auf Sektionsebene und für Felder in Listeneinträgen.
|
||||||
|
| Unbekannte Schlüssel werden lesbar gemacht (button_text → „Button text“).
|
||||||
|
*/
|
||||||
|
'field_labels' => [
|
||||||
|
'title' => 'Überschrift',
|
||||||
|
'subtitle' => 'Untertitel',
|
||||||
|
'headline' => 'Überschrift',
|
||||||
|
'tagline' => 'Claim',
|
||||||
|
'text' => 'Text',
|
||||||
|
'intro' => 'Einleitung',
|
||||||
|
'description' => 'Beschreibung',
|
||||||
|
'content' => 'Inhalt',
|
||||||
|
'lead' => 'Vorspann',
|
||||||
|
'note' => 'Hinweis',
|
||||||
|
'label' => 'Beschriftung',
|
||||||
|
'badge' => 'Kennzeichen',
|
||||||
|
'eyebrow' => 'Überzeile',
|
||||||
|
'quote' => 'Zitat',
|
||||||
|
'author' => 'Zitatgeber',
|
||||||
|
'name' => 'Name',
|
||||||
|
'role' => 'Rolle / Funktion',
|
||||||
|
'city' => 'Ort',
|
||||||
|
'value' => 'Wert',
|
||||||
|
'number' => 'Zahl',
|
||||||
|
'suffix' => 'Zusatz',
|
||||||
|
'icon' => 'Icon',
|
||||||
|
'image' => 'Bild',
|
||||||
|
'image_alt' => 'Bildbeschreibung',
|
||||||
|
'alt' => 'Bildbeschreibung',
|
||||||
|
'logo' => 'Logo',
|
||||||
|
'link' => 'Link',
|
||||||
|
'url' => 'Adresse (URL)',
|
||||||
|
'href' => 'Link',
|
||||||
|
'link_text' => 'Linktext',
|
||||||
|
'button_text' => 'Button-Beschriftung',
|
||||||
|
'button_link' => 'Button-Link',
|
||||||
|
'primary_button' => 'Erster Button',
|
||||||
|
'secondary_button' => 'Zweiter Button',
|
||||||
|
'cta_title' => 'Überschrift Abschluss',
|
||||||
|
'cta_text' => 'Text Abschluss',
|
||||||
|
'cta_link' => 'Link Abschluss',
|
||||||
|
'items' => 'Einträge',
|
||||||
|
'steps' => 'Schritte',
|
||||||
|
'cards' => 'Karten',
|
||||||
|
'stats' => 'Kennzahlen',
|
||||||
|
'features' => 'Merkmale',
|
||||||
|
'benefits' => 'Vorteile',
|
||||||
|
'values' => 'Werte',
|
||||||
|
'links' => 'Links',
|
||||||
|
'columns' => 'Spalten',
|
||||||
|
'menu' => 'Menüpunkte',
|
||||||
|
'navigation' => 'Navigation',
|
||||||
|
'topics' => 'Themen',
|
||||||
|
'options' => 'Auswahlmöglichkeiten',
|
||||||
|
'placeholder' => 'Platzhaltertext',
|
||||||
|
'meta_title' => 'Seitentitel (Browser, Google)',
|
||||||
|
'meta_description' => 'Beschreibung für Google',
|
||||||
|
'email' => 'E-Mail',
|
||||||
|
'phone' => 'Telefon',
|
||||||
|
'whatsapp' => 'WhatsApp-Nummer',
|
||||||
|
'address' => 'Adresse',
|
||||||
|
'opening_hours' => 'Öffnungszeiten',
|
||||||
|
'copyright' => 'Copyright-Zeile',
|
||||||
|
'map_alt' => 'Beschreibung der Karte',
|
||||||
|
'region_label' => 'Beschriftung der Region',
|
||||||
|
'distance_suffix' => 'Zusatz hinter der Entfernung',
|
||||||
|
'hub' => 'Drehscheibe (Bielefeld)',
|
||||||
|
'steps_title' => 'Überschrift Ablauf-Block',
|
||||||
|
'steps_text' => 'Einleitung Ablauf-Block',
|
||||||
|
'x' => 'Position waagerecht (Entwickler)',
|
||||||
|
'y' => 'Position senkrecht (Entwickler)',
|
||||||
|
'id' => 'Sprungmarke (Entwickler)',
|
||||||
|
'slug' => 'Adresskürzel (Entwickler)',
|
||||||
|
'teaser' => 'Kurztext',
|
||||||
|
'summary' => 'Zusammenfassung',
|
||||||
|
'brands' => 'Marken',
|
||||||
|
'projects' => 'Projekte',
|
||||||
|
'partners' => 'Partner',
|
||||||
|
'questions' => 'Fragen',
|
||||||
|
'question' => 'Frage',
|
||||||
|
'answer' => 'Antwort',
|
||||||
|
'read_more' => 'Weiterlesen-Text',
|
||||||
|
'empty_text' => 'Text, wenn nichts vorhanden ist',
|
||||||
|
'success_text' => 'Text nach dem Absenden',
|
||||||
|
'submit_text' => 'Beschriftung Absenden-Button',
|
||||||
|
'privacy_text' => 'Datenschutz-Hinweis',
|
||||||
|
'subjects' => 'Betreffe',
|
||||||
|
'subject' => 'Betreff',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Seiten in der Reihenfolge des Menüs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'pages' => [
|
||||||
|
|
||||||
|
'home' => [
|
||||||
|
'title' => 'Startseite',
|
||||||
|
'path' => '/',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'home', 'key' => 'hero', 'anchor' => 'hero', 'title' => 'Hero',
|
||||||
|
'description' => 'Der große Einstieg ganz oben: Überschrift, Text, Buttons und Bild.'],
|
||||||
|
['group' => 'home', 'key' => 'founder_bar', 'anchor' => 'gruender', 'title' => 'Gründer-Leiste',
|
||||||
|
'description' => 'Schmale Zeile mit Marcel Scheibe und Zitat. Steht auch auf „Über uns“ und in der Immobilien-Welt.'],
|
||||||
|
['group' => 'home', 'key' => 'audience_switch', 'anchor' => 'wege', 'title' => 'Zielgruppen-Wahl',
|
||||||
|
'description' => 'Zwei Wege: Privat einrichten oder Projekt ausstatten.'],
|
||||||
|
['group' => 'home', 'key' => 'core_brands', 'anchor' => 'marken', 'title' => 'Kernmarken',
|
||||||
|
'description' => 'Überschrift und Untertitel des Marken-Rasters. Die Kacheln selbst kommen aus der Markenverwaltung.'],
|
||||||
|
['group' => 'home', 'key' => 'origin_section', 'anchor' => 'herkunft', 'title' => 'Aus der Region gefertigt',
|
||||||
|
'description' => 'Karte mit Bielefeld als Drehscheibe und der Ablauf-Block am Beispiel CABINET. Die Standorte pflegst du bei den Marken. Steht auch auf /marken.'],
|
||||||
|
['group' => 'home', 'key' => 'vision_section', 'anchor' => 'vision', 'title' => 'Gebaut auf Expertise',
|
||||||
|
'description' => 'Die Geschichte hinter B2in: Text, Kennzahlen und Bild.'],
|
||||||
|
['group' => 'home', 'key' => 'sponsoring', 'anchor' => 'engagement', 'title' => 'Regional verbunden',
|
||||||
|
'description' => 'Vereine und Organisationen, die B2in unterstützt.'],
|
||||||
|
['group' => 'referenzen', 'key' => 'display_feed', 'anchor' => 'displays', 'title' => 'Aus den Stores',
|
||||||
|
'description' => 'Überschrift und Text des Display-Feeds. Die Inhalte kommen aus den Store Displays. Steht auch auf /referenzen.'],
|
||||||
|
['group' => 'home', 'key' => 'immobilien_teaser', 'anchor' => 'immobilien', 'title' => 'Immobilien-Teaser',
|
||||||
|
'description' => 'Der Übergang in die Immobilien-Welt am Ende der Startseite.'],
|
||||||
|
['group' => 'global', 'key' => 'cta_section', 'anchor' => 'cta', 'title' => 'Abschluss: Termin anfragen',
|
||||||
|
'description' => 'Der letzte Block vor dem Fuß. Steht auf allen Interior-Seiten.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'marken' => [
|
||||||
|
'title' => 'Marken',
|
||||||
|
'path' => '/marken',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'marken', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
||||||
|
'description' => 'Überschrift, Einleitung und Bild oben auf /marken. Die Markenblöcke darunter kommen aus der Markenverwaltung.'],
|
||||||
|
['group' => 'marken', 'key' => 'network', 'anchor' => 'netzwerk', 'title' => 'Mehr als Marken: unser Netzwerk',
|
||||||
|
'description' => 'Partner und Zulieferer jenseits der Kernmarken.'],
|
||||||
|
['group' => 'marken', 'key' => 'growth', 'anchor' => 'wachstum', 'title' => 'Die Markenwelt wächst',
|
||||||
|
'description' => 'Hinweis, dass weitere Marken folgen.'],
|
||||||
|
['group' => 'marken', 'key' => 'cta', 'anchor' => 'marken-cta', 'title' => 'Abschluss auf /marken',
|
||||||
|
'description' => 'Der Abschlussblock „Lernen Sie unsere Marken kennen“ mit Terminanfrage und WhatsApp.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'referenzen' => [
|
||||||
|
'title' => 'Referenzen',
|
||||||
|
'path' => '/referenzen',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'referenzen', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
||||||
|
'description' => 'Überschrift und Einleitung der Referenzen-Seite.'],
|
||||||
|
['group' => 'referenzen', 'key' => 'projects', 'anchor' => 'projekte', 'title' => 'Realisierte Projekte',
|
||||||
|
'description' => 'Überschrift, Text und Leerzustand über den Projekten. Die Projekte selbst pflegst du unter „Referenzen“.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'magazin' => [
|
||||||
|
'title' => 'Magazin',
|
||||||
|
'path' => '/magazin',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'magazin', 'key' => 'list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht',
|
||||||
|
'description' => 'Überschrift und Einleitung der Übersicht. Die Beiträge pflegst du unter „Magazin“.'],
|
||||||
|
['group' => 'magazin', 'key' => 'detail', 'anchor' => null, 'title' => 'Beitragsseite',
|
||||||
|
'description' => 'Feste Texte auf jeder Beitragsseite, etwa „Zurück zur Übersicht“ und „Weitere Beiträge“.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'about' => [
|
||||||
|
'title' => 'Über uns',
|
||||||
|
'path' => '/about',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'about', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
||||||
|
'description' => 'Überschrift und Einleitung von „Über uns“.'],
|
||||||
|
['group' => 'about', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Unsere Geschichte',
|
||||||
|
'description' => 'Der Text zur Entstehung von B2in.'],
|
||||||
|
['group' => 'about', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
|
||||||
|
'description' => 'Großes Bild mit kurzer Zeile zwischen Geschichte und Werten.'],
|
||||||
|
['group' => 'about', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Unsere Werte',
|
||||||
|
'description' => 'Die Werte-Kacheln.'],
|
||||||
|
['group' => 'netzwerk', 'key' => 'partner_cta', 'anchor' => 'cta', 'title' => 'Abschluss auf „Über uns“',
|
||||||
|
'description' => 'Der Abschlussblock am Ende von „Über uns“.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'contact' => [
|
||||||
|
'title' => 'Kontakt',
|
||||||
|
'path' => '/contact',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'contact', 'key' => 'contact_form', 'anchor' => 'kontakt', 'title' => 'Kontaktformular',
|
||||||
|
'description' => 'Überschrift, Betreffe, Feldbeschriftungen und Datenschutz-Hinweis des Formulars.'],
|
||||||
|
['group' => 'global', 'key' => 'contact', 'anchor' => 'kontakt', 'title' => 'Kontaktdaten',
|
||||||
|
'description' => 'Telefon, E-Mail, Adresse und die WhatsApp-Nummer. Ohne WhatsApp-Nummer erscheint kein WhatsApp-Button.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'faq' => [
|
||||||
|
'title' => 'FAQ',
|
||||||
|
'path' => '/faq',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'faq', 'key' => 'faq', 'anchor' => 'faq', 'title' => 'Fragen und Antworten',
|
||||||
|
'description' => 'Überschrift und die Liste der Fragen der Interior-Welt.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'global' => [
|
||||||
|
'title' => 'Auf jeder Seite',
|
||||||
|
'path' => '/',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'global', 'key' => 'announcement_bar', 'anchor' => 'header', 'title' => 'Hinweisleiste',
|
||||||
|
'description' => 'Schmale Zeile ganz oben über dem Menü. Leer lassen, um sie auszublenden.'],
|
||||||
|
['group' => 'global', 'key' => 'header', 'anchor' => 'header', 'title' => 'Hauptmenü',
|
||||||
|
'description' => 'Menüpunkte und Buttons im Kopf der Interior-Welt.'],
|
||||||
|
['group' => 'global', 'key' => 'appointment_widget', 'anchor' => null, 'title' => 'Terminanfrage-Widget',
|
||||||
|
'description' => 'Das schwebende Widget rechts unten: Texte, Themen und Zeitfenster.'],
|
||||||
|
['group' => 'global', 'key' => 'footer', 'anchor' => 'footer', 'title' => 'Fußbereich',
|
||||||
|
'description' => 'Spalten, Links, Social-Media und Copyright-Zeile.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'immobilien' => [
|
||||||
|
'title' => 'Immobilien-Welt',
|
||||||
|
'path' => '/immobilien',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'global', 'key' => 'announcement_bar_immobilien', 'anchor' => 'header', 'title' => 'Hinweisleiste (Immobilien)',
|
||||||
|
'description' => 'Hinweisleiste über dem Menü der Immobilien-Welt.'],
|
||||||
|
['group' => 'global', 'key' => 'header_immobilien', 'anchor' => 'header', 'title' => 'Hauptmenü (Immobilien)',
|
||||||
|
'description' => 'Menüpunkte und Buttons im Kopf der Immobilien-Welt.'],
|
||||||
|
['group' => 'global', 'key' => 'cta_section_immobilien', 'anchor' => 'cta', 'title' => 'Abschluss (Immobilien)',
|
||||||
|
'description' => 'Der letzte Block vor dem Fuß auf allen Immobilien-Seiten.'],
|
||||||
|
['group' => 'immobilien', 'key' => 'magazin_list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht (Immobilien)',
|
||||||
|
'description' => 'Überschrift und Einleitung unter /immobilien/magazin.', 'path' => '/immobilien/magazin'],
|
||||||
|
['group' => 'immobilien', 'key' => 'about_hero', 'anchor' => 'seitenkopf', 'title' => 'Über B2in: Seitenkopf',
|
||||||
|
'description' => 'Überschrift und Einleitung unter /immobilien/ueber-b2in.', 'path' => '/immobilien/ueber-b2in'],
|
||||||
|
['group' => 'immobilien', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Über B2in: Geschichte',
|
||||||
|
'description' => 'Der Geschichte-Block der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
|
||||||
|
['group' => 'immobilien', 'key' => 'about_image_break', 'anchor' => 'bild', 'title' => 'Über B2in: Bildpause',
|
||||||
|
'description' => 'Bild zwischen Geschichte und Werten.', 'path' => '/immobilien/ueber-b2in'],
|
||||||
|
['group' => 'immobilien', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Über B2in: Werte',
|
||||||
|
'description' => 'Die Werte-Kacheln der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
|
||||||
|
['group' => 'faq', 'key' => 'faq_immobilien', 'anchor' => 'faq', 'title' => 'FAQ (Immobilien)',
|
||||||
|
'description' => 'Fragen und Antworten unter /immobilien/faq.', 'path' => '/immobilien/faq'],
|
||||||
|
['group' => 'contact', 'key' => 'contact_form_immobilien', 'anchor' => 'kontakt', 'title' => 'Kontaktformular (Immobilien)',
|
||||||
|
'description' => 'Abweichende Texte und Betreffe unter /immobilien/kontakt. Leere Felder übernehmen den Interior-Wert.', 'path' => '/immobilien/kontakt'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'legal' => [
|
||||||
|
'title' => 'Rechtliches',
|
||||||
|
'path' => '/impressum',
|
||||||
|
'sections' => [
|
||||||
|
['group' => 'legal', 'key' => 'impressum', 'anchor' => null, 'title' => 'Impressum',
|
||||||
|
'description' => 'Pflichtangaben. Der Inhalt ist HTML.', 'path' => '/impressum'],
|
||||||
|
['group' => 'legal', 'key' => 'privacy', 'anchor' => null, 'title' => 'Datenschutz',
|
||||||
|
'description' => 'Datenschutzerklärung. Der Inhalt ist HTML.', 'path' => '/privacy'],
|
||||||
|
['group' => 'legal', 'key' => 'terms', 'anchor' => null, 'title' => 'AGB',
|
||||||
|
'description' => 'Allgemeine Geschäftsbedingungen. Der Inhalt ist HTML.', 'path' => '/terms'],
|
||||||
|
['group' => 'legal', 'key' => 'cookie_policy', 'anchor' => null, 'title' => 'Cookie-Hinweise',
|
||||||
|
'description' => 'Cookie-Richtlinie. Der Inhalt ist HTML.', 'path' => '/cookie-policy'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Sammelpunkt für alles, was in der Datenbank liegt, aber oben nicht steht
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'unlisted' => [
|
||||||
|
'title' => 'Nicht mehr verwendet',
|
||||||
|
'description' => 'Sektionen stillgelegter Seiten (Netzwerk, Partner, alte Immobilien-Seite). Bleiben als Archiv liegen, erscheinen nirgends.',
|
||||||
|
],
|
||||||
|
];
|
||||||
62
database/factories/CmsReferenceFactory.php
Normal file
62
database/factories/CmsReferenceFactory.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\CmsReference;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<CmsReference>
|
||||||
|
*/
|
||||||
|
class CmsReferenceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = CmsReference::class;
|
||||||
|
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$title = fake()->unique()->words(3, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => ['de' => ucfirst($title), 'en' => ucfirst($title)],
|
||||||
|
'location' => fake()->city(),
|
||||||
|
'year' => (string) fake()->numberBetween(2022, 2026),
|
||||||
|
'description' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
|
||||||
|
'image' => 'referenzen/'.fake()->unique()->slug(2).'.webp',
|
||||||
|
'gallery' => [],
|
||||||
|
'brand_slugs' => [],
|
||||||
|
'is_published' => true,
|
||||||
|
'order' => fake()->numberBetween(1, 20),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function published(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => ['is_published' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unpublished(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => ['is_published' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $slugs
|
||||||
|
*/
|
||||||
|
public function forBrands(array $slugs): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => ['brand_slugs' => $slugs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withGallery(int $count = 2): static
|
||||||
|
{
|
||||||
|
return $this->state(function () use ($count) {
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
for ($i = 1; $i <= $count; $i++) {
|
||||||
|
$items[] = ['image' => 'referenzen/bild-'.$i.'.webp', 'caption' => 'Bild '.$i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['gallery' => $items];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fertigungsort je Kernmarke (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
|
||||||
|
*
|
||||||
|
* Die Herkunfts-Sektion „Aus der Region gefertigt“ holt ihre Standorte nicht mehr aus einem
|
||||||
|
* JSON-Eintrag im generischen Inhalte-Editor, sondern aus der Markenverwaltung: Marcel pflegt
|
||||||
|
* Ort, Entfernung und Rolle direkt bei der Marke. Die Kartenpositionen bleiben im Code.
|
||||||
|
*
|
||||||
|
* - `origin_city` Fertigungsort, z. B. „Bünde“ – leer bedeutet: Marke erscheint nicht in der Sektion
|
||||||
|
* - `origin_distance` Entfernung ab Bielefeld als Text, z. B. „rund 30 km“
|
||||||
|
* - `origin_role` Ein Satz zur Rolle des Standorts
|
||||||
|
*
|
||||||
|
* Alle drei sind übersetzbar wie die übrigen Textfelder der Marke.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('cms_brands', function (Blueprint $table) {
|
||||||
|
$table->json('origin_city')->nullable()->after('showroom');
|
||||||
|
$table->json('origin_distance')->nullable()->after('origin_city');
|
||||||
|
$table->json('origin_role')->nullable()->after('origin_distance');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('cms_brands', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['origin_city', 'origin_distance', 'origin_role']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenzprojekte der Interior-Welt (Nachtrag 13).
|
||||||
|
*
|
||||||
|
* AP5 hatte die Referenzen aus den Markengalerien zusammengesammelt, um keine zweite Pflegestelle
|
||||||
|
* zu schaffen. Abstimmung 15.09.: Marcel soll Referenzen als eigene Einträge pflegen – ein
|
||||||
|
* realisiertes Projekt hat mehr als ein Bild und mehr als eine Marke. Solange kein Eintrag
|
||||||
|
* existiert, greift das Frontend weiter auf die Markengalerien zurück.
|
||||||
|
*
|
||||||
|
* - `title`, `description` übersetzbar
|
||||||
|
* - `location`, `year` Ort und Jahr als Text („Bielefeld“, „2025“)
|
||||||
|
* - `image` Titelbild (Dateiname aus der Medienbibliothek)
|
||||||
|
* - `gallery` weitere Bilder mit Bildunterschrift
|
||||||
|
* - `brand_slugs` beteiligte Kernmarken (Slugs aus cms_brands), verlinkt auf die Markenseiten
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cms_references', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->json('title');
|
||||||
|
$table->string('location')->nullable();
|
||||||
|
$table->string('year', 20)->nullable();
|
||||||
|
$table->json('description')->nullable();
|
||||||
|
$table->string('image')->nullable();
|
||||||
|
$table->json('gallery')->nullable();
|
||||||
|
$table->json('brand_slugs')->nullable();
|
||||||
|
$table->boolean('is_published')->default(true);
|
||||||
|
$table->integer('order')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cms_references');
|
||||||
|
}
|
||||||
|
};
|
||||||
87
database/seeders/BrandOriginSeeder.php
Normal file
87
database/seeders/BrandOriginSeeder.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\CmsBrand;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trägt den Fertigungsort der Kernmarken nach (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
|
||||||
|
*
|
||||||
|
* Die Herkunfts-Sektion „Aus der Region gefertigt“ liest Ort, Entfernung und Rolle aus der
|
||||||
|
* Marke. {@see CmsBrandSeeder} bringt dieselben Werte für frische Installationen mit,
|
||||||
|
* überspringt aber bereits angelegte Marken. Dieser Seeder fasst nur die drei Felder an und
|
||||||
|
* auch nur bei Marken, deren Fertigungsort noch leer ist – ein im Admin eingetragener Ort
|
||||||
|
* bleibt stehen. Mehrfaches Ausführen ist unbedenklich.
|
||||||
|
*
|
||||||
|
* Entfernungen gerundet, Straße ab Bielefeld Mitte. ⚠️ Rolle CABINET nach Kevins Verständnis
|
||||||
|
* (Fertigung Köln, Beratung im Store Bielefeld) – von Marcel bestätigen lassen.
|
||||||
|
*
|
||||||
|
* php artisan db:seed --class=BrandOriginSeeder
|
||||||
|
*/
|
||||||
|
class BrandOriginSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/** @var array<string, array{origin_city: array<string, string>, origin_distance: array<string, string>, origin_role: array<string, string>}> */
|
||||||
|
private const ORIGINS = [
|
||||||
|
'cabinet' => [
|
||||||
|
'origin_city' => ['de' => 'Köln', 'en' => 'Cologne'],
|
||||||
|
'origin_distance' => ['de' => 'rund 200 km', 'en' => 'about 200 km'],
|
||||||
|
'origin_role' => [
|
||||||
|
'de' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
|
||||||
|
'en' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'rotpunkt' => [
|
||||||
|
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
|
||||||
|
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
|
||||||
|
'origin_role' => [
|
||||||
|
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
|
||||||
|
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'februe' => [
|
||||||
|
'origin_city' => ['de' => 'Herford', 'en' => 'Herford'],
|
||||||
|
'origin_distance' => ['de' => 'rund 20 km', 'en' => 'about 20 km'],
|
||||||
|
'origin_role' => [
|
||||||
|
'de' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
|
||||||
|
'en' => 'Office and contract furniture from their own production in Herford.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'tischlerei' => [
|
||||||
|
'origin_city' => ['de' => 'Rheda-Wiedenbrück', 'en' => 'Rheda-Wiedenbrück'],
|
||||||
|
'origin_distance' => ['de' => 'rund 35 km', 'en' => 'about 35 km'],
|
||||||
|
'origin_role' => [
|
||||||
|
'de' => 'Unsere Werkstatt für Aufbau, Sonderlösungen und Veredelung.',
|
||||||
|
'en' => 'Our workshop for assembly, custom solutions and finishing.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$set = 0;
|
||||||
|
$kept = 0;
|
||||||
|
|
||||||
|
foreach (self::ORIGINS as $slug => $origin) {
|
||||||
|
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||||
|
|
||||||
|
if (! $brand) {
|
||||||
|
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim((string) $brand->getTranslation('origin_city', 'de', false)) !== '') {
|
||||||
|
$kept++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$brand->fill($origin);
|
||||||
|
$brand->save();
|
||||||
|
$set++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command?->info("Fertigungsorte gesetzt: {$set}, vorhandene behalten: {$kept}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,8 @@ use Illuminate\Database\Seeder;
|
||||||
* Partnerbetriebs gehört laut Plan auf die Detailseite, nicht in die Marke.
|
* 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
|
* 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
|
* Stimmungsbilder und Galerien fehlen als Zulieferung und bleiben leer; die Kacheln und
|
||||||
* Detailseiten zeigen so lange einen Platzhalter.
|
* 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.'],
|
['title' => 'Montage durch Fachleute', 'description' => 'Wir liefern und montieren – bis zum letzten Detail.'],
|
||||||
],
|
],
|
||||||
'showroom' => 'Showroom Bielefeld',
|
'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,
|
'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.'],
|
['title' => 'Lieferung und Aufbau', 'description' => 'Koordiniert aus einer Hand, inklusive Anschluss.'],
|
||||||
],
|
],
|
||||||
'showroom' => 'Küchenstudio',
|
'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,
|
'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.'],
|
['title' => 'Akustik und Ordnung', 'description' => 'Stauraum und Raumgliederung, die Konzentration ermöglichen.'],
|
||||||
],
|
],
|
||||||
'showroom' => '',
|
'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,
|
'is_published' => true,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|
@ -223,6 +242,12 @@ It complements CABINET and Rotpunkt wherever making to drawing is required: asse
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'showroom' => '',
|
'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,
|
'is_published' => true,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,21 @@
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use FluxCms\Core\Models\CmsContent;
|
||||||
|
use FluxCms\Core\Services\CmsContentService;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Herkunfts-Sektion „Aus der Region gefertigt“ ins CMS legen (Arbeitsplan 14.09.2026, Punkt 1.3).
|
* 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,
|
* Legt die Sektion nur an, wenn sie fehlt. Ein bestehender Eintrag bleibt inhaltlich unangetastet,
|
||||||
* damit Korrekturen aus dem Admin (Entfernungen, Ablauf CABINET) nicht verloren gehen.
|
* 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
|
* php artisan db:seed --class=OriginSectionCmsSeeder
|
||||||
*/
|
*/
|
||||||
|
|
@ -24,6 +32,38 @@ class OriginSectionCmsSeeder extends Seeder
|
||||||
$seeder->onlySections = self::SECTIONS;
|
$seeder->onlySections = self::SECTIONS;
|
||||||
$seeder->run();
|
$seeder->run();
|
||||||
|
|
||||||
|
$this->dropLegacyLocations();
|
||||||
|
|
||||||
$this->command?->info('Herkunfts-Sektion: angelegt, falls sie fehlte.');
|
$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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| 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.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.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
|
### 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,
|
**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).
|
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.
|
**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
|
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.
|
# 8. Vierte Kernmarke „Tischlerei“ als Platzhalter (Nachtrag 9) – legt nur an, was fehlt.
|
||||||
php artisan db:seed --class=CmsBrandSeeder --force
|
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
|
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 config:cache
|
||||||
php artisan view:clear
|
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`.
|
Markenübersicht und „Mehr als Marken: unser Netzwerk“. Anker `#herkunft`.
|
||||||
- **Schematische Karte** (SVG, ab `md`): Bielefeld als Drehscheibe in Ocker, die vier Standorte
|
- **Schematische Karte** (SVG, ab `md`): Bielefeld als Drehscheibe in Ocker, die vier Standorte
|
||||||
als Punkte in Tanne mit gestrichelten Speichen und Entfernungsangabe, gestrichelte Ellipse
|
als Punkte in Tanne mit gestrichelten Speichen und Entfernungsangabe, gestrichelte Ellipse
|
||||||
„Ostwestfalen“ um die drei OWL-Standorte. Keine Geografie, nur Richtung und Entfernung –
|
„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
|
Positionen und Beschriftungsseite lagen zunächst im Inhalt; seit Nachtrag 11 stehen sie im
|
||||||
Inhalt und sind im Admin änderbar. Unter `md` entfällt die Karte, die Kacheln tragen alles.
|
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
|
- **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
|
Link „Zur Marke“. Ursprünglich lagen die Standorte im Sektions-JSON, der Link kam aus den
|
||||||
Marken (`brand_slug`, ersatzweise Namensabgleich): eine unveröffentlichte Marke verliert den
|
veröffentlichten Marken. Seit Nachtrag 11 gilt: Standort = Marke, siehe dort.
|
||||||
Link, ihr Standort bleibt sichtbar.
|
|
||||||
- **Ablauf-Block „Gefertigt in Köln, geplant in Bielefeld“**: drei nummerierte Schritte am
|
- **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
|
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“
|
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
|
**Offen:** Bestätigung des CABINET-Ablaufs durch Marcel; Wortlaut der Tischlerei-Kachel folgt
|
||||||
dem Platzhalter aus Nachtrag 9 und wandert mit dem echten Markennamen.
|
dem Platzhalter aus Nachtrag 9 und wandert mit dem echten Markennamen.
|
||||||
|
|
||||||
**Tests:** `tests/Feature/OriginSectionTest.php` (12 Fälle).
|
**Tests:** `tests/Feature/OriginSectionTest.php` (12 Fälle, mit Nachtrag 11 umgeschrieben).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachtrag 11 · Standorte an der Marke, Marken in der Sidebar (15.09.2026)
|
||||||
|
|
||||||
|
Prüfung der Inhaltspflege im Admin auf Kevins Frage, ob Marken wie das Magazin einen eigenen
|
||||||
|
Punkt brauchen. Ergebnis: **Die Markenverwaltung gab es schon** („Kernmarken verwalten“, AP2),
|
||||||
|
sie war nur nicht in der Sidebar verlinkt – erreichbar allein über die Kachel auf der
|
||||||
|
CMS-Übersicht. Die Herkunfts-Sektion aus Nachtrag 10 lag dagegen komplett im generischen
|
||||||
|
Inhalte-Editor, inklusive der Kartenkoordinaten `x`/`y` und der Beschriftungsseite als nackte
|
||||||
|
Felder. Das war für Marcel nicht pflegbar. Abgestimmt mit Kevin: **Weg 1**, Standort an die Marke.
|
||||||
|
|
||||||
|
### Was sich ändert
|
||||||
|
|
||||||
|
- **Sidebar:** neuer Eintrag „Marken“ unter CMS → Website-Inhalte, zwischen Projekte und
|
||||||
|
Magazin. Die Gruppe klappt auf der Markenseite jetzt auch auf.
|
||||||
|
- **Drei neue Felder je Marke** (Migration `add_origin_to_cms_brands_table`, übersetzbar wie die
|
||||||
|
übrigen Textfelder): `origin_city` (Fertigungsort), `origin_distance` (Entfernung als Text,
|
||||||
|
„rund 30 km“), `origin_role` (ein Satz zur Rolle). Im Admin als Block „Fertigungsort“ unter
|
||||||
|
den Stammdaten, mit Hinweis: ohne Ort erscheint die Marke nicht in der Sektion.
|
||||||
|
- **`OriginSection` liest die Standorte aus `CmsBrand::forFrontend()`**: nur veröffentlichte
|
||||||
|
Marken mit Ort, in der Reihenfolge der Marken. Eine unveröffentlichte Marke fällt damit ganz
|
||||||
|
heraus (vorher blieb ihr Standort ohne Link stehen – jetzt gibt es keinen Standort ohne
|
||||||
|
Marke mehr). Der Link „Zur Marke“ hängt an `has_detail_page`, der Text `link_text` liegt
|
||||||
|
einmal auf Sektionsebene statt je Standort.
|
||||||
|
- **Kartenpositionen im Code:** `OriginSection::MAP_POSITIONS`, je Slug `x`/`y`/`label`. Eine
|
||||||
|
neue Marke mit Ort, aber ohne Position, erscheint in den Kacheln, nicht in der Karte – dafür
|
||||||
|
muss ein Entwickler eine Position ergänzen. Das ist gewollt: die Karte ist Zeichnung, kein
|
||||||
|
Inhalt.
|
||||||
|
- **Sektions-JSON `home/origin_section`** enthält nur noch Überschrift, Untertitel, Alt-Text,
|
||||||
|
Regionslabel, Entfernungs-Suffix, Drehscheibe, Link-Text und Ablauf-Block. Der alte Schlüssel
|
||||||
|
`locations` wird von `OriginSectionCmsSeeder` aus einem bestehenden Eintrag entfernt, damit er
|
||||||
|
im Inhalte-Editor nicht mehr als Pflegefeld auftaucht (idempotent, sonst weiterhin nur
|
||||||
|
anlegen-wenn-fehlt).
|
||||||
|
- **Lang-Fallback:** die vier Einträge in `core_brands.brands` tragen jetzt `slug` und die drei
|
||||||
|
Herkunftsfelder, damit die Sektion auch ohne gepflegte Marke steht – dann ohne Links, wie die
|
||||||
|
Kacheln der Kernmarken.
|
||||||
|
- **Seeder:** `CmsBrandSeeder` bringt die Herkunft für frische Installationen mit; neu
|
||||||
|
`BrandOriginSeeder` trägt sie bei bestehenden Marken nach, nur wenn der Ort leer ist (Muster
|
||||||
|
wie `BrandLogoSeeder`). Keine Dubletten möglich, beide arbeiten über den Slug.
|
||||||
|
- Ohne Marke mit Ort bleiben Überschrift und Ablauf-Block stehen, Karte und Kacheln entfallen.
|
||||||
|
|
||||||
|
**Lokal ausgeführt:** `php artisan migrate`, `db:seed --class=BrandOriginSeeder` (4 gesetzt),
|
||||||
|
`db:seed --class=OriginSectionCmsSeeder` (`locations` entfernt). Beim Deployment: Abschnitt 8,
|
||||||
|
Schritte 9 und 10, die Migration läuft mit `migrate` vorweg.
|
||||||
|
|
||||||
|
**Offen:** wie Nachtrag 10 (Bestätigung des CABINET-Ablaufs durch Marcel). Der generische
|
||||||
|
Inhalte-Editor bleibt für die übrigen Sektionen ohne Schema; wo Listen mit technischen Feldern
|
||||||
|
auftauchen, ist derselbe Weg möglich: Daten an ein Modell hängen, Darstellung in den Code.
|
||||||
|
|
||||||
|
**Tests:** `tests/Feature/OriginSectionTest.php` (15 Fälle), drei neue Fälle in
|
||||||
|
`tests/Feature/Admin/Cms/CmsBrandsIndexTest.php` (Herkunft je Sprache speichern, Ort leeren,
|
||||||
|
Sidebar-Link).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachtrag 12 · Inhalte-Editor: Seitenbaum und Vorschau (15.09.2026)
|
||||||
|
|
||||||
|
Kevins Rückmeldung nach der Prüfung des CMS: der Punkt „Inhalte“ zeigt Datenbank-Gruppen
|
||||||
|
alphabetisch, ohne Bezug zur Seite, mit JSON-Schlüsseln als Beschriftung – zu abstrakt für
|
||||||
|
Marcel. Abgestimmt: Stufe 1 (Seitenbaum, Klartext) und Stufe 2 (Vorschau im Rahmen) umsetzen,
|
||||||
|
Stufe 3 (Stift-Markierungen in der Vorschau, die den Editor öffnen) erst nach Marcels Feedback.
|
||||||
|
|
||||||
|
### Stufe 1 · Seitenbaum statt Gruppen
|
||||||
|
|
||||||
|
- **Registry `config/cms_pages.php`:** je Seite (Startseite, Marken, Referenzen, Magazin, Über
|
||||||
|
uns, Kontakt, FAQ, „Auf jeder Seite“, Immobilien-Welt, Rechtliches) die Sektionen in der
|
||||||
|
Reihenfolge, wie sie auf der Website stehen, mit Titel, einem Satz Beschreibung, Pfad und
|
||||||
|
Sprungmarke. Dazu ein Wörterbuch `field_labels` (`button_text` → „Button-Beschriftung“,
|
||||||
|
`x`/`y` → „Position (Entwickler)“ …), je Sektion überschreibbar über `fields`.
|
||||||
|
- **`App\Services\CmsPageTree`** verbindet die Registry mit der Datenbank: welche Sektion
|
||||||
|
existiert (`content_id`), welche fehlt, was in der Datenbank liegt, aber auf keiner Seite
|
||||||
|
eingetragen ist. Letzteres landet als Sammelpunkt **„Nicht mehr verwendet“** ganz unten
|
||||||
|
(Netzwerk-, Partner- und alte Immobilien-Sektionen, `home/synergie_section` & Co.) – nichts
|
||||||
|
geht verloren, nichts steht im Weg.
|
||||||
|
- **Editor (`admin.cms.content-index`):** linke Spalte zeigt den Seitenbaum, nummeriert von
|
||||||
|
oben nach unten. Ein Klick wählt genau eine Sektion (`selectSection`), der Kopf zeigt Titel,
|
||||||
|
Beschreibung, Seite und den technischen Schlüssel klein darunter. Felder tragen die
|
||||||
|
Klartext-Beschriftung, der Schlüssel steht als `code` daneben; Typ-Badges heißen „Text“,
|
||||||
|
„Formatierter Text“, „Bild“, „Liste“. Der Listen-Editor (Overlay) beschriftet seine Felder
|
||||||
|
genauso. `selectGroup` bleibt für die Gruppenansicht erhalten.
|
||||||
|
- **Fehlende Sektion anlegen:** steht eine Sektion in der Registry, aber nicht in der
|
||||||
|
Datenbank (heute z. B. das Terminanfrage-Widget), erscheint sie grau mit „+“. Ein Klick
|
||||||
|
legt sie über `CmsContentSeeder` mit den Texten aus `b2in.php` an. Rechtliches und die
|
||||||
|
Kontaktdaten (`global/contact`) haben keine Vorlage, dort erscheint ein Hinweis.
|
||||||
|
|
||||||
|
### Stufe 2 · Vorschau
|
||||||
|
|
||||||
|
- Rechts neben dem Editor ein **iframe mit der echten Website** (Domain aus
|
||||||
|
`config('domains')`, Pfad der Seite, `?cms-preview=<n>` als Cache-Brecher, `#anker`). Beim
|
||||||
|
Wechsel der Sektion springt die Vorschau zur Sprungmarke, nach jedem Speichern lädt sie
|
||||||
|
neu (`previewVersion`). Buttons: neu laden, in neuem Tab öffnen, Vorschau aus/ein.
|
||||||
|
Keine Anmeldung nötig, die Website ist öffentlich; Frame-Header setzt die App keine.
|
||||||
|
- **Sprungmarken nachgezogen:** `id` an den Wurzelelementen der lebenden Sektionen (`hero`,
|
||||||
|
`gruender`, `wege`, `marken`, `herkunft`, `vision`, `engagement`, `displays`, `immobilien`,
|
||||||
|
`cta`, `seitenkopf`, `netzwerk`, `wachstum`, `marken-cta`, `projekte`, `magazin`,
|
||||||
|
`geschichte`, `bild`, `werte`, `kontakt`, `faq`, `header`, `footer`). Beim Display-Feed
|
||||||
|
sitzt der Anker am Wurzel-`div`, weil die Sektion ohne veröffentlichtes Display leer bleibt.
|
||||||
|
- Sektionen ohne Seite (Beitragsseite, Widget, Rechtliches) zeigen die Seite ohne Sprung.
|
||||||
|
|
||||||
|
### Erkenntnisse beim Kartieren
|
||||||
|
|
||||||
|
- `/immobilien` rendert `web/dev/immobilien-azizi.blade.php` mit statischen Texten aus
|
||||||
|
`immobilien-azizi.json`; die CMS-Sektionen `immobilien/hero`, `warum_dubai`, `kaufprozess`
|
||||||
|
usw. gehören zur stillgelegten `web/immobilien.blade.php` und stehen deshalb unter „Nicht
|
||||||
|
mehr verwendet“. Gleiches gilt für `about/leadership_team`.
|
||||||
|
- `display_feed` liegt in der Gruppe `referenzen`, steht aber zuerst auf der Startseite –
|
||||||
|
im Baum unter Startseite mit Hinweis auf /referenzen.
|
||||||
|
|
||||||
|
**Tests:** `tests/Feature/Cms/CmsPageTreeTest.php` (9 Fälle, darunter: jede Sprungmarke der
|
||||||
|
Registry existiert auf der jeweiligen Seite, jede Sektion mit Vorlage ist in `b2in.php`
|
||||||
|
auffindbar) und sechs neue Fälle in `tests/Feature/Cms/CmsAdminTest.php` (Seitenbaum,
|
||||||
|
Sektion wählen mit Klartext-Labels und Vorschau-Adresse, Sammelpunkt, Anlegen aus Vorlage,
|
||||||
|
Anlegen ohne Vorlage, Vorschau ausblenden). Kein Deployment-Schritt nötig: Registry und
|
||||||
|
Editor sind Code, die Datenbank bleibt unverändert.
|
||||||
|
|
||||||
|
**Offen:** Stufe 3 nach Marcels Rückmeldung. Neue Sektionen müssen in `cms_pages.php`
|
||||||
|
eingetragen werden, sonst landen sie unter „Nicht mehr verwendet“ – als Erinnerung steht das
|
||||||
|
im Kopf der Datei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nachtrag 13 · Referenzen als eigenes Modul, Immobilien-Projekte ausgeblendet (15.09.2026)
|
||||||
|
|
||||||
|
Kevin nach der Abnahme von Nachtrag 12: „Projekte“ im Portal (die Immobilienprojekte aus
|
||||||
|
Dubai) ist nicht freigegeben und kommt raus; an die Stelle gehören die Projekte/Referenzen der
|
||||||
|
Interior-Welt. Das ist eine **Korrektur zu AP5**, das die Referenzen bewusst aus den
|
||||||
|
Markengalerien zusammengesammelt hatte, um keine zweite Pflegestelle zu schaffen. Ein
|
||||||
|
realisiertes Projekt hat aber mehr als ein Bild und oft mehr als eine Marke (CABINET plus
|
||||||
|
Tischlerei), darum jetzt ein eigener Eintrag.
|
||||||
|
|
||||||
|
### Was sich ändert
|
||||||
|
|
||||||
|
- **Neues Modell `CmsReference`** (Tabelle `cms_references`): Titel und Beschreibung
|
||||||
|
übersetzbar, Ort und Jahr als Text, Titelbild, weitere Bilder mit Bildunterschrift,
|
||||||
|
beteiligte Marken als Liste von Slugs aus `cms_brands`, Veröffentlicht, Sortierung.
|
||||||
|
- **Portal → CMS → „Referenzen“** (`admin.cms.references-index`, Route
|
||||||
|
`cms.references.index`): Liste mit Vorschaubild, Marken-Badges und Bildanzahl; Formular
|
||||||
|
nach dem Muster des Magazins mit Sprachumschalter, Marken als Checkboxen, Titelbild und
|
||||||
|
Galerie über den Media-Picker, Galeriebilder sortierbar. Der Kopf der Seite sagt die Regel:
|
||||||
|
nur ausgeführte Aufträge, Entwürfe gehören in die Galerie der Marke.
|
||||||
|
- **Sidebar und CMS-Übersicht:** „Referenzen“ ersetzt „Projekte“. Die Route
|
||||||
|
`cms.projects.index` bleibt bestehen und ist per Adresse erreichbar, nur nicht mehr
|
||||||
|
verlinkt – die Dubai-Daten bleiben unangetastet, falls die Freigabe später kommt.
|
||||||
|
- **`/referenzen`** zeigt die veröffentlichten Referenzen: Titelbild, Titel, „Ort · Jahr“,
|
||||||
|
Beschreibung, weitere Bilder als kleine Reihe, darunter die beteiligten Marken verlinkt auf
|
||||||
|
ihre Markenseiten (unveröffentlichte Marken fallen heraus). Ohne Titelbild dient das erste
|
||||||
|
Galeriebild als Titelbild, ohne jedes Bild wird die Referenz übersprungen.
|
||||||
|
- **Fallback bleibt:** Solange keine Referenz veröffentlicht ist, greift der Stand aus AP5
|
||||||
|
(Bilder der Rubrik „Realisiertes Projekt“ aus den Markengalerien). Der Leerzustand von AP5
|
||||||
|
gilt weiter, wenn beides leer ist. So sieht die Preview beim Deployment keinen Unterschied,
|
||||||
|
bis Marcel die erste Referenz anlegt.
|
||||||
|
- Registry `cms_pages.php`: Beschreibung von „Realisierte Projekte“ verweist auf das neue Modul.
|
||||||
|
|
||||||
|
**Deployment:** nur `php artisan migrate --force` (Abschnitt 8.2, Schritt 1 deckt die neue
|
||||||
|
Tabelle ab). Kein Seeder, keine Daten.
|
||||||
|
|
||||||
|
**Offen:** Marcel legt die ersten Referenzen selbst an; die bisherigen Galeriebilder der Marken
|
||||||
|
bleiben dort, wo sie sind. Ob die Bilder aus den Markengalerien später automatisch als Referenz
|
||||||
|
übernommen werden sollen, klärt sich mit den ersten echten Einträgen.
|
||||||
|
|
||||||
|
**Tests:** `tests/Feature/Admin/Cms/CmsReferencesIndexTest.php` (9 Fälle), vier neue Fälle in
|
||||||
|
`tests/Feature/ReferenzenPageTest.php` (Referenzen vor Galerien, Entwurf ausgeblendet und
|
||||||
|
Fallback, unveröffentlichte Marke und Referenz ohne Bild, Galeriebild als Titelbild),
|
||||||
|
Dashboard-Statistik in `CmsAdminTest` auf Referenzen umgestellt.
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
'subtitle' => 'Wir arbeiten nicht mit beliebigen Katalogen, sondern mit ausgewählten Herstellern, hinter denen wir stehen – jede Marke ein Spezialist auf ihrem Gebiet.',
|
||||||
'brands' => [
|
'brands' => [
|
||||||
[
|
[
|
||||||
|
'slug' => 'cabinet',
|
||||||
'name' => 'CABINET',
|
'name' => 'CABINET',
|
||||||
'tagline' => 'Einbauschränke nach Maß.',
|
'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.',
|
'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' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'Mehr zu CABINET',
|
'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',
|
'name' => 'Rotpunkt Küchen',
|
||||||
'tagline' => 'Küchen, die Haltung zeigen.',
|
'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.',
|
'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' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'Mehr zu Rotpunkt',
|
'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ü',
|
'name' => 'Febrü',
|
||||||
'tagline' => 'Arbeiten, wo man gerne bleibt.',
|
'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.',
|
'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' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'Mehr zu Febrü',
|
'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',
|
'name' => 'Tischlerei',
|
||||||
'tagline' => 'Maßarbeit, die den Raum vollendet.',
|
'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.',
|
'teaser' => 'Aufbau, Sonderlösungen und Veredelung aus einer Tischlerei in Ostwestfalen – dort, wo Serienmöbel aufhören und Ihr Raum anfängt.',
|
||||||
'image' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'Mehr zur Tischlerei',
|
'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
|
// Herkunfts-Sektion (Arbeitsplan 14.09., Punkt 1.3). Die Standorte kommen aus den Marken
|
||||||
// schematischen Karte (viewBox 640×400), keine Geografie. Entfernungen gerundet, Straße ab
|
// (origin_city, origin_distance, origin_role – Nachtrag 11), die Kartenpositionen aus
|
||||||
// Bielefeld Mitte. ⚠️ Ablauf CABINET (steps) nach Kevins Verständnis – von Marcel bestätigen lassen.
|
// OriginSection::MAP_POSITIONS. ⚠️ Ablauf CABINET (steps) nach Kevins Verständnis – von Marcel bestätigen lassen.
|
||||||
'origin_section' => [
|
'origin_section' => [
|
||||||
'title' => 'Aus der Region <span class="text-secondary">gefertigt</span>',
|
'title' => 'Aus der Region <span class="text-secondary">gefertigt</span>',
|
||||||
'subtitle' => 'Vier deutsche Manufakturen, drei davon in Ostwestfalen. Kurze Wege, Menschen, die wir kennen, und Qualität, die man besuchen kann.',
|
'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,
|
'x' => 400,
|
||||||
'y' => 175,
|
'y' => 175,
|
||||||
],
|
],
|
||||||
'locations' => [
|
'link_text' => 'Zur Marke',
|
||||||
[
|
|
||||||
'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',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'steps_title' => 'Gefertigt in Köln, <span class="text-secondary">geplant in Bielefeld</span>',
|
'steps_title' => 'Gefertigt in Köln, <span class="text-secondary">geplant in Bielefeld</span>',
|
||||||
'steps_text' => 'Am Beispiel CABINET: Die Möbel entstehen in der Kölner Fertigung. Beraten, aufgemessen und geplant wird bei uns im CABINET Store Bielefeld. Aufgebaut wird vor Ort – mit unserer Tischlerei, die auf Wunsch auch das umsetzt, was im Katalog nicht steht.',
|
'steps_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' => [
|
'steps' => [
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
'subtitle' => 'We do not work with arbitrary catalogues, but with selected manufacturers we stand behind – each brand a specialist in its field.',
|
||||||
'brands' => [
|
'brands' => [
|
||||||
[
|
[
|
||||||
|
'slug' => 'cabinet',
|
||||||
'name' => 'CABINET',
|
'name' => 'CABINET',
|
||||||
'tagline' => 'Fitted wardrobes made to measure.',
|
'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.',
|
'teaser' => 'Bespoke wardrobe solutions, planned to the millimetre – built for rooms where standard will not do. Experience the possibilities in our Bielefeld showroom.',
|
||||||
'image' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'More about CABINET',
|
'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',
|
'name' => 'Rotpunkt Küchen',
|
||||||
'tagline' => 'Kitchens with character.',
|
'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.',
|
'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' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'More about Rotpunkt',
|
'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ü',
|
'name' => 'Febrü',
|
||||||
'tagline' => 'Work where you like to be.',
|
'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.',
|
'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' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'More about Febrü',
|
'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',
|
'name' => 'Joinery',
|
||||||
'tagline' => 'Craftsmanship that completes the room.',
|
'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.',
|
'teaser' => 'Assembly, bespoke solutions and finishing from a joinery in East Westphalia – where series furniture ends and your room begins.',
|
||||||
'image' => '',
|
'image' => '',
|
||||||
'logo' => '',
|
'logo' => '',
|
||||||
'link_text' => 'More about the joinery',
|
'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,
|
'x' => 400,
|
||||||
'y' => 175,
|
'y' => 175,
|
||||||
],
|
],
|
||||||
'locations' => [
|
'link_text' => 'To the brand',
|
||||||
[
|
|
||||||
'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',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'steps_title' => 'Made in Cologne, <span class="text-secondary">planned in Bielefeld</span>',
|
'steps_title' => 'Made in Cologne, <span class="text-secondary">planned in Bielefeld</span>',
|
||||||
'steps_text' => 'Take CABINET: the furniture is made in Cologne. Consulting, measuring and planning happen with us at the CABINET Store Bielefeld. Assembly takes place on site – with our joinery, which on request also builds what the catalogue does not offer.',
|
'steps_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' => [
|
'steps' => [
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@
|
||||||
|
|
||||||
<flux:navlist.group :heading="__('CMS')" class="grid mb-4">
|
<flux:navlist.group :heading="__('CMS')" class="grid mb-4">
|
||||||
<flux:navlist.group expandable
|
<flux:navlist.group expandable
|
||||||
:expanded="request()->routeIs(['cms.dashboard', 'cms.content.index', 'cms.projects.index', 'cms.articles.index', 'cms.media.index'])"
|
:expanded="request()->routeIs(['cms.dashboard', 'cms.content.index', 'cms.references.index', 'cms.brands.index', 'cms.articles.index', 'cms.media.index'])"
|
||||||
heading="Website-Inhalte" class="grid">
|
heading="Website-Inhalte" class="grid">
|
||||||
<flux:navlist.item icon="rectangle-group" :href="route('cms.dashboard')"
|
<flux:navlist.item icon="rectangle-group" :href="route('cms.dashboard')"
|
||||||
:current="request()->routeIs('cms.dashboard')" wire:navigate>{{ __('Übersicht') }}
|
:current="request()->routeIs('cms.dashboard')" wire:navigate>{{ __('Übersicht') }}
|
||||||
|
|
@ -169,8 +169,12 @@
|
||||||
<flux:navlist.item icon="document-text" :href="route('cms.content.index')"
|
<flux:navlist.item icon="document-text" :href="route('cms.content.index')"
|
||||||
:current="request()->routeIs('cms.content.index')" wire:navigate>{{ __('Inhalte') }}
|
:current="request()->routeIs('cms.content.index')" wire:navigate>{{ __('Inhalte') }}
|
||||||
</flux:navlist.item>
|
</flux:navlist.item>
|
||||||
<flux:navlist.item icon="building-office" :href="route('cms.projects.index')"
|
{{-- Immobilien-Projekte (cms.projects.index) sind nicht freigegeben und bewusst nicht verlinkt (Nachtrag 13). --}}
|
||||||
:current="request()->routeIs('cms.projects.index')" wire:navigate>{{ __('Projekte') }}
|
<flux:navlist.item icon="photo" :href="route('cms.references.index')"
|
||||||
|
:current="request()->routeIs('cms.references.index')" wire:navigate>{{ __('Referenzen') }}
|
||||||
|
</flux:navlist.item>
|
||||||
|
<flux:navlist.item icon="tag" :href="route('cms.brands.index')"
|
||||||
|
:current="request()->routeIs('cms.brands.index')" wire:navigate>{{ __('Marken') }}
|
||||||
</flux:navlist.item>
|
</flux:navlist.item>
|
||||||
<flux:navlist.item icon="newspaper" :href="route('cms.articles.index')"
|
<flux:navlist.item icon="newspaper" :href="route('cms.articles.index')"
|
||||||
:current="request()->routeIs('cms.articles.index')" wire:navigate>{{ __('Magazin') }}
|
:current="request()->routeIs('cms.articles.index')" wire:navigate>{{ __('Magazin') }}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ state([
|
||||||
'gallery' => [],
|
'gallery' => [],
|
||||||
'website_url' => '',
|
'website_url' => '',
|
||||||
'showroom' => '',
|
'showroom' => '',
|
||||||
|
'originCity' => '',
|
||||||
|
'originDistance' => '',
|
||||||
|
'originRole' => '',
|
||||||
'is_published' => true,
|
'is_published' => true,
|
||||||
'order' => 0,
|
'order' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
@ -70,7 +73,7 @@ $brands = computed(
|
||||||
|
|
||||||
$openCreate = function () {
|
$openCreate = function () {
|
||||||
$this->editingId = null;
|
$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->sections = [];
|
||||||
$this->highlights = ['', '', ''];
|
$this->highlights = ['', '', ''];
|
||||||
$this->services = [['title' => '', 'description' => '']];
|
$this->services = [['title' => '', 'description' => '']];
|
||||||
|
|
@ -89,6 +92,9 @@ $loadLocaleFields = function (CmsBrand $brand, string $locale) {
|
||||||
$this->widgetLabel = $brand->getTranslation('widget_label', $locale) ?? '';
|
$this->widgetLabel = $brand->getTranslation('widget_label', $locale) ?? '';
|
||||||
$this->ctaTitle = $brand->getTranslation('cta_title', $locale) ?? '';
|
$this->ctaTitle = $brand->getTranslation('cta_title', $locale) ?? '';
|
||||||
$this->ctaText = $brand->getTranslation('cta_text', $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) {
|
$openEdit = function (int $id) {
|
||||||
|
|
@ -194,6 +200,9 @@ $save = function () {
|
||||||
'summary' => $this->summary,
|
'summary' => $this->summary,
|
||||||
'website_url' => $this->website_url,
|
'website_url' => $this->website_url,
|
||||||
'showroom' => $this->showroom,
|
'showroom' => $this->showroom,
|
||||||
|
'originCity' => $this->originCity,
|
||||||
|
'originDistance' => $this->originDistance,
|
||||||
|
'originRole' => $this->originRole,
|
||||||
'is_published' => $this->is_published,
|
'is_published' => $this->is_published,
|
||||||
'order' => $this->order,
|
'order' => $this->order,
|
||||||
], [
|
], [
|
||||||
|
|
@ -204,6 +213,9 @@ $save = function () {
|
||||||
'summary' => 'nullable|string|max:4000',
|
'summary' => 'nullable|string|max:4000',
|
||||||
'website_url' => 'nullable|url|max:500',
|
'website_url' => 'nullable|url|max:500',
|
||||||
'showroom' => 'nullable|string|max:255',
|
'showroom' => 'nullable|string|max:255',
|
||||||
|
'originCity' => 'nullable|string|max:255',
|
||||||
|
'originDistance' => 'nullable|string|max:255',
|
||||||
|
'originRole' => 'nullable|string|max:500',
|
||||||
'is_published' => 'boolean',
|
'is_published' => 'boolean',
|
||||||
'order' => 'integer|min:0',
|
'order' => 'integer|min:0',
|
||||||
], [
|
], [
|
||||||
|
|
@ -255,6 +267,11 @@ $save = function () {
|
||||||
$brand->setTranslation('cta_title', $this->editLocale, trim((string) $this->ctaTitle));
|
$brand->setTranslation('cta_title', $this->editLocale, trim((string) $this->ctaTitle));
|
||||||
$brand->setTranslation('cta_text', $this->editLocale, trim((string) $this->ctaText));
|
$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.
|
// normalizeSections() nimmt die Textblöcke entgegen und trennt sie an den Leerzeilen.
|
||||||
$brand->sections = CmsBrand::normalizeSections(collect($this->sections)->map(fn (array $s) => [
|
$brand->sections = CmsBrand::normalizeSections(collect($this->sections)->map(fn (array $s) => [
|
||||||
'title' => $s['title'] ?? '',
|
'title' => $s['title'] ?? '',
|
||||||
|
|
@ -365,6 +382,24 @@ $cancelForm = function () {
|
||||||
placeholder="Ausführlicher Text für die Markenübersicht. Leerzeile trennt Absätze." />
|
placeholder="Ausführlicher Text für die Markenübersicht. Leerzeile trennt Absätze." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Fertigungsort für „Aus der Region gefertigt“ (Startseite und /marken) --}}
|
||||||
|
<div class="mt-6">
|
||||||
|
<flux:heading size="sm" class="mb-1">Fertigungsort</flux:heading>
|
||||||
|
<flux:text class="mb-3 text-sm">
|
||||||
|
Erscheint in der Sektion „Aus der Region gefertigt“ auf der Startseite und unter /marken.
|
||||||
|
Ohne Ort bleibt die Marke dort weg. Die Position in der Karte ist fest hinterlegt.
|
||||||
|
</flux:text>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<flux:input wire:model="originCity" label="Ort ({{ strtoupper($editLocale) }})" placeholder="Bünde" />
|
||||||
|
<flux:input wire:model="originDistance" label="Entfernung von Bielefeld ({{ strtoupper($editLocale) }})"
|
||||||
|
placeholder="rund 30 km" />
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<flux:input wire:model="originRole" label="Rolle des Standorts ({{ strtoupper($editLocale) }})"
|
||||||
|
placeholder="Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Drei Merkmale für die Übersichtsseite --}}
|
{{-- Drei Merkmale für die Übersichtsseite --}}
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Services\CmsFluxEditorHtmlTransformer;
|
use App\Services\CmsFluxEditorHtmlTransformer;
|
||||||
|
use App\Services\CmsPageTree;
|
||||||
|
use Database\Seeders\CmsContentSeeder;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
use FluxCms\Core\Models\CmsContent;
|
use FluxCms\Core\Models\CmsContent;
|
||||||
use FluxCms\Core\Models\CmsMedia;
|
use FluxCms\Core\Models\CmsMedia;
|
||||||
|
|
@ -56,11 +58,21 @@ if (! function_exists('_cmsFieldLooksLikeImage')) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! function_exists('_cmsFieldLabel')) {
|
||||||
|
function _cmsFieldLabel(string $field, ?string $group = null, ?string $key = null): string
|
||||||
|
{
|
||||||
|
return app(CmsPageTree::class)->fieldLabel($field, $group, $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
layout('components.layouts.app');
|
layout('components.layouts.app');
|
||||||
title('CMS Inhalte');
|
title('CMS Inhalte');
|
||||||
|
|
||||||
state([
|
state([
|
||||||
'selectedGroup' => null,
|
'selectedGroup' => null,
|
||||||
|
'selectedKey' => null,
|
||||||
|
'showPreview' => true,
|
||||||
|
'previewVersion' => 0,
|
||||||
'search' => '',
|
'search' => '',
|
||||||
'editingId' => null,
|
'editingId' => null,
|
||||||
'editingField' => null,
|
'editingField' => null,
|
||||||
|
|
@ -112,12 +124,21 @@ $groups = computed(fn () => CmsContent::query()
|
||||||
->pluck('count', 'group')
|
->pluck('count', 'group')
|
||||||
->toArray());
|
->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 () {
|
$flatContents = computed(function () {
|
||||||
if (! $this->selectedGroup) {
|
if (! $this->selectedGroup) {
|
||||||
return collect();
|
return collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
$contents = CmsContent::forGroup($this->selectedGroup)
|
$contents = CmsContent::forGroup($this->selectedGroup)
|
||||||
|
->when($this->selectedKey, fn ($q, $key) => $q->where('key', $key))
|
||||||
->orderBy('order')
|
->orderBy('order')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|
@ -186,8 +207,40 @@ $selectGroup = function (string $group) {
|
||||||
$this->editMediaId = null;
|
$this->editMediaId = null;
|
||||||
$this->editingFieldType = 'text';
|
$this->editingFieldType = 'text';
|
||||||
$this->selectedGroup = $group;
|
$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) {
|
$startFieldEdit = function (int $contentId, ?string $fieldKey = null) {
|
||||||
$content = CmsContent::find($contentId);
|
$content = CmsContent::find($contentId);
|
||||||
if (! $content) {
|
if (! $content) {
|
||||||
|
|
@ -375,6 +428,7 @@ $saveEdit = function () {
|
||||||
$this->editValue = '';
|
$this->editValue = '';
|
||||||
$this->editingFieldType = 'text';
|
$this->editingFieldType = 'text';
|
||||||
$this->editMediaId = null;
|
$this->editMediaId = null;
|
||||||
|
$this->previewVersion++;
|
||||||
|
|
||||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
||||||
};
|
};
|
||||||
|
|
@ -439,6 +493,7 @@ $saveJsonModal = function () {
|
||||||
$this->editingId = null;
|
$this->editingId = null;
|
||||||
$this->editingField = null;
|
$this->editingField = null;
|
||||||
$this->jsonItems = [];
|
$this->jsonItems = [];
|
||||||
|
$this->previewVersion++;
|
||||||
|
|
||||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
||||||
};
|
};
|
||||||
|
|
@ -461,9 +516,8 @@ $cancelJsonModal = function () {
|
||||||
?>
|
?>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$cmsGroupLabels = [
|
$section = $this->currentSection;
|
||||||
'legal' => 'Rechtliches',
|
$pageTitle = $selectedGroup ? ($this->tree[$section['page'] ?? '']['title'] ?? $selectedGroup) : null;
|
||||||
];
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -474,30 +528,85 @@ $cancelJsonModal = function () {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 xl:grid-cols-12">
|
||||||
<div class="lg:col-span-1">
|
{{-- Seitenbaum: Seiten in Menü-Reihenfolge, Sektionen von oben nach unten (config/cms_pages.php) --}}
|
||||||
<flux:card>
|
<div class="xl:col-span-3">
|
||||||
<flux:heading size="sm" class="mb-3">Seiten / Gruppen</flux:heading>
|
<flux:card class="space-y-5">
|
||||||
<div class="flex flex-col gap-1">
|
@foreach ($this->tree as $pageKey => $page)
|
||||||
@foreach ($this->groups as $group => $count)
|
<div wire:key="cms-page-{{ $pageKey }}">
|
||||||
<button wire:click="selectGroup('{{ $group }}')"
|
<div class="mb-1 flex items-center justify-between px-3">
|
||||||
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedGroup === $group ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $page['title'] }}</span>
|
||||||
<span>{{ $cmsGroupLabels[$group] ?? $group }}</span>
|
@if ($page['path'])
|
||||||
<flux:badge size="sm">{{ $count }}</flux:badge>
|
<span class="text-xs text-zinc-400">{{ $page['path'] }}</span>
|
||||||
</button>
|
@endif
|
||||||
@endforeach
|
</div>
|
||||||
</div>
|
|
||||||
|
@if ($pageKey === \App\Services\CmsPageTree::UNLISTED)
|
||||||
|
<details class="px-3">
|
||||||
|
<summary class="cursor-pointer py-1 text-sm text-zinc-500">{{ count($page['sections']) }} Sektionen stillgelegter Seiten</summary>
|
||||||
|
<div class="mt-1 flex flex-col gap-0.5">
|
||||||
|
@foreach ($page['sections'] as $item)
|
||||||
|
<button wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
|
||||||
|
wire:click="selectSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
|
||||||
|
class="rounded-lg px-2 py-1 text-left text-xs transition-colors {{ $selectedGroup === $item['group'] && $selectedKey === $item['key'] ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
||||||
|
{{ $item['title'] }}
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
@foreach ($page['sections'] as $index => $item)
|
||||||
|
@php $isActive = $selectedGroup === $item['group'] && $selectedKey === $item['key']; @endphp
|
||||||
|
@if ($item['content_id'])
|
||||||
|
<button wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
|
||||||
|
wire:click="selectSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
|
||||||
|
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm transition-colors {{ $isActive ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
||||||
|
<span class="w-5 shrink-0 text-xs text-zinc-400">{{ $index + 1 }}</span>
|
||||||
|
<span class="truncate">{{ $item['title'] }}</span>
|
||||||
|
</button>
|
||||||
|
@else
|
||||||
|
<div wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
|
||||||
|
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-zinc-400">
|
||||||
|
<span class="w-5 shrink-0 text-xs">{{ $index + 1 }}</span>
|
||||||
|
<span class="truncate">{{ $item['title'] }}</span>
|
||||||
|
<flux:button size="xs" variant="ghost" icon="plus" class="ml-auto"
|
||||||
|
wire:click="createSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
|
||||||
|
title="Aus den Texten der Website anlegen" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
</flux:card>
|
</flux:card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:col-span-3" wire:key="cms-content-panel-{{ $selectedGroup ?? 'empty' }}">
|
{{-- Editor --}}
|
||||||
|
<div class="{{ $showPreview && $this->previewUrl ? 'xl:col-span-4' : 'xl:col-span-9' }}"
|
||||||
|
wire:key="cms-content-panel-{{ $selectedGroup ?? 'empty' }}-{{ $selectedKey ?? 'all' }}">
|
||||||
@if ($selectedGroup)
|
@if ($selectedGroup)
|
||||||
<flux:card>
|
<flux:card>
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||||
<flux:heading size="lg">{{ $cmsGroupLabels[$selectedGroup] ?? $selectedGroup }}</flux:heading>
|
<div class="min-w-0">
|
||||||
|
<flux:heading size="lg">{{ $section['title'] ?? ($selectedKey ?? $selectedGroup) }}</flux:heading>
|
||||||
|
@if (! empty($section['description']))
|
||||||
|
<flux:text class="mt-1 text-sm">{{ $section['description'] }}</flux:text>
|
||||||
|
@endif
|
||||||
|
<div class="mt-1 flex items-center gap-2 text-xs text-zinc-400">
|
||||||
|
@if ($pageTitle && $pageTitle !== ($section['title'] ?? null))
|
||||||
|
<span>{{ $pageTitle }}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
@endif
|
||||||
|
<code>{{ $selectedGroup }}{{ $selectedKey ? '/' . $selectedKey : '' }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
|
@if (! $selectedKey)
|
||||||
icon="magnifying-glass" class="w-48" />
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
|
||||||
|
icon="magnifying-glass" class="w-48" />
|
||||||
|
@endif
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||||
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||||
|
|
@ -506,13 +615,17 @@ $cancelJsonModal = function () {
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
@if ($this->previewUrl)
|
||||||
|
<flux:button size="xs" variant="ghost" :icon="$showPreview ? 'eye-slash' : 'eye'"
|
||||||
|
wire:click="togglePreview">{{ $showPreview ? 'Vorschau aus' : 'Vorschau' }}</flux:button>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php $lastSection = null; @endphp
|
@php $lastSection = null; @endphp
|
||||||
<div class="divide-y dark:divide-zinc-700">
|
<div class="divide-y dark:divide-zinc-700">
|
||||||
@forelse ($this->flatContents as $row)
|
@forelse ($this->flatContents as $row)
|
||||||
@if ($row->section_key !== $lastSection)
|
@if (! $selectedKey && $row->section_key !== $lastSection)
|
||||||
@php $lastSection = $row->section_key; @endphp
|
@php $lastSection = $row->section_key; @endphp
|
||||||
<div class="bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
|
<div class="bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $row->section_key }}</span>
|
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $row->section_key }}</span>
|
||||||
|
|
@ -526,18 +639,23 @@ $cancelJsonModal = function () {
|
||||||
$editKey = $row->field_key !== null
|
$editKey = $row->field_key !== null
|
||||||
? $row->content_id . ",'" . $row->field_key . "'"
|
? $row->content_id . ",'" . $row->field_key . "'"
|
||||||
: $row->content_id . ',null';
|
: $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
|
@endphp
|
||||||
|
|
||||||
<div wire:key="row-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}" class="py-3 px-1">
|
<div wire:key="row-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}" class="py-3 px-1">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="mb-1 flex items-center gap-2">
|
<div class="mb-1 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-zinc-800 dark:text-zinc-100">{{ $fieldLabel }}</span>
|
||||||
<code class="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{{ $row->field_key ?? $row->section_key }}</code>
|
<code class="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{{ $row->field_key ?? $row->section_key }}</code>
|
||||||
@php
|
@php
|
||||||
$typeColors = ['html' => 'amber', 'legal_html' => 'blue', 'image' => 'green', 'json' => 'violet', 'link' => 'rose'];
|
$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';
|
$badgeColor = $typeColors[$row->type] ?? 'zinc';
|
||||||
@endphp
|
@endphp
|
||||||
<flux:badge size="sm" :color="$badgeColor">{{ $row->type }}</flux:badge>
|
<flux:badge size="sm" :color="$badgeColor">{{ $typeLabels[$row->type] ?? $row->type }}</flux:badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -643,12 +761,31 @@ $cancelJsonModal = function () {
|
||||||
<flux:card>
|
<flux:card>
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||||
<flux:heading>Seite auswählen</flux:heading>
|
<flux:heading>Sektion auswählen</flux:heading>
|
||||||
<flux:text>Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten.</flux:text>
|
<flux:text>Links stehen die Seiten der Website mit ihren Abschnitten von oben nach unten. Wähle einen Abschnitt, um seine Texte und Bilder zu bearbeiten.</flux:text>
|
||||||
</div>
|
</div>
|
||||||
</flux:card>
|
</flux:card>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Vorschau: die echte Website im Rahmen, springt zur gewählten Sektion und lädt nach dem Speichern neu --}}
|
||||||
|
@if ($selectedGroup && $showPreview && $this->previewUrl)
|
||||||
|
<div class="xl:col-span-5">
|
||||||
|
<flux:card class="sticky top-4 overflow-hidden p-0!">
|
||||||
|
<div class="flex items-center justify-between gap-2 border-b border-zinc-200 px-4 py-2 dark:border-zinc-700">
|
||||||
|
<div class="min-w-0 truncate text-xs text-zinc-500">
|
||||||
|
Vorschau · {{ $section['path'] ?? '' }}{{ ! empty($section['anchor']) ? '#' . $section['anchor'] : '' }}
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<flux:button size="xs" variant="ghost" icon="arrow-path" wire:click="reloadPreview" title="Neu laden" />
|
||||||
|
<flux:button size="xs" variant="ghost" icon="arrow-top-right-on-square" :href="$this->previewUrl" target="_blank" title="In neuem Tab öffnen" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe wire:key="cms-preview-{{ md5($this->previewUrl) }}" src="{{ $this->previewUrl }}"
|
||||||
|
class="h-[78vh] w-full bg-white" title="Vorschau der Website" loading="lazy"></iframe>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- JSON-Editor: kein flux:modal — vermeidet ReferenceError fluxModal bei jedem Livewire-Render (z. B. bei legal_html). --}}
|
{{-- 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
|
wire:click.stop
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ $jsonEditingKey }}</flux:heading>
|
<flux:heading size="lg">{{ $section['title'] ?? $selectedGroup }}: {{ $editingField !== null ? _cmsFieldLabel($editingField, $selectedGroup, $selectedKey) : ($section['title'] ?? $jsonEditingKey) }}</flux:heading>
|
||||||
<flux:text class="mt-1">
|
<flux:text class="mt-1">
|
||||||
|
<code class="text-xs">{{ $jsonEditingKey }}</code> ·
|
||||||
{{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }}
|
{{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }}
|
||||||
({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }}
|
({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }}
|
||||||
</flux:text>
|
</flux:text>
|
||||||
|
|
@ -711,7 +849,7 @@ $cancelJsonModal = function () {
|
||||||
<div class="flex items-end gap-3">
|
<div class="flex items-end gap-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<flux:select wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
<flux:select wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||||
variant="listbox" searchable label="{{ ucfirst($field) }}"
|
variant="listbox" searchable label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}"
|
||||||
placeholder="Icon auswählen...">
|
placeholder="Icon auswählen...">
|
||||||
<flux:select.option value="">— Kein Icon —</flux:select.option>
|
<flux:select.option value="">— Kein Icon —</flux:select.option>
|
||||||
@foreach ($this->availableIcons as $iconName)
|
@foreach ($this->availableIcons as $iconName)
|
||||||
|
|
@ -728,7 +866,7 @@ $cancelJsonModal = function () {
|
||||||
</div>
|
</div>
|
||||||
@elseif ($isImageField)
|
@elseif ($isImageField)
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ ucfirst($field) }}</label>
|
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}</label>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
|
||||||
@if (is_string($fieldValue) && $fieldValue !== '')
|
@if (is_string($fieldValue) && $fieldValue !== '')
|
||||||
<div class="h-20 w-32 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
<div class="h-20 w-32 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
|
@ -751,18 +889,18 @@ $cancelJsonModal = function () {
|
||||||
@elseif ($isRichText)
|
@elseif ($isRichText)
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||||
label="{{ ucfirst($field) }}" toolbar="bold italic highlight"
|
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}" toolbar="bold italic highlight"
|
||||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||||
</div>
|
</div>
|
||||||
@elseif ($isNestedJson)
|
@elseif ($isNestedJson)
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||||
label="{{ ucfirst($field) }} (JSON)" rows="3"
|
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }} (JSON)" rows="3"
|
||||||
class="font-mono text-xs" />
|
class="font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||||
label="{{ ucfirst($field) }}" />
|
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}" />
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use App\Models\CmsArticle;
|
use App\Models\CmsArticle;
|
||||||
use App\Models\CmsBrand;
|
use App\Models\CmsBrand;
|
||||||
use App\Models\CmsProject;
|
use App\Models\CmsReference;
|
||||||
use FluxCms\Core\Models\CmsContent;
|
use FluxCms\Core\Models\CmsContent;
|
||||||
use FluxCms\Core\Models\CmsMedia;
|
use FluxCms\Core\Models\CmsMedia;
|
||||||
use function Livewire\Volt\{layout, title, computed};
|
use function Livewire\Volt\{layout, title, computed};
|
||||||
|
|
@ -13,8 +13,8 @@ title('CMS Dashboard');
|
||||||
$stats = computed(fn () => [
|
$stats = computed(fn () => [
|
||||||
'contents' => CmsContent::count(),
|
'contents' => CmsContent::count(),
|
||||||
'groups' => CmsContent::distinct()->pluck('group')->count(),
|
'groups' => CmsContent::distinct()->pluck('group')->count(),
|
||||||
'projects' => CmsProject::count(),
|
'references' => CmsReference::count(),
|
||||||
'projects_published' => CmsProject::published()->count(),
|
'references_published' => CmsReference::published()->count(),
|
||||||
'articles' => CmsArticle::count(),
|
'articles' => CmsArticle::count(),
|
||||||
'articles_published' => CmsArticle::published()->count(),
|
'articles_published' => CmsArticle::published()->count(),
|
||||||
'brands' => CmsBrand::count(),
|
'brands' => CmsBrand::count(),
|
||||||
|
|
@ -27,7 +27,7 @@ $stats = computed(fn () => [
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<flux:heading size="xl">CMS</flux:heading>
|
<flux:heading size="xl">CMS</flux:heading>
|
||||||
<flux:text class="mt-1">Inhalte, Projekte und Medien verwalten.</flux:text>
|
<flux:text class="mt-1">Inhalte, Referenzen, Marken, Magazin und Medien verwalten.</flux:text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
@ -43,13 +43,13 @@ $stats = computed(fn () => [
|
||||||
</flux:card>
|
</flux:card>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{{ route('cms.projects.index') }}" wire:navigate>
|
<a href="{{ route('cms.references.index') }}" wire:navigate>
|
||||||
<flux:card class="hover:border-emerald-500 transition-colors">
|
<flux:card class="hover:border-emerald-500 transition-colors">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<flux:icon name="building-office" class="text-emerald-500" />
|
<flux:icon name="photo" class="text-emerald-500" />
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ $this->stats['projects'] }}</flux:heading>
|
<flux:heading size="lg">{{ $this->stats['references'] }}</flux:heading>
|
||||||
<flux:text class="text-sm">Projekte ({{ $this->stats['projects_published'] }} veröffentlicht)</flux:text>
|
<flux:text class="text-sm">Referenzen ({{ $this->stats['references_published'] }} veröffentlicht)</flux:text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</flux:card>
|
</flux:card>
|
||||||
|
|
@ -107,7 +107,7 @@ $stats = computed(fn () => [
|
||||||
</p>
|
</p>
|
||||||
<ul class="mt-2 ml-5 list-disc space-y-1">
|
<ul class="mt-2 ml-5 list-disc space-y-1">
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte</strong> – Texte, Abschnitte und strukturierte Daten der Website (z. B. Startseite, Netzwerk, rechtliche Texte). Inhalte sind in <em>Gruppen</em> organisiert, sodass zusammengehörige Felder gebündelt bearbeitet werden können.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte</strong> – Texte, Abschnitte und strukturierte Daten der Website (z. B. Startseite, Netzwerk, rechtliche Texte). Inhalte sind in <em>Gruppen</em> organisiert, sodass zusammengehörige Felder gebündelt bearbeitet werden können.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Projekte</strong> – Immobilien-Referenzobjekte mit Bildern, Preisen und Beschreibungen. Jedes Projekt kann als Entwurf angelegt und später veröffentlicht werden.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Referenzen</strong> – Realisierte Projekte für die Seite /referenzen mit Titelbild, weiteren Bildern und den beteiligten Marken. Nur ausgeführte Aufträge; Entwürfe gehören in die Galerie der Marke.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Magazin</strong> – Redaktionelle Artikel (z. B. über Dubai-Immobilien, Supply-Chain oder Einrichtung). Artikel unterstützen Kategorien, Autoren und Lesezeiten.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Magazin</strong> – Redaktionelle Artikel (z. B. über Dubai-Immobilien, Supply-Chain oder Einrichtung). Artikel unterstützen Kategorien, Autoren und Lesezeiten.</li>
|
||||||
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien</strong> – Zentrale Bild- und Dateiverwaltung. Alle Uploads landen hier und können in jedem Modul wiederverwendet werden.</li>
|
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien</strong> – Zentrale Bild- und Dateiverwaltung. Alle Uploads landen hier und können in jedem Modul wiederverwendet werden.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
367
resources/views/livewire/admin/cms/references-index.blade.php
Normal file
367
resources/views/livewire/admin/cms/references-index.blade.php
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\CmsBrand;
|
||||||
|
use App\Models\CmsReference;
|
||||||
|
use Flux\Flux;
|
||||||
|
use FluxCms\Core\Models\CmsMedia;
|
||||||
|
|
||||||
|
use function Livewire\Volt\{computed, layout, on, state, title};
|
||||||
|
|
||||||
|
layout('components.layouts.app');
|
||||||
|
title('Referenzen verwalten');
|
||||||
|
|
||||||
|
state([
|
||||||
|
'search' => '',
|
||||||
|
'showForm' => false,
|
||||||
|
'editingId' => null,
|
||||||
|
'editLocale' => 'de',
|
||||||
|
'referenceTitle' => '',
|
||||||
|
'description' => '',
|
||||||
|
'location' => '',
|
||||||
|
'year' => '',
|
||||||
|
'image' => '',
|
||||||
|
'gallery' => [],
|
||||||
|
'brandSlugs' => [],
|
||||||
|
'is_published' => true,
|
||||||
|
'order' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
on(['media-selected' => function ($mediaId, $url, $field) {
|
||||||
|
$media = $mediaId ? CmsMedia::find($mediaId) : null;
|
||||||
|
|
||||||
|
if (! $media) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field === 'reference_image') {
|
||||||
|
$this->image = $media->filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($field, 'reference_gallery:')) {
|
||||||
|
$index = (int) substr($field, strlen('reference_gallery:'));
|
||||||
|
|
||||||
|
if (isset($this->gallery[$index])) {
|
||||||
|
$this->gallery[$index]['image'] = $media->filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]);
|
||||||
|
|
||||||
|
$references = computed(
|
||||||
|
fn () => CmsReference::query()
|
||||||
|
->when($this->search, fn ($q, $s) => $q->where('location', 'like', "%{$s}%")
|
||||||
|
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(title, '$.de')) LIKE ?", ["%{$s}%"]))
|
||||||
|
->ordered()
|
||||||
|
->get(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$brands = computed(fn () => CmsBrand::query()->ordered()->get());
|
||||||
|
|
||||||
|
$openCreate = function () {
|
||||||
|
$this->editingId = null;
|
||||||
|
$this->reset(['referenceTitle', 'description', 'location', 'year', 'image']);
|
||||||
|
$this->gallery = [];
|
||||||
|
$this->brandSlugs = [];
|
||||||
|
$this->is_published = true;
|
||||||
|
$this->order = (CmsReference::max('order') ?? 0) + 1;
|
||||||
|
$this->showForm = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$loadLocaleFields = function (CmsReference $reference, string $locale) {
|
||||||
|
$this->referenceTitle = $reference->getTranslation('title', $locale) ?? '';
|
||||||
|
$this->description = $reference->getTranslation('description', $locale) ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
$openEdit = function (int $id) {
|
||||||
|
$reference = CmsReference::find($id);
|
||||||
|
|
||||||
|
if (! $reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->editingId = $id;
|
||||||
|
$this->location = $reference->location ?? '';
|
||||||
|
$this->year = $reference->year ?? '';
|
||||||
|
$this->image = $reference->image ?? '';
|
||||||
|
$this->gallery = CmsReference::normalizeGallery($reference->gallery);
|
||||||
|
$this->brandSlugs = array_values(array_map('strval', $reference->brand_slugs ?? []));
|
||||||
|
$this->is_published = $reference->is_published;
|
||||||
|
$this->order = $reference->order ?? 0;
|
||||||
|
|
||||||
|
$this->loadLocaleFields($reference, $this->editLocale);
|
||||||
|
$this->showForm = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$switchLocale = function (string $locale) {
|
||||||
|
$this->editLocale = $locale;
|
||||||
|
|
||||||
|
if ($this->editingId) {
|
||||||
|
$reference = CmsReference::find($this->editingId);
|
||||||
|
|
||||||
|
if ($reference) {
|
||||||
|
$this->loadLocaleFields($reference, $locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$addGalleryImage = fn () => $this->gallery[] = ['image' => '', 'caption' => ''];
|
||||||
|
|
||||||
|
$removeGalleryImage = function (int $index) {
|
||||||
|
unset($this->gallery[$index]);
|
||||||
|
$this->gallery = array_values($this->gallery);
|
||||||
|
};
|
||||||
|
|
||||||
|
$moveGalleryImage = function (int $index, int $direction) {
|
||||||
|
$target = $index + $direction;
|
||||||
|
|
||||||
|
if (! isset($this->gallery[$index], $this->gallery[$target])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$this->gallery[$index], $this->gallery[$target]] = [$this->gallery[$target], $this->gallery[$index]];
|
||||||
|
};
|
||||||
|
|
||||||
|
$save = function () {
|
||||||
|
$validated = validator([
|
||||||
|
'referenceTitle' => $this->referenceTitle,
|
||||||
|
'description' => $this->description,
|
||||||
|
'location' => $this->location,
|
||||||
|
'year' => $this->year,
|
||||||
|
'image' => $this->image,
|
||||||
|
'brandSlugs' => $this->brandSlugs,
|
||||||
|
'is_published' => $this->is_published,
|
||||||
|
'order' => $this->order,
|
||||||
|
], [
|
||||||
|
'referenceTitle' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string|max:4000',
|
||||||
|
'location' => 'nullable|string|max:255',
|
||||||
|
'year' => 'nullable|string|max:20',
|
||||||
|
'image' => 'nullable|string|max:500',
|
||||||
|
'brandSlugs' => 'array',
|
||||||
|
'brandSlugs.*' => 'string|exists:cms_brands,slug',
|
||||||
|
'is_published' => 'boolean',
|
||||||
|
'order' => 'integer|min:0',
|
||||||
|
], [
|
||||||
|
'referenceTitle.required' => 'Bitte einen Titel angeben.',
|
||||||
|
'brandSlugs.*.exists' => 'Eine der gewählten Marken gibt es nicht mehr.',
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$reference = $this->editingId
|
||||||
|
? CmsReference::findOrFail($this->editingId)
|
||||||
|
: CmsReference::query()->make();
|
||||||
|
|
||||||
|
$reference->setTranslation('title', $this->editLocale, trim($validated['referenceTitle']));
|
||||||
|
$reference->setTranslation('description', $this->editLocale, trim((string) ($validated['description'] ?? '')));
|
||||||
|
$reference->location = trim((string) ($validated['location'] ?? '')) ?: null;
|
||||||
|
$reference->year = trim((string) ($validated['year'] ?? '')) ?: null;
|
||||||
|
$reference->image = trim((string) ($validated['image'] ?? '')) ?: null;
|
||||||
|
$reference->gallery = CmsReference::normalizeGallery($this->gallery);
|
||||||
|
$reference->brand_slugs = array_values(array_unique($validated['brandSlugs']));
|
||||||
|
$reference->is_published = $validated['is_published'];
|
||||||
|
$reference->order = $validated['order'];
|
||||||
|
$reference->save();
|
||||||
|
|
||||||
|
$this->showForm = false;
|
||||||
|
$this->editingId = null;
|
||||||
|
|
||||||
|
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Referenz wurde erfolgreich gespeichert.');
|
||||||
|
};
|
||||||
|
|
||||||
|
$togglePublished = function (int $id) {
|
||||||
|
$reference = CmsReference::findOrFail($id);
|
||||||
|
$reference->update(['is_published' => ! $reference->is_published]);
|
||||||
|
|
||||||
|
Flux::toast(heading: 'Status geändert', text: $reference->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||||
|
};
|
||||||
|
|
||||||
|
$deleteReference = function (int $id) {
|
||||||
|
CmsReference::findOrFail($id)->delete();
|
||||||
|
|
||||||
|
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Referenz wurde entfernt.');
|
||||||
|
};
|
||||||
|
|
||||||
|
$cancelForm = function () {
|
||||||
|
$this->showForm = false;
|
||||||
|
$this->editingId = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="xl">Referenzen</flux:heading>
|
||||||
|
<flux:text class="mt-1">Realisierte Projekte für die Seite /referenzen. Nur ausgeführte Aufträge – Entwürfe und Visualisierungen gehören in die Galerie der jeweiligen Marke.</flux:text>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||||
|
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||||
|
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||||
|
@endforeach
|
||||||
|
<flux:button variant="primary" icon="plus" wire:click="openCreate">Neue Referenz</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:input wire:model.live.debounce.300ms="search" placeholder="Referenz suchen..." icon="magnifying-glass"
|
||||||
|
size="sm" class="w-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($showForm)
|
||||||
|
<flux:card class="mb-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<flux:heading size="lg">{{ $editingId ? 'Referenz bearbeiten' : 'Neue Referenz' }}</flux:heading>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
|
||||||
|
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
|
||||||
|
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<flux:input wire:model="referenceTitle" label="Titel ({{ strtoupper($editLocale) }})"
|
||||||
|
placeholder="Ankleide unter der Dachschräge" />
|
||||||
|
@error('referenceTitle') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
<flux:input wire:model="location" label="Ort (optional)" placeholder="Bielefeld" />
|
||||||
|
<flux:input wire:model="year" label="Jahr (optional)" placeholder="2025" />
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<flux:textarea wire:model="description" label="Beschreibung ({{ strtoupper($editLocale) }})" rows="4"
|
||||||
|
placeholder="Was wurde gebaut, was war die Aufgabe, was ist das Besondere?" />
|
||||||
|
</div>
|
||||||
|
<flux:input wire:model="order" label="Sortierung" type="number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Beteiligte Marken --}}
|
||||||
|
<div class="mt-6">
|
||||||
|
<flux:heading size="sm" class="mb-1">Beteiligte Marken</flux:heading>
|
||||||
|
<flux:text class="mb-3 text-sm">Jede gewählte Marke wird unter der Referenz genannt und auf ihre Markenseite verlinkt.</flux:text>
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
@foreach ($this->brands as $brand)
|
||||||
|
<flux:checkbox wire:model="brandSlugs" value="{{ $brand->slug }}"
|
||||||
|
label="{{ $brand->getTranslation('name', 'de') }}" wire:key="brand-check-{{ $brand->slug }}" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@error('brandSlugs.*') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Titelbild --}}
|
||||||
|
<div class="mt-6">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Titelbild</label>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
@if ($image)
|
||||||
|
<div class="h-20 w-28 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||||
|
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<livewire:admin.cms.media-picker :value="null" field="reference_image" type="image" profile="card"
|
||||||
|
label="Bild wählen" :key="'reference-img-' . ($editingId ?? 'new')" />
|
||||||
|
<flux:input wire:model="image" size="sm" placeholder="Oder Dateiname manuell..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Weitere Bilder --}}
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<flux:heading size="sm">Weitere Bilder</flux:heading>
|
||||||
|
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addGalleryImage">Bild hinzufügen</flux:button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (empty($gallery))
|
||||||
|
<p class="text-sm text-zinc-400">Noch keine weiteren Bilder.</p>
|
||||||
|
@else
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
@foreach ($gallery as $i => $item)
|
||||||
|
<div wire:key="reference-gallery-{{ $i }}" class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
|
||||||
|
@if (!empty($item['image']))
|
||||||
|
<div class="mb-2 h-24 w-full overflow-hidden rounded-md">
|
||||||
|
<img src="{{ media_url($item['image']) }}" class="h-full w-full object-cover" alt="" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<flux:input wire:model="gallery.{{ $i }}.caption" size="sm" class="mb-2"
|
||||||
|
placeholder="Bildunterschrift (optional)" />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<livewire:admin.cms.media-picker :value="null" field="reference_gallery:{{ $i }}"
|
||||||
|
type="image" profile="card" label="Wählen"
|
||||||
|
:key="'reference-gallery-' . ($editingId ?? 'new') . '-' . $i" />
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<flux:button size="xs" variant="ghost" icon="chevron-left" wire:click="moveGalleryImage({{ $i }}, -1)" />
|
||||||
|
<flux:button size="xs" variant="ghost" icon="chevron-right" wire:click="moveGalleryImage({{ $i }}, 1)" />
|
||||||
|
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeGalleryImage({{ $i }})" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:input wire:model="gallery.{{ $i }}.image" size="sm" class="mt-2"
|
||||||
|
placeholder="Oder Dateiname manuell..." />
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<flux:switch wire:model="is_published" label="Veröffentlicht" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||||
|
<flux:button variant="ghost" wire:click="cancelForm">Abbrechen</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<flux:card>
|
||||||
|
<div class="divide-y dark:divide-zinc-700">
|
||||||
|
@forelse ($this->references as $reference)
|
||||||
|
<div wire:key="reference-{{ $reference->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||||
|
@if ($reference->image)
|
||||||
|
<div class="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||||
|
<img src="{{ media_url($reference->image) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex h-12 w-20 shrink-0 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||||
|
<x-heroicon-o-photo class="h-6 w-6 text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate font-medium text-zinc-800 dark:text-zinc-200">
|
||||||
|
{{ $reference->getTranslation('title', $editLocale) ?: $reference->getTranslation('title', 'de') }}
|
||||||
|
</span>
|
||||||
|
@foreach ($reference->brand_slugs ?? [] as $slug)
|
||||||
|
<flux:badge size="sm" color="blue">{{ $slug }}</flux:badge>
|
||||||
|
@endforeach
|
||||||
|
@unless ($reference->is_published)
|
||||||
|
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||||
|
@endunless
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-zinc-500">
|
||||||
|
{{ $reference->location ?: '–' }} · {{ $reference->year ?: '–' }} · {{ count(CmsReference::normalizeGallery($reference->gallery)) + ($reference->image ? 1 : 0) }} Bilder
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
|
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $reference->id }})" />
|
||||||
|
<flux:button size="sm" variant="ghost" :icon="$reference->is_published ? 'eye' : 'eye-slash'"
|
||||||
|
wire:click="togglePublished({{ $reference->id }})" />
|
||||||
|
<flux:button size="sm" variant="ghost" icon="trash"
|
||||||
|
wire:click="deleteReference({{ $reference->id }})"
|
||||||
|
wire:confirm="Referenz wirklich löschen?" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="py-12 text-center">
|
||||||
|
<x-heroicon-o-photo class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||||
|
<flux:heading>Keine Referenzen</flux:heading>
|
||||||
|
<flux:text>Solange hier nichts steht, zeigt /referenzen die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien.</flux:text>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</flux:card>
|
||||||
|
</div>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding flex items-center relative border-b border-border/30">
|
<section id="seitenkopf" class="section-padding flex items-center relative border-b border-border/30">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-[hsl(var(--hero-container))] rounded-[20px] w-[95%]">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-[hsl(var(--hero-container))] rounded-[20px] w-[95%]">
|
||||||
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
<div class="grid lg:grid-cols-2 gap-16 items-center">
|
||||||
<div class="space-y-8 slide-right delay-200">
|
<div class="space-y-8 slide-right delay-200">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="{{ $bg }} text-secondary-foreground section-padding">
|
<section id="cta" class="{{ $bg }} text-secondary-foreground section-padding">
|
||||||
<div class="container-narrow text-center">
|
<div class="container-narrow text-center">
|
||||||
<div class="spacing-section">
|
<div class="spacing-section">
|
||||||
<h2 class="text-section-title text-white slide-up delay-300">
|
<h2 class="text-section-title text-white slide-up delay-300">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{{-- Livewire braucht ein Wurzelelement. Solange kein Display veröffentlicht ist, bleibt es leer –
|
{{-- Livewire braucht ein Wurzelelement. Solange kein Display veröffentlicht ist, bleibt es leer –
|
||||||
lieber gar keine Sektion als ein Rahmen mit ewigem Ladebalken. --}}
|
lieber gar keine Sektion als ein Rahmen mit ewigem Ladebalken. --}}
|
||||||
<div>
|
<div id="displays">
|
||||||
@if ($displayId !== null)
|
@if ($displayId !== null)
|
||||||
<section class="section-padding {{ $bg }}">
|
<section class="section-padding {{ $bg }}">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
@php($faqCounter = 0)
|
@php($faqCounter = 0)
|
||||||
<div x-data="{ openIndex: null }">
|
<div x-data="{ openIndex: null }">
|
||||||
<section class="section-padding {{ $bg }}">
|
<section id="faq" class="section-padding {{ $bg }}">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
{{-- Section Title --}}
|
{{-- Section Title --}}
|
||||||
<div class="text-center mb-16">
|
<div class="text-center mb-16">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="py-8 bg-background border-b border-border/30">
|
<section id="gruender" class="py-8 bg-background border-b border-border/30">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex flex-col sm:flex-row items-center gap-6 sm:gap-8">
|
<div class="flex flex-col sm:flex-row items-center gap-6 sm:gap-8">
|
||||||
@if(isset($content['image']))
|
@if(isset($content['image']))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding flex items-center relative border-b border-border/30">
|
<section id="hero" class="section-padding flex items-center relative border-b border-border/30">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-hero-container rounded-[20px] w-[95%]">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-hero-container rounded-[20px] w-[95%]">
|
||||||
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||||
{{-- Left Content --}}
|
{{-- Left Content --}}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
@if (!empty($content))
|
@if (!empty($content))
|
||||||
<section class="relative h-[30vh] min-h-[220px] overflow-hidden">
|
<section id="bild" class="relative h-[30vh] min-h-[220px] overflow-hidden">
|
||||||
<x-web-picture
|
<x-web-picture
|
||||||
src="{{ theme_image_url($content['image'] ?? '') }}"
|
src="{{ theme_image_url($content['image'] ?? '') }}"
|
||||||
alt="{{ $content['image_alt'] ?? 'Dekoratives Bild' }}"
|
alt="{{ $content['image_alt'] ?? 'Dekoratives Bild' }}"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{{-- Bewusst klein und dezent: wer gezielt sucht, findet den Weg; wer wegen Einrichtung
|
{{-- Bewusst klein und dezent: wer gezielt sucht, findet den Weg; wer wegen Einrichtung
|
||||||
kommt, wird nicht abgelenkt. Keine Preise, keine Renditeversprechen. --}}
|
kommt, wird nicht abgelenkt. Keine Preise, keine Renditeversprechen. --}}
|
||||||
<section class="py-12 {{ $bg }}">
|
<section id="immobilien" class="py-12 {{ $bg }}">
|
||||||
<div class="container-narrow">
|
<div class="container-narrow">
|
||||||
<div class="border-t border-border pt-10 text-center">
|
<div class="border-t border-border pt-10 text-center">
|
||||||
<h2 class="text-xl font-medium">{!! $content['title'] ?? '' !!}</h2>
|
<h2 class="text-xl font-medium">{!! $content['title'] ?? '' !!}</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding">
|
<section id="magazin" class="section-padding">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
<div class="text-center mb-16">
|
<div class="text-center mb-16">
|
||||||
<h1 class="text-hero mb-6">
|
<h1 class="text-hero mb-6">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding bg-secondary text-secondary-foreground">
|
<section id="marken-cta" class="section-padding bg-secondary text-secondary-foreground">
|
||||||
<div class="container-narrow text-center spacing-section">
|
<div class="container-narrow text-center spacing-section">
|
||||||
<h2 class="text-section-title text-white">{!! $content['title'] ?? '' !!}</h2>
|
<h2 class="text-section-title text-white">{!! $content['title'] ?? '' !!}</h2>
|
||||||
<p class="text-large max-w-2xl mx-auto leading-relaxed">{{ $content['text'] ?? '' }}</p>
|
<p class="text-large max-w-2xl mx-auto leading-relaxed">{{ $content['text'] ?? '' }}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{{-- Optionaler Block laut Spec – über das CMS-Feld `enabled` abschaltbar. --}}
|
{{-- Optionaler Block laut Spec – über das CMS-Feld `enabled` abschaltbar. --}}
|
||||||
@if ($content['enabled'] ?? true)
|
@if ($content['enabled'] ?? true)
|
||||||
<section class="py-16 {{ $bg }}">
|
<section id="wachstum" class="py-16 {{ $bg }}">
|
||||||
<div class="container-narrow text-center">
|
<div class="container-narrow text-center">
|
||||||
<h2 class="text-2xl font-medium">{!! $content['title'] ?? '' !!}</h2>
|
<h2 class="text-2xl font-medium">{!! $content['title'] ?? '' !!}</h2>
|
||||||
<p class="text-muted-foreground leading-relaxed mt-4">{{ $content['text'] ?? '' }}</p>
|
<p class="text-muted-foreground leading-relaxed mt-4">{{ $content['text'] ?? '' }}</p>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<section class="section-padding {{ $bg }}">
|
<section id="netzwerk" class="section-padding {{ $bg }}">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
<div class="container-narrow text-center">
|
<div class="container-narrow text-center">
|
||||||
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
|
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Ohne gepflegten Fertigungsort bleibt nur die Überschrift und der Ablauf-Block. --}}
|
||||||
|
@if ($this->locations !== [])
|
||||||
<div class="grid lg:grid-cols-2 gap-10 lg:gap-16 items-center">
|
<div class="grid lg:grid-cols-2 gap-10 lg:gap-16 items-center">
|
||||||
{{-- Schematische Karte: Bielefeld als Drehscheibe, die vier Standorte als Speichen.
|
{{-- Schematische Karte: Bielefeld als Drehscheibe, die Standorte als Speichen.
|
||||||
Keine Geografie, nur Richtung und Entfernung. Unter md tragen die Kacheln alles. --}}
|
Keine Geografie, nur Richtung und Entfernung. Positionen aus OriginSection::MAP_POSITIONS,
|
||||||
|
eine Marke ohne Position steht nur in den Kacheln. Unter md tragen die Kacheln alles. --}}
|
||||||
<div class="hidden md:block card-elevated rounded-2xl p-6 lg:p-8 slide-left delay-400">
|
<div class="hidden md:block card-elevated rounded-2xl p-6 lg:p-8 slide-left delay-400">
|
||||||
<svg class="origin-map" viewBox="0 0 640 400" role="img"
|
<svg class="origin-map" viewBox="0 0 640 400" role="img"
|
||||||
aria-label="{{ $content['map_alt'] ?? '' }}">
|
aria-label="{{ $content['map_alt'] ?? '' }}">
|
||||||
|
|
@ -27,10 +30,10 @@
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@foreach ($this->locations as $index => $location)
|
@foreach ($this->locations as $index => $location)
|
||||||
|
@continue(empty($location['map']))
|
||||||
@php
|
@php
|
||||||
$x = (int) ($location['x'] ?? 0);
|
$x = $location['map']['x'];
|
||||||
$y = (int) ($location['y'] ?? 0);
|
$y = $location['map']['y'];
|
||||||
$left = ($location['label'] ?? 'right') === 'left';
|
|
||||||
$midX = (int) round(($hubX + $x) / 2);
|
$midX = (int) round(($hubX + $x) / 2);
|
||||||
$midY = (int) round(($hubY + $y) / 2);
|
$midY = (int) round(($hubY + $y) / 2);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
@ -41,10 +44,11 @@
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
@foreach ($this->locations as $index => $location)
|
@foreach ($this->locations as $index => $location)
|
||||||
|
@continue(empty($location['map']))
|
||||||
@php
|
@php
|
||||||
$x = (int) ($location['x'] ?? 0);
|
$x = $location['map']['x'];
|
||||||
$y = (int) ($location['y'] ?? 0);
|
$y = $location['map']['y'];
|
||||||
$left = ($location['label'] ?? 'right') === 'left';
|
$left = $location['map']['label'] === 'left';
|
||||||
$labelX = $left ? $x - 14 : $x + 14;
|
$labelX = $left ? $x - 14 : $x + 14;
|
||||||
@endphp
|
@endphp
|
||||||
<circle class="origin-map-dot" cx="{{ $x }}" cy="{{ $y }}" r="7" />
|
<circle class="origin-map-dot" cx="{{ $x }}" cy="{{ $y }}" r="7" />
|
||||||
|
|
@ -63,10 +67,10 @@
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Die vier Standorte --}}
|
{{-- Eine Kachel je Marke mit Fertigungsort --}}
|
||||||
<div class="grid sm:grid-cols-2 gap-6 slide-right delay-400">
|
<div class="grid sm:grid-cols-2 gap-6 slide-right delay-400">
|
||||||
@foreach ($this->locations as $index => $location)
|
@foreach ($this->locations as $location)
|
||||||
<div class="card-elevated rounded-2xl p-6 flex flex-col gap-2" wire:key="origin-{{ $index }}">
|
<div class="card-elevated rounded-2xl p-6 flex flex-col gap-2" wire:key="origin-{{ $location['slug'] }}">
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-secondary">
|
<span class="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-secondary">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
|
|
@ -84,9 +88,9 @@
|
||||||
@if (!empty($location['distance']))
|
@if (!empty($location['distance']))
|
||||||
<span class="text-clay">{{ $location['distance'] }} {{ $content['distance_suffix'] ?? '' }}</span>
|
<span class="text-clay">{{ $location['distance'] }} {{ $content['distance_suffix'] ?? '' }}</span>
|
||||||
@endif
|
@endif
|
||||||
@if (!empty($location['link']) && !empty($location['link_text']))
|
@if (!empty($location['link']) && !empty($content['link_text']))
|
||||||
<a href="{{ $location['link'] }}" class="font-medium text-primary hover:text-secondary transition-colors">
|
<a href="{{ $location['link'] }}" class="font-medium text-primary hover:text-secondary transition-colors">
|
||||||
{{ $location['link_text'] }}
|
{{ $content['link_text'] }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,6 +98,7 @@
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- Gefertigt oder vertrieben? Der Weg eines Maßmöbels am Beispiel CABINET. --}}
|
{{-- Gefertigt oder vertrieben? Der Weg eines Maßmöbels am Beispiel CABINET. --}}
|
||||||
@if ($steps !== [])
|
@if ($steps !== [])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding">
|
<section id="geschichte" class="section-padding">
|
||||||
<div class="container-padding text-center">
|
<div class="container-padding text-center">
|
||||||
<div class="text-center mb-16 slide-up delay-200">
|
<div class="text-center mb-16 slide-up delay-200">
|
||||||
<h2 class="text-section-title text-foreground mb-12">
|
<h2 class="text-section-title text-foreground mb-12">
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ function renderHeroIcon($iconName, $style = 'outline') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
<section class="section-padding bg-accent">
|
<section id="werte" class="section-padding bg-accent">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
<div class="text-center mb-16 slide-up delay-300">
|
<div class="text-center mb-16 slide-up delay-300">
|
||||||
<h2 class="text-section-title">{!! $content['title'] !!}</h2>
|
<h2 class="text-section-title">{!! $content['title'] !!}</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{{-- Seitenkopf für Unterseiten – abgesetzte graue Fläche wie der Hero auf Startseite und
|
{{-- 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. --}}
|
Netzwerkseite, damit er sich klar von den Blöcken darunter absetzt. --}}
|
||||||
<section class="section-padding flex items-center relative border-b border-border/30 {{ $bg }}">
|
<section id="seitenkopf" class="section-padding flex items-center relative border-b border-border/30 {{ $bg }}">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20 bg-hero-container rounded-[20px] w-[95%]">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20 bg-hero-container rounded-[20px] w-[95%]">
|
||||||
{{-- Breadcrumb: Home → Marken --}}
|
{{-- Breadcrumb: Home → Marken --}}
|
||||||
<nav aria-label="Breadcrumb" class="mb-8">
|
<nav aria-label="Breadcrumb" class="mb-8">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="{{ $bg }} text-secondary-foreground section-padding">
|
<section id="cta" class="{{ $bg }} text-secondary-foreground section-padding">
|
||||||
<div class="container-narrow text-center">
|
<div class="container-narrow text-center">
|
||||||
<div class="spacing-section">
|
<div class="spacing-section">
|
||||||
<h2 class="text-section-title text-white slide-up delay-300">
|
<h2 class="text-section-title text-white slide-up delay-300">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding {{ $bg }}">
|
<section id="projekte" class="section-padding {{ $bg }}">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
<div class="text-center max-w-2xl mx-auto">
|
<div class="text-center max-w-2xl mx-auto">
|
||||||
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
|
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<figure class="card-elevated rounded-2xl overflow-hidden group" wire:key="ref-{{ $project['key'] }}">
|
<figure class="card-elevated rounded-2xl overflow-hidden group" wire:key="ref-{{ $project['key'] }}">
|
||||||
<div class="relative overflow-hidden">
|
<div class="relative overflow-hidden">
|
||||||
<img src="{{ theme_image_url($project['image']) }}"
|
<img src="{{ theme_image_url($project['image']) }}"
|
||||||
alt="{{ $project['caption'] ?: $project['brand'] }}" loading="lazy"
|
alt="{{ $project['caption'] ?: ($project['brands'][0]['name'] ?? '') }}" loading="lazy"
|
||||||
class="w-full aspect-[4/3] object-cover transition-transform duration-300 group-hover:scale-105" />
|
class="w-full aspect-[4/3] object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||||
<span class="absolute top-3 left-3 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
<span class="absolute top-3 left-3 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
||||||
{{ __('ui.brand_gallery_badge_project') }}
|
{{ __('ui.brand_gallery_badge_project') }}
|
||||||
|
|
@ -23,13 +23,32 @@
|
||||||
@if (!empty($project['caption']))
|
@if (!empty($project['caption']))
|
||||||
<p class="font-medium">{{ $project['caption'] }}</p>
|
<p class="font-medium">{{ $project['caption'] }}</p>
|
||||||
@endif
|
@endif
|
||||||
@if ($project['has_detail_page'])
|
@if (!empty($project['meta']))
|
||||||
<a href="{{ route('marken.show', ['slug' => $project['brand_slug']]) }}"
|
<p class="text-xs uppercase tracking-wider text-clay mt-1">{{ $project['meta'] }}</p>
|
||||||
class="text-sm text-secondary underline underline-offset-2 hover:no-underline mt-1 inline-block">
|
@endif
|
||||||
{{ $project['brand'] }}
|
@if (!empty($project['description']))
|
||||||
</a>
|
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{{ $project['description'] }}</p>
|
||||||
@else
|
@endif
|
||||||
<p class="text-sm text-muted-foreground mt-1">{{ $project['brand'] }}</p>
|
@if (!empty($project['gallery']))
|
||||||
|
{{-- Weitere Bilder der Referenz als kleine Reihe --}}
|
||||||
|
<div class="flex gap-2 mt-3 overflow-x-auto">
|
||||||
|
@foreach ($project['gallery'] as $item)
|
||||||
|
<img src="{{ theme_image_url($item['image']) }}" alt="{{ $item['caption'] ?: $project['caption'] }}"
|
||||||
|
loading="lazy" class="h-14 w-20 shrink-0 rounded-md object-cover" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if (!empty($project['brands']))
|
||||||
|
<p class="text-sm mt-2 flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
@foreach ($project['brands'] as $brand)
|
||||||
|
@if (!empty($brand['link']))
|
||||||
|
<a href="{{ $brand['link'] }}"
|
||||||
|
class="text-secondary underline underline-offset-2 hover:no-underline">{{ $brand['name'] }}</a>
|
||||||
|
@else
|
||||||
|
<span class="text-muted-foreground">{{ $brand['name'] }}</span>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</p>
|
||||||
@endif
|
@endif
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding {{ $bg }}">
|
<section id="engagement" class="section-padding {{ $bg }}">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
<div class="text-center mb-12 slide-up delay-300">
|
<div class="text-center mb-12 slide-up delay-300">
|
||||||
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
|
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<section class="section-padding {{ $bg }}">
|
<section id="vision" class="section-padding {{ $bg }}">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<div>
|
<div>
|
||||||
<!-- Hero Section with Contact Form -->
|
<!-- Hero Section with Contact Form -->
|
||||||
<section class="py-10 flex items-center relative overflow-hidden">
|
<section id="kontakt" class="py-10 flex items-center relative overflow-hidden">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-[hsl(var(--hero-container))] rounded-[20px] w-[95%]">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 bg-[hsl(var(--hero-container))] rounded-[20px] w-[95%]">
|
||||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
<!-- Left Side - Hero Text -->
|
<!-- Left Side - Hero Text -->
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Immobilien dunkel mit negativem Logo (Tokens `--footer-*` in theme-b2in.css, Logo in mount()).
|
Immobilien dunkel mit negativem Logo (Tokens `--footer-*` in theme-b2in.css, Logo in mount()).
|
||||||
pb-28 auf kleinen Schirmen: darunter schwebt das Terminanfrage-Widget, das sonst die
|
pb-28 auf kleinen Schirmen: darunter schwebt das Terminanfrage-Widget, das sonst die
|
||||||
letzten Zeilen des Footers verdecken wuerde. --}}
|
letzten Zeilen des Footers verdecken wuerde. --}}
|
||||||
<footer class="section-footer section-padding pb-28 sm:pb-16">
|
<footer id="footer" class="section-footer section-padding pb-28 sm:pb-16">
|
||||||
<div class="container-padding">
|
<div class="container-padding">
|
||||||
{{-- Main Content --}}
|
{{-- Main Content --}}
|
||||||
<div class="text-center spacing-section">
|
<div class="text-center spacing-section">
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ Route::middleware(['auth', 'partner.setup'])->group(function () {
|
||||||
Volt::route('admin/flux-cms/media', 'admin.cms.media-index')->name('cms.media.index');
|
Volt::route('admin/flux-cms/media', 'admin.cms.media-index')->name('cms.media.index');
|
||||||
Volt::route('admin/flux-cms/articles', 'admin.cms.articles-index')->name('cms.articles.index');
|
Volt::route('admin/flux-cms/articles', 'admin.cms.articles-index')->name('cms.articles.index');
|
||||||
Volt::route('admin/flux-cms/brands', 'admin.cms.brands-index')->name('cms.brands.index');
|
Volt::route('admin/flux-cms/brands', 'admin.cms.brands-index')->name('cms.brands.index');
|
||||||
|
Volt::route('admin/flux-cms/references', 'admin.cms.references-index')->name('cms.references.index');
|
||||||
|
|
||||||
// Documentation
|
// Documentation
|
||||||
Volt::route('admin/documentation', 'admin.documentation')->name('admin.documentation');
|
Volt::route('admin/documentation', 'admin.documentation')->name('admin.documentation');
|
||||||
|
|
|
||||||
|
|
@ -327,3 +327,69 @@ it('saves the widget label and the closing block', function () {
|
||||||
->and($brand->getTranslation('cta_title', 'de'))->toBe('Ihre Küche. Persönlich geplant.')
|
->and($brand->getTranslation('cta_title', 'de'))->toBe('Ihre Küche. Persönlich geplant.')
|
||||||
->and($brand->getTranslation('cta_text', 'de'))->toBe('Jeder Raum ist anders.');
|
->and($brand->getTranslation('cta_text', 'de'))->toBe('Jeder Raum ist anders.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Fertigungsort für „Aus der Region gefertigt“ (Nachtrag 11)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
it('saves the origin of a brand per language', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
Volt::test('admin.cms.brands-index')
|
||||||
|
->call('openCreate')
|
||||||
|
->set('slug', 'mit-herkunft')
|
||||||
|
->set('brandName', 'Mit Herkunft')
|
||||||
|
->set('originCity', 'Bünde')
|
||||||
|
->set('originDistance', 'rund 30 km')
|
||||||
|
->set('originRole', 'Küchen aus eigener Fertigung.')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$brand = CmsBrand::where('slug', 'mit-herkunft')->firstOrFail();
|
||||||
|
|
||||||
|
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
|
||||||
|
->and($brand->getTranslation('origin_distance', 'de'))->toBe('rund 30 km')
|
||||||
|
->and($brand->getTranslation('origin_role', 'de'))->toBe('Küchen aus eigener Fertigung.');
|
||||||
|
|
||||||
|
Volt::test('admin.cms.brands-index')
|
||||||
|
->call('openEdit', $brand->id)
|
||||||
|
->assertSet('originCity', 'Bünde')
|
||||||
|
->call('switchLocale', 'en')
|
||||||
|
->assertSet('originCity', '')
|
||||||
|
->set('brandName', 'With origin')
|
||||||
|
->set('originCity', 'Buende')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$brand->refresh();
|
||||||
|
|
||||||
|
expect($brand->getTranslation('origin_city', 'de'))->toBe('Bünde')
|
||||||
|
->and($brand->getTranslation('origin_city', 'en'))->toBe('Buende');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the origin when the city is emptied', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
$brand = CmsBrand::factory()->create(['origin_city' => ['de' => 'Herford']]);
|
||||||
|
|
||||||
|
Volt::test('admin.cms.brands-index')
|
||||||
|
->call('openEdit', $brand->id)
|
||||||
|
->set('originCity', '')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect($brand->fresh()->getTranslation('origin_city', 'de', false))->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is reachable from the sidebar for admins', function () {
|
||||||
|
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->assignRole('Admin');
|
||||||
|
|
||||||
|
$html = $this->actingAs($user)
|
||||||
|
->get(route('cms.brands.index'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain(route('cms.brands.index'))
|
||||||
|
->toMatch('#data-content>\s*Marken\s*</div>#');
|
||||||
|
});
|
||||||
|
|
|
||||||
144
tests/Feature/Admin/Cms/CmsReferencesIndexTest.php
Normal file
144
tests/Feature/Admin/Cms/CmsReferencesIndexTest.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\CmsReference;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referenzen verwalten (Nachtrag 13).
|
||||||
|
*/
|
||||||
|
beforeEach(function () {
|
||||||
|
url()->forceRootUrl('https://'.config('domains.domain_portal'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication', function () {
|
||||||
|
$this->get(route('cms.references.index'))->assertRedirect('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders for authenticated users', function () {
|
||||||
|
$this->actingAs(User::factory()->create())
|
||||||
|
->get(route('cms.references.index'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Referenzen')
|
||||||
|
->assertSee('Keine Referenzen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a reference with brands, cover and gallery', function () {
|
||||||
|
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
Volt::test('admin.cms.references-index')
|
||||||
|
->call('openCreate')
|
||||||
|
->set('referenceTitle', 'Ankleide unter der Dachschräge')
|
||||||
|
->set('location', 'Bielefeld')
|
||||||
|
->set('year', '2025')
|
||||||
|
->set('description', 'Maßschrank bis unter den First.')
|
||||||
|
->set('image', 'referenzen/ankleide.webp')
|
||||||
|
->set('brandSlugs', ['cabinet', 'tischlerei'])
|
||||||
|
->call('addGalleryImage')
|
||||||
|
->set('gallery.0.image', 'referenzen/ankleide-2.webp')
|
||||||
|
->set('gallery.0.caption', 'Innenausbau')
|
||||||
|
->call('addGalleryImage')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$reference = CmsReference::firstOrFail();
|
||||||
|
|
||||||
|
expect($reference->getTranslation('title', 'de'))->toBe('Ankleide unter der Dachschräge')
|
||||||
|
->and($reference->location)->toBe('Bielefeld')
|
||||||
|
->and($reference->year)->toBe('2025')
|
||||||
|
->and($reference->image)->toBe('referenzen/ankleide.webp')
|
||||||
|
->and($reference->brand_slugs)->toBe(['cabinet', 'tischlerei'])
|
||||||
|
->and($reference->gallery)->toBe([['image' => 'referenzen/ankleide-2.webp', 'caption' => 'Innenausbau']])
|
||||||
|
->and($reference->is_published)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a title', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
Volt::test('admin.cms.references-index')
|
||||||
|
->call('openCreate')
|
||||||
|
->set('referenceTitle', '')
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors(['referenceTitle']);
|
||||||
|
|
||||||
|
expect(CmsReference::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a brand slug that does not exist', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
Volt::test('admin.cms.references-index')
|
||||||
|
->call('openCreate')
|
||||||
|
->set('referenceTitle', 'Test')
|
||||||
|
->set('brandSlugs', ['gibt-es-nicht'])
|
||||||
|
->call('save')
|
||||||
|
->assertHasErrors(['brandSlugs.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits an existing reference and keeps both languages', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
$reference = CmsReference::factory()->create(['title' => ['de' => 'Küche', 'en' => 'Kitchen']]);
|
||||||
|
|
||||||
|
Volt::test('admin.cms.references-index')
|
||||||
|
->call('openEdit', $reference->id)
|
||||||
|
->assertSet('referenceTitle', 'Küche')
|
||||||
|
->set('referenceTitle', 'Küche in Hiddenhausen')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$reference->refresh();
|
||||||
|
|
||||||
|
expect($reference->getTranslation('title', 'de'))->toBe('Küche in Hiddenhausen')
|
||||||
|
->and($reference->getTranslation('title', 'en'))->toBe('Kitchen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders and removes gallery images', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
$reference = CmsReference::factory()->withGallery(3)->create();
|
||||||
|
|
||||||
|
Volt::test('admin.cms.references-index')
|
||||||
|
->call('openEdit', $reference->id)
|
||||||
|
->call('moveGalleryImage', 2, -1)
|
||||||
|
->call('moveGalleryImage', 0, -1)
|
||||||
|
->call('removeGalleryImage', 2)
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(array_column($reference->fresh()->gallery, 'image'))
|
||||||
|
->toBe(['referenzen/bild-1.webp', 'referenzen/bild-3.webp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles the published state and deletes', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
$reference = CmsReference::factory()->create();
|
||||||
|
|
||||||
|
Volt::test('admin.cms.references-index')
|
||||||
|
->call('togglePublished', $reference->id);
|
||||||
|
|
||||||
|
expect($reference->fresh()->is_published)->toBeFalse();
|
||||||
|
|
||||||
|
Volt::test('admin.cms.references-index')
|
||||||
|
->call('deleteReference', $reference->id);
|
||||||
|
|
||||||
|
expect(CmsReference::count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is linked from the sidebar while the real estate projects are not', function () {
|
||||||
|
\Spatie\Permission\Models\Role::firstOrCreate(['name' => 'Admin']);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$user->assignRole('Admin');
|
||||||
|
|
||||||
|
$html = $this->actingAs($user)
|
||||||
|
->get(route('cms.references.index'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)->toMatch('#data-content>\s*Referenzen\s*</div>#')
|
||||||
|
->not->toContain(route('cms.projects.index'));
|
||||||
|
});
|
||||||
|
|
@ -52,8 +52,8 @@ test('cms dashboard renders for authenticated users', function () {
|
||||||
|
|
||||||
test('cms dashboard shows correct stats', function () {
|
test('cms dashboard shows correct stats', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
CmsProject::factory()->published()->count(2)->create();
|
\App\Models\CmsReference::factory()->published()->count(2)->create();
|
||||||
CmsProject::factory()->unpublished()->create();
|
\App\Models\CmsReference::factory()->unpublished()->create();
|
||||||
|
|
||||||
Volt::actingAs($user)
|
Volt::actingAs($user)
|
||||||
->test('admin.cms.dashboard')
|
->test('admin.cms.dashboard')
|
||||||
|
|
@ -408,3 +408,129 @@ test('cms articles index can toggle published status', function () {
|
||||||
|
|
||||||
expect($article->refresh()->is_published)->toBeFalse();
|
expect($article->refresh()->is_published)->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Inhalte-Editor: Seitenbaum, Beschriftungen, Vorschau (Nachtrag 12)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
test('cms content index shows the pages of the website instead of raw groups', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
CmsContent::create([
|
||||||
|
'group' => 'home',
|
||||||
|
'key' => 'hero',
|
||||||
|
'type' => 'json',
|
||||||
|
'value' => ['de' => ['title' => 'Test']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::actingAs($user)
|
||||||
|
->test('admin.cms.content-index')
|
||||||
|
->assertSee('Startseite')
|
||||||
|
->assertSee('Aus der Region gefertigt')
|
||||||
|
->assertSee('Auf jeder Seite')
|
||||||
|
->assertSee('Rechtliches')
|
||||||
|
->assertDontSee('Nicht mehr verwendet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cms content index selects one section and labels its fields in plain language', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$hero = CmsContent::create([
|
||||||
|
'group' => 'home',
|
||||||
|
'key' => 'hero',
|
||||||
|
'type' => 'json',
|
||||||
|
'value' => ['de' => ['title' => 'Hero Title', 'button_text' => 'Los']],
|
||||||
|
]);
|
||||||
|
CmsContent::create([
|
||||||
|
'group' => 'home',
|
||||||
|
'key' => 'sponsoring',
|
||||||
|
'type' => 'json',
|
||||||
|
'value' => ['de' => ['title' => 'Sponsoring Title']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Volt::actingAs($user)
|
||||||
|
->test('admin.cms.content-index')
|
||||||
|
->call('selectSection', 'home', 'hero')
|
||||||
|
->assertSet('selectedGroup', 'home')
|
||||||
|
->assertSet('selectedKey', 'hero')
|
||||||
|
->assertSee('Überschrift')
|
||||||
|
->assertSee('Button-Beschriftung')
|
||||||
|
->assertSee('Hero Title')
|
||||||
|
->assertDontSee('Sponsoring Title');
|
||||||
|
|
||||||
|
expect($component->instance()->previewUrl)->toEndWith('/?cms-preview=0#hero');
|
||||||
|
|
||||||
|
$component->call('startFieldEdit', $hero->id, 'title')
|
||||||
|
->set('editValue', 'Neu')
|
||||||
|
->call('saveEdit')
|
||||||
|
->assertSet('previewVersion', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cms content index lists unregistered rows under "not used any more"', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
CmsContent::create([
|
||||||
|
'group' => 'netzwerk',
|
||||||
|
'key' => 'teasers',
|
||||||
|
'type' => 'json',
|
||||||
|
'value' => ['de' => ['title' => 'Alt']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::actingAs($user)
|
||||||
|
->test('admin.cms.content-index')
|
||||||
|
->assertSee('Nicht mehr verwendet')
|
||||||
|
->assertSee('netzwerk / teasers')
|
||||||
|
->call('selectSection', 'netzwerk', 'teasers')
|
||||||
|
->assertSee('Alt');
|
||||||
|
|
||||||
|
expect(Volt::actingAs($user)->test('admin.cms.content-index')
|
||||||
|
->call('selectSection', 'netzwerk', 'teasers')
|
||||||
|
->instance()->previewUrl)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cms content index creates a missing section from the lang template', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
expect(CmsContent::where('group', 'global')->where('key', 'appointment_widget')->exists())->toBeFalse();
|
||||||
|
|
||||||
|
Volt::actingAs($user)
|
||||||
|
->test('admin.cms.content-index')
|
||||||
|
->call('createSection', 'global', 'appointment_widget')
|
||||||
|
->assertSet('selectedGroup', 'global')
|
||||||
|
->assertSet('selectedKey', 'appointment_widget');
|
||||||
|
|
||||||
|
$row = CmsContent::where('group', 'global')->where('key', 'appointment_widget')->first();
|
||||||
|
|
||||||
|
expect($row)->not->toBeNull();
|
||||||
|
expect($row->getTranslation('value', 'de'))->toBe(cms_theme_section('appointment_widget'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cms content index refuses to create a section without a lang template', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
Volt::actingAs($user)
|
||||||
|
->test('admin.cms.content-index')
|
||||||
|
->call('createSection', 'global', 'contact')
|
||||||
|
->assertSet('selectedKey', null);
|
||||||
|
|
||||||
|
expect(CmsContent::where('group', 'global')->where('key', 'contact')->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cms content index can hide the preview', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
CmsContent::create([
|
||||||
|
'group' => 'home',
|
||||||
|
'key' => 'hero',
|
||||||
|
'type' => 'json',
|
||||||
|
'value' => ['de' => ['title' => 'Test']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::actingAs($user)
|
||||||
|
->test('admin.cms.content-index')
|
||||||
|
->call('selectSection', 'home', 'hero')
|
||||||
|
->assertSee('Vorschau der Website')
|
||||||
|
->call('togglePreview')
|
||||||
|
->assertSet('showPreview', false)
|
||||||
|
->assertDontSee('Vorschau der Website');
|
||||||
|
});
|
||||||
|
|
|
||||||
154
tests/Feature/Cms/CmsPageTreeTest.php
Normal file
154
tests/Feature/Cms/CmsPageTreeTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\CmsPageTree;
|
||||||
|
use FluxCms\Core\Models\CmsContent;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seitenbaum des Inhalte-Editors (config/cms_pages.php, Nachtrag 12).
|
||||||
|
*/
|
||||||
|
function cmsRow(string $group, string $key, array $value = ['title' => 'x']): CmsContent
|
||||||
|
{
|
||||||
|
return CmsContent::create([
|
||||||
|
'group' => $group,
|
||||||
|
'key' => $key,
|
||||||
|
'type' => 'json',
|
||||||
|
'value' => ['de' => $value],
|
||||||
|
'order' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lists the pages in menu order with the homepage sections top to bottom', function () {
|
||||||
|
$pages = app(CmsPageTree::class)->pages();
|
||||||
|
|
||||||
|
expect(array_slice(array_keys($pages), 0, 7))->toBe(['home', 'marken', 'referenzen', 'magazin', 'about', 'contact', 'faq']);
|
||||||
|
expect($pages['home']['title'])->toBe('Startseite')
|
||||||
|
->and($pages['home']['path'])->toBe('/');
|
||||||
|
|
||||||
|
$keys = array_map(fn (array $s) => $s['group'].'/'.$s['key'], $pages['home']['sections']);
|
||||||
|
|
||||||
|
expect(array_slice($keys, 0, 5))->toBe(['home/hero', 'home/founder_bar', 'home/audience_switch', 'home/core_brands', 'home/origin_section'])
|
||||||
|
->and(array_slice($keys, -2))->toBe(['home/immobilien_teaser', 'global/cta_section']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks sections that exist in the database and leaves the rest without content id', function () {
|
||||||
|
$hero = cmsRow('home', 'hero');
|
||||||
|
|
||||||
|
$pages = app(CmsPageTree::class)->pages();
|
||||||
|
$sections = collect($pages['home']['sections'])->keyBy('key');
|
||||||
|
|
||||||
|
expect($sections['hero']['content_id'])->toBe($hero->id)
|
||||||
|
->and($sections['founder_bar']['content_id'])->toBeNull()
|
||||||
|
->and($sections['hero']['theme_key'])->toBe('hero')
|
||||||
|
->and($sections['hero']['anchor'])->toBe('hero');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects database rows that no page lists under "not used any more"', function () {
|
||||||
|
cmsRow('netzwerk', 'teasers');
|
||||||
|
cmsRow('home', 'synergie_section');
|
||||||
|
|
||||||
|
$pages = app(CmsPageTree::class)->pages();
|
||||||
|
|
||||||
|
expect($pages)->toHaveKey(CmsPageTree::UNLISTED);
|
||||||
|
expect(array_key_last($pages))->toBe(CmsPageTree::UNLISTED);
|
||||||
|
|
||||||
|
$titles = array_column($pages[CmsPageTree::UNLISTED]['sections'], 'title');
|
||||||
|
|
||||||
|
expect($titles)->toBe(['home / synergie_section', 'netzwerk / teasers']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the collection point while every row is listed on a page', function () {
|
||||||
|
cmsRow('home', 'hero');
|
||||||
|
|
||||||
|
expect(app(CmsPageTree::class)->pages())->not->toHaveKey(CmsPageTree::UNLISTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a section by group and key', function () {
|
||||||
|
cmsRow('marken', 'network');
|
||||||
|
|
||||||
|
$section = app(CmsPageTree::class)->section('marken', 'network');
|
||||||
|
|
||||||
|
expect($section['title'])->toBe('Mehr als Marken: unser Netzwerk')
|
||||||
|
->and($section['page'])->toBe('marken')
|
||||||
|
->and($section['path'])->toBe('/marken')
|
||||||
|
->and($section['content_id'])->not->toBeNull();
|
||||||
|
|
||||||
|
expect(app(CmsPageTree::class)->section('marken', 'gibt-es-nicht'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels fields from the dictionary, a section override or a readable fallback', function () {
|
||||||
|
config()->set('cms_pages.pages.home.sections.0.fields', ['title' => 'Große Überschrift']);
|
||||||
|
|
||||||
|
$tree = app(CmsPageTree::class);
|
||||||
|
|
||||||
|
expect($tree->fieldLabel('title', 'home', 'hero'))->toBe('Große Überschrift')
|
||||||
|
->and($tree->fieldLabel('title', 'home', 'sponsoring'))->toBe('Überschrift')
|
||||||
|
->and($tree->fieldLabel('button_text'))->toBe('Button-Beschriftung')
|
||||||
|
->and($tree->fieldLabel('some_unknown_key'))->toBe('Some unknown key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the preview url from the frontend domain, path, version and anchor', function () {
|
||||||
|
config()->set('domains.protocol', 'https://');
|
||||||
|
config()->set('domains.domain_b2in', 'b2in.test');
|
||||||
|
|
||||||
|
$tree = app(CmsPageTree::class);
|
||||||
|
|
||||||
|
expect($tree->previewUrl($tree->section('home', 'origin_section'), 3))
|
||||||
|
->toBe('https://b2in.test/?cms-preview=3#herkunft');
|
||||||
|
expect($tree->previewUrl($tree->section('faq', 'faq_immobilien')))
|
||||||
|
->toBe('https://b2in.test/immobilien/faq?cms-preview=0#faq');
|
||||||
|
expect($tree->previewUrl($tree->section('magazin', 'detail')))
|
||||||
|
->toBe('https://b2in.test/magazin?cms-preview=0');
|
||||||
|
expect($tree->previewUrl(null))->toBeNull();
|
||||||
|
expect($tree->previewUrl(['path' => null]))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the lang template for every registered section that has one', function () {
|
||||||
|
$tree = app(CmsPageTree::class);
|
||||||
|
|
||||||
|
foreach (config('cms_pages.pages') as $page) {
|
||||||
|
foreach ($page['sections'] as $section) {
|
||||||
|
$themeKey = $tree->themeKey($section['group'], $section['key']);
|
||||||
|
|
||||||
|
// Kontaktdaten und die rechtlichen Seiten liegen nur in der Datenbank (CmsLegalSeeder), nicht in b2in.php.
|
||||||
|
if ($section['group'] === 'legal' || ($section['group'] === 'global' && $section['key'] === 'contact')) {
|
||||||
|
expect($themeKey)->toBeNull();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($themeKey)->not->toBeNull("Kein Theme-Key für {$section['group']}/{$section['key']}");
|
||||||
|
expect(cms_theme_section($themeKey))->toBeArray("Keine Lang-Vorlage für {$themeKey}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an anchor on the website for every section the registry points to', function () {
|
||||||
|
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
||||||
|
|
||||||
|
$expected = [];
|
||||||
|
|
||||||
|
foreach (config('cms_pages.pages') as $page) {
|
||||||
|
foreach ($page['sections'] as $section) {
|
||||||
|
$path = $section['path'] ?? $page['path'] ?? null;
|
||||||
|
|
||||||
|
if ($path === null || empty($section['anchor'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected[$path][$section['anchor']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($expected as $path => $anchors) {
|
||||||
|
$html = $this->get($path)->assertSuccessful()->getContent();
|
||||||
|
|
||||||
|
foreach (array_keys($anchors) as $anchor) {
|
||||||
|
expect(str_contains($html, 'id="'.$anchor.'"'))->toBeTrue("Anker #{$anchor} fehlt auf {$path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Livewire\Web\Components\Sections\OriginSection;
|
use App\Livewire\Web\Components\Sections\OriginSection;
|
||||||
|
use App\Models\CmsBrand;
|
||||||
|
use Database\Seeders\BrandOriginSeeder;
|
||||||
|
use Database\Seeders\CmsBrandSeeder;
|
||||||
|
use Database\Seeders\OriginSectionCmsSeeder;
|
||||||
use FluxCms\Core\Models\CmsContent;
|
use FluxCms\Core\Models\CmsContent;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
@ -10,7 +14,10 @@ use Livewire\Livewire;
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3).
|
* Herkunfts-Sektion „Aus der Region gefertigt“ (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
|
||||||
|
*
|
||||||
|
* Die Standorte kommen aus den Marken (origin_city, origin_distance, origin_role),
|
||||||
|
* die Kartenpositionen aus OriginSection::MAP_POSITIONS.
|
||||||
*/
|
*/
|
||||||
function originBody(string $html): string
|
function originBody(string $html): string
|
||||||
{
|
{
|
||||||
|
|
@ -19,7 +26,21 @@ function originBody(string $html): string
|
||||||
return substr($html, (int) strpos($html, '<main'));
|
return substr($html, (int) strpos($html, '<main'));
|
||||||
}
|
}
|
||||||
|
|
||||||
it('shows the four locations on the homepage', function () {
|
function originSection(string $html): string
|
||||||
|
{
|
||||||
|
$html = originBody($html);
|
||||||
|
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
||||||
|
|
||||||
|
return substr($section, 0, (int) strpos($section, '</section>'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Standorte aus den Marken
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
it('shows the four locations from the seeded brands on the homepage', function () {
|
||||||
|
$this->seed(CmsBrandSeeder::class);
|
||||||
|
|
||||||
$this->get('/')
|
$this->get('/')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('id="herkunft"', false)
|
->assertSee('id="herkunft"', false)
|
||||||
|
|
@ -27,9 +48,96 @@ it('shows the four locations on the homepage', function () {
|
||||||
->assertSee('Bünde', false)
|
->assertSee('Bünde', false)
|
||||||
->assertSee('Herford')
|
->assertSee('Herford')
|
||||||
->assertSee('Rheda-Wiedenbrück', false)
|
->assertSee('Rheda-Wiedenbrück', false)
|
||||||
->assertSee('Köln', false);
|
->assertSee('Köln', false)
|
||||||
|
->assertSee('rund 200 km');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links every location to its brand page', function () {
|
||||||
|
$this->seed(CmsBrandSeeder::class);
|
||||||
|
|
||||||
|
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
|
||||||
|
expect($section)->toContain('href="'.$link.'"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an unpublished brand from the locations entirely', function () {
|
||||||
|
$this->seed(CmsBrandSeeder::class);
|
||||||
|
CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
|
||||||
|
|
||||||
|
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
// „Herford“ steht auch im Alt-Text der Karte – geprüft wird die Kachel.
|
||||||
|
expect($section)->toContain('href="/marken/cabinet"')
|
||||||
|
->toContain('wire:key="origin-cabinet"')
|
||||||
|
->not->toContain('wire:key="origin-februe"')
|
||||||
|
->not->toContain('/marken/februe');
|
||||||
|
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves out a brand whose origin city is empty', function () {
|
||||||
|
$this->seed(CmsBrandSeeder::class);
|
||||||
|
CmsBrand::query()->where('slug', 'februe')->update(['origin_city' => ['de' => '', 'en' => '']]);
|
||||||
|
|
||||||
|
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
expect($section)->toContain('wire:key="origin-rotpunkt"')
|
||||||
|
->not->toContain('wire:key="origin-februe"');
|
||||||
|
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a brand without a map position in the tiles only', function () {
|
||||||
|
$this->seed(CmsBrandSeeder::class);
|
||||||
|
CmsBrand::factory()->create([
|
||||||
|
'slug' => 'neue-marke',
|
||||||
|
'name' => ['de' => 'Neue Marke'],
|
||||||
|
'origin_city' => ['de' => 'Verl'],
|
||||||
|
'is_published' => true,
|
||||||
|
'order' => 9,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
expect($section)->toContain('Verl')
|
||||||
|
->toContain('wire:key="origin-neue-marke"');
|
||||||
|
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the lang fallback brands without links while no brand is maintained', function () {
|
||||||
|
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
expect($section)->toContain('Bünde')
|
||||||
|
->toContain('Köln')
|
||||||
|
->not->toContain('href="/marken/');
|
||||||
|
expect(substr_count($section, 'class="origin-map-dot"'))->toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws Bielefeld as the hub with one spoke per location', function () {
|
||||||
|
$this->seed(CmsBrandSeeder::class);
|
||||||
|
|
||||||
|
$html = $this->get('/')->assertSuccessful()->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain('class="origin-map"')
|
||||||
|
->toContain('origin-map-hub')
|
||||||
|
->toContain('Ostwestfalen');
|
||||||
|
expect(substr_count($html, 'class="origin-map-spoke"'))->toBe(4);
|
||||||
|
expect(substr_count($html, 'class="origin-map-dot"'))->toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides map and tiles but keeps the steps when no brand has an origin', function () {
|
||||||
|
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null, 'is_published' => true]);
|
||||||
|
|
||||||
|
$section = originSection($this->get('/')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
expect($section)->not->toContain('origin-map')
|
||||||
|
->toContain('Fertigung in Köln');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Platzierung und Ablauf-Block
|
||||||
|
// ========================================
|
||||||
|
|
||||||
it('sits between the core brands and the founder story on the homepage', function () {
|
it('sits between the core brands and the founder story on the homepage', function () {
|
||||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
|
@ -43,7 +151,7 @@ it('sits between the core brands and the founder story on the homepage', functio
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appears on the brands page after the brand overview and before the network', function () {
|
it('appears on the brands page after the brand overview and before the network', function () {
|
||||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
$this->seed(CmsBrandSeeder::class);
|
||||||
|
|
||||||
$html = originBody($this->get('/marken')->assertSuccessful()->getContent());
|
$html = originBody($this->get('/marken')->assertSuccessful()->getContent());
|
||||||
|
|
||||||
|
|
@ -56,50 +164,6 @@ it('appears on the brands page after the brand overview and before the network',
|
||||||
expect($network)->toBeGreaterThan($origin);
|
expect($network)->toBeGreaterThan($origin);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('links every location to its brand page', function () {
|
|
||||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
|
||||||
|
|
||||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
|
||||||
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
|
||||||
$section = substr($section, 0, (int) strpos($section, '</section>'));
|
|
||||||
|
|
||||||
foreach (['/marken/rotpunkt', '/marken/februe', '/marken/tischlerei', '/marken/cabinet'] as $link) {
|
|
||||||
expect($section)->toContain('href="'.$link.'"');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops the link of an unpublished brand but keeps its location', function () {
|
|
||||||
$this->seed(\Database\Seeders\CmsBrandSeeder::class);
|
|
||||||
\App\Models\CmsBrand::query()->where('slug', 'februe')->update(['is_published' => false]);
|
|
||||||
|
|
||||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
|
||||||
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
|
||||||
$section = substr($section, 0, (int) strpos($section, '</section>'));
|
|
||||||
|
|
||||||
expect($section)->toContain('Herford')
|
|
||||||
->toContain('href="/marken/cabinet"')
|
|
||||||
->not->toContain('/marken/februe');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds the brand page by name when the lang fallback uses other slugs', function () {
|
|
||||||
$html = originBody($this->get('/')->assertSuccessful()->getContent());
|
|
||||||
$section = substr($html, (int) strpos($html, 'id="herkunft"'));
|
|
||||||
$section = substr($section, 0, (int) strpos($section, '</section>'));
|
|
||||||
|
|
||||||
expect($section)->toContain('href="/marken/cabinet"')
|
|
||||||
->toContain('href="/marken/tischlerei"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('draws Bielefeld as the hub with one spoke per location', function () {
|
|
||||||
$html = $this->get('/')->assertSuccessful()->getContent();
|
|
||||||
|
|
||||||
expect($html)->toContain('class="origin-map"')
|
|
||||||
->toContain('origin-map-hub')
|
|
||||||
->toContain('Ostwestfalen');
|
|
||||||
expect(substr_count($html, 'class="origin-map-spoke"'))->toBe(4);
|
|
||||||
expect(substr_count($html, 'class="origin-map-dot"'))->toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('separates manufacturing from sales for CABINET', function () {
|
it('separates manufacturing from sales for CABINET', function () {
|
||||||
$this->get('/')
|
$this->get('/')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
|
|
@ -109,17 +173,16 @@ it('separates manufacturing from sales for CABINET', function () {
|
||||||
->assertSee('Aufbau vor Ort', false);
|
->assertSee('Aufbau vor Ort', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefers the CMS content over the lang fallback', function () {
|
it('prefers the CMS content for the texts but keeps the locations from the brands', function () {
|
||||||
|
$this->seed(CmsBrandSeeder::class);
|
||||||
CmsContent::create([
|
CmsContent::create([
|
||||||
'group' => 'home',
|
'group' => 'home',
|
||||||
'key' => 'origin_section',
|
'key' => 'origin_section',
|
||||||
'type' => 'json',
|
'type' => 'json',
|
||||||
'value' => ['de' => [
|
'value' => ['de' => [
|
||||||
'title' => 'Gefertigt in <span class="text-secondary">OWL</span>',
|
'title' => 'Gefertigt in <span class="text-secondary">OWL</span>',
|
||||||
|
'link_text' => 'Zur Manufaktur',
|
||||||
'hub' => ['city' => 'Bielefeld', 'x' => 400, 'y' => 175],
|
'hub' => ['city' => 'Bielefeld', 'x' => 400, 'y' => 175],
|
||||||
'locations' => [
|
|
||||||
['city' => 'Verl', 'brand' => 'Testmarke', 'x' => 300, 'y' => 100],
|
|
||||||
],
|
|
||||||
]],
|
]],
|
||||||
'order' => 0,
|
'order' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
@ -127,43 +190,47 @@ it('prefers the CMS content over the lang fallback', function () {
|
||||||
$this->get('/')
|
$this->get('/')
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Gefertigt in <span class="text-secondary">OWL</span>', false)
|
->assertSee('Gefertigt in <span class="text-secondary">OWL</span>', false)
|
||||||
->assertSee('Verl')
|
->assertSee('Zur Manufaktur')
|
||||||
->assertSee('Testmarke')
|
->assertSee('Rheda-Wiedenbrück', false);
|
||||||
->assertDontSee('Rheda-Wiedenbrück');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips locations without brand or city instead of breaking the map', function () {
|
|
||||||
Livewire::test(OriginSection::class)
|
|
||||||
->set('content', [
|
|
||||||
'title' => 'Herkunft',
|
|
||||||
'locations' => [
|
|
||||||
['city' => 'Bünde', 'brand' => 'Rotpunkt Küchen', 'x' => 1, 'y' => 1],
|
|
||||||
['city' => '', 'brand' => 'Ohne Stadt'],
|
|
||||||
'kaputt',
|
|
||||||
['brand' => '', 'city' => 'Ohne Marke'],
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->assertSee('Rotpunkt Küchen', false)
|
|
||||||
->assertDontSee('Ohne Stadt')
|
|
||||||
->assertDontSee('Ohne Marke');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders without the steps block when the CMS leaves it empty', function () {
|
it('renders without the steps block when the CMS leaves it empty', function () {
|
||||||
Livewire::test(OriginSection::class)
|
Livewire::test(OriginSection::class)
|
||||||
->set('content', ['title' => 'Herkunft', 'locations' => [], 'steps' => []])
|
->set('content', ['title' => 'Herkunft', 'steps' => []])
|
||||||
->assertSee('Herkunft')
|
->assertSee('Herkunft')
|
||||||
->assertDontSee('<ol', false);
|
->assertDontSee('<ol', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the CMS section only when it is missing', function () {
|
// ========================================
|
||||||
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
|
// Seeder
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
it('creates the CMS section only when it is missing and strips the legacy locations', function () {
|
||||||
|
$this->seed(OriginSectionCmsSeeder::class);
|
||||||
|
|
||||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'origin_section')->first();
|
$row = CmsContent::query()->where('group', 'home')->where('key', 'origin_section')->first();
|
||||||
expect($row)->not->toBeNull();
|
expect($row)->not->toBeNull();
|
||||||
expect($row->getTranslation('value', 'de')['locations'])->toHaveCount(4);
|
expect($row->getTranslation('value', 'de'))->not->toHaveKey('locations');
|
||||||
|
|
||||||
$row->update(['value' => ['de' => ['title' => 'Vom Admin geändert']]]);
|
$row->update(['value' => ['de' => [
|
||||||
$this->seed(\Database\Seeders\OriginSectionCmsSeeder::class);
|
'title' => 'Vom Admin geändert',
|
||||||
|
'locations' => [['city' => 'Alt', 'brand' => 'Alt']],
|
||||||
|
]]]);
|
||||||
|
$this->seed(OriginSectionCmsSeeder::class);
|
||||||
|
|
||||||
expect($row->fresh()->getTranslation('value', 'de')['title'])->toBe('Vom Admin geändert');
|
$value = $row->fresh()->getTranslation('value', 'de');
|
||||||
|
expect($value['title'])->toBe('Vom Admin geändert');
|
||||||
|
expect($value)->not->toHaveKey('locations');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the origin of existing brands only where it is empty', function () {
|
||||||
|
CmsBrand::factory()->create(['slug' => 'cabinet', 'origin_city' => null]);
|
||||||
|
CmsBrand::factory()->create(['slug' => 'rotpunkt', 'origin_city' => ['de' => 'Eigener Ort']]);
|
||||||
|
|
||||||
|
$this->seed(BrandOriginSeeder::class);
|
||||||
|
|
||||||
|
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_city', 'de'))->toBe('Köln');
|
||||||
|
expect(CmsBrand::where('slug', 'cabinet')->first()->getTranslation('origin_distance', 'en'))->toBe('about 200 km');
|
||||||
|
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_city', 'de'))->toBe('Eigener Ort');
|
||||||
|
expect(CmsBrand::where('slug', 'rotpunkt')->first()->getTranslation('origin_distance', 'de', false))->toBeEmpty();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,65 @@ it('says so plainly when there is nothing to show yet', function () {
|
||||||
->assertSee('sobald wir sie zeigen dürfen', false)
|
->assertSee('sobald wir sie zeigen dürfen', false)
|
||||||
->assertSee('Termin anfragen');
|
->assertSee('Termin anfragen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Eigene Referenzen aus dem Portal (Nachtrag 13)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
it('prefers maintained references over the brand galleries', function () {
|
||||||
|
CmsBrand::where('slug', 'cabinet')->firstOrFail()->update([
|
||||||
|
'gallery' => [['image' => 'a.webp', 'category' => 'project', 'caption' => 'Aus der Galerie']],
|
||||||
|
]);
|
||||||
|
\App\Models\CmsReference::factory()->forBrands(['cabinet', 'tischlerei'])->withGallery(2)->create([
|
||||||
|
'title' => ['de' => 'Ankleide unter der Dachschräge'],
|
||||||
|
'location' => 'Bielefeld',
|
||||||
|
'year' => '2025',
|
||||||
|
'description' => ['de' => 'Maßschrank bis unter den First.'],
|
||||||
|
'image' => 'referenzen/ankleide.webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain('Ankleide unter der Dachschräge')
|
||||||
|
->toContain('Bielefeld · 2025')
|
||||||
|
->toContain('Maßschrank bis unter den First.')
|
||||||
|
->toContain('referenzen/bild-1.webp')
|
||||||
|
->toContain('href="/marken/cabinet"')
|
||||||
|
->toContain('href="/marken/tischlerei"')
|
||||||
|
->not->toContain('Aus der Galerie');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides unpublished references and falls back to the galleries when none is published', function () {
|
||||||
|
CmsBrand::where('slug', 'rotpunkt')->firstOrFail()->update([
|
||||||
|
'gallery' => [['image' => 'c.webp', 'category' => 'project', 'caption' => 'Küche in Hiddenhausen']],
|
||||||
|
]);
|
||||||
|
\App\Models\CmsReference::factory()->unpublished()->create(['title' => ['de' => 'Noch nicht freigegeben']]);
|
||||||
|
|
||||||
|
$this->get('/referenzen')
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertDontSee('Noch nicht freigegeben')
|
||||||
|
->assertSee('Küche in Hiddenhausen', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a brand that is unpublished and skips a reference without any image', function () {
|
||||||
|
CmsBrand::where('slug', 'februe')->firstOrFail()->update(['is_published' => false]);
|
||||||
|
\App\Models\CmsReference::factory()->forBrands(['februe', 'cabinet'])->create(['title' => ['de' => 'Büro in Herford']]);
|
||||||
|
\App\Models\CmsReference::factory()->create(['title' => ['de' => 'Ohne Bild'], 'image' => null, 'gallery' => []]);
|
||||||
|
|
||||||
|
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain('Büro in Herford')
|
||||||
|
->toContain('href="/marken/cabinet"')
|
||||||
|
->not->toContain('/marken/februe')
|
||||||
|
->not->toContain('Ohne Bild');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the first gallery image as cover when no cover is set', function () {
|
||||||
|
\App\Models\CmsReference::factory()->withGallery(2)->create(['title' => ['de' => 'Nur Galerie'], 'image' => null]);
|
||||||
|
|
||||||
|
$html = $this->get('/referenzen')->assertSuccessful()->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain('Nur Galerie')
|
||||||
|
->toContain('referenzen/bild-1.webp');
|
||||||
|
expect(substr_count($html, 'referenzen/bild-1.webp'))->toBe(1);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue