Interior-Feinschliff: Hero-Slideshow, Lektorat, Rechtstexte, Aufräumen und Korrekturen 08.10.

Startseite und Heroes
- Hero-Slideshow (11 Motive inkl. Febrü) mit Infokarte, Laufzeit-Linie, größeren Klickflächen,
  Goldrahmen und mobiler Karte unter dem Bild; Zeile über der Überschrift, neue Vertrauenspunkte
- Hero-Stil auf Marken, Über uns und Kontakt übertragen, Breadcrumb entfernt
- Gründer-Leiste, Vision-Box auf Ocker, Showroom-Display in Box mit „Größer ansehen“/Vollbild
- Akzentfarbe #a8741f, größere Serif-Überschriften in der Interior-Welt
- Marken: Bildpause mit Zitat; Vereine aktualisiert und verlinkt

Inhalte
- Lektorat aller angezeigten Texte, /about-Korrekturen, Rechtstexte (DDG, TDDDG, Hosting Hetzner)
- Ablauf Maßanfertigung überall einheitlich, Magazin-Beitrag neu, alte Beiträge offline,
  deutsches Datum/Lesezeit, FAQ Projektausstattung, Otterpohl
- Referenzen-Seite per CMS-Schalter ausblendbar

CMS und Technik
- Tote Sektionen, Felder, Seiten und Komponenten entfernt und archiviert (dev/2026-08-28/archiv-*)
- Entwurf /entwurf, Farb-Schalter und /welcome entfernt; Test-Routen nur noch lokal
- Seitenbaum-Beschreibungen geprüft, fehlende CMS-Felder ergänzt, Markenliste aus „Kernmarken“
- Admin-Bildvorschauen repariert, verwendete Bilder in die Mediathek übernommen
- Seeder für das Deployment siehe dev/2026-08-28/entwicklungsplan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-08 14:00:15 +00:00
parent 4b07bbdcae
commit fcb10be120
406 changed files with 22549 additions and 3798 deletions

View file

@ -0,0 +1,104 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Textkorrekturen auf /about (Abstimmung 06.10.2026, Nachtrag 21).
*
* „… am Ende auch dafür geradesteht“, dazu Flüchtigkeits- und Formulierungsfehler: Komma bei
* „wenigen ausgewählten“, deutsche Anführungszeichen im Zitat, „Wort halten“, Otterpohl als
* vierte Kernmarke in der Zeitleiste, „ungenutzt“ statt „liegen lässt“, „B2in-Partner“ und
* ein Alternativtext für die Bildpause, der das Bild beschreibt (nur Interior-Welt).
* Der Seeder tauscht nur genau diese Textstellen in den betroffenen CMS-Sektionen; alles andere
* bleibt. Steht eine Stelle nicht mehr drin, passiert dort nichts. Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=AboutPageTextSeeder
*/
class AboutPageTextSeeder extends Seeder
{
/** @var list<array{0: string, 1: string}> Gruppe und Schlüssel der Sektionen */
public const SECTIONS = [
['about', 'hero'],
['about', 'image_break'],
['about', 'our_story'],
['about', 'our_values'],
['about', 'partner_cta'],
['netzwerk', 'partner_cta'],
];
/** @var array<string, string> alte Stelle → neue Stelle */
public const REPLACEMENTS = [
'aufmisst und am Ende geradesteht.' => 'aufmisst und am Ende auch dafür geradesteht.',
'mit wenigen, ausgewählten Herstellern' => 'mit wenigen ausgewählten Herstellern',
'"Gute Einrichtung entsteht' => '„Gute Einrichtung entsteht',
'bis nach der Montage."' => 'bis nach der Montage.“',
'verbunden durch dieselbe Grundhaltung, dass Qualität und Wort halten zusammengehören.' => 'verbunden durch dieselbe Grundhaltung: Zu Qualität gehört, Wort zu halten.',
'2026 rückt das Interior in den Mittelpunkt: Mit CABINET, Rotpunkt Küchen und Febrü stehen starke Kernmarken im Zentrum, ergänzt um ein Netzwerk regionaler Fachexperten. Internationale Immobilien bleiben als ergänzender Bereich bestehen.' => '2026 rückt die Einrichtung in den Mittelpunkt: CABINET, Rotpunkt Küchen, Febrü und Otterpohl Möbelservice bilden die Kernmarken, ergänzt um ein Netzwerk regionaler Fachexperten. Internationale Immobilien bleiben als zweiter Bereich bestehen.',
'Wir planen den Raum, den ein Standardmöbel liegen lässt.' => 'Wir planen auch den Raum, den ein Standardmöbel ungenutzt lässt.',
'Werden Sie B2in Partner' => 'Werden Sie B2in-Partner',
'B2in – Immobilien und Einrichtung verbinden' => 'Wohnraum halb als Planungsskizze, halb fertig eingerichtet',
'B2in – Connecting Real Estate and Interior' => 'Living space, half planning sketch, half fully furnished',
'In 2026 interiors move to the centre: CABINET, Rotpunkt Küchen and Febrü form the core, complemented by a network of regional specialists. International real estate remains as a complementary area.' => 'In 2026 interiors move to the centre: CABINET, Rotpunkt Küchen, Febrü and Otterpohl Möbelservice form the core, complemented by a network of regional specialists. International real estate remains as a second area.',
];
public function run(): void
{
$total = 0;
foreach (self::SECTIONS as [$group, $key]) {
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
if (! $row) {
continue;
}
$count = 0;
$value = $this->replaceDeep($row->getTranslations('value'), $count);
if ($count === 0) {
continue;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache($group);
$total += $count;
}
$this->command?->info($total > 0
? "About-Texte korrigiert ({$total} Stelle(n))."
: 'About-Texte: bereits aktuell oder nicht im CMS – nichts geändert.');
}
/**
* @param array<mixed> $data
* @return array<mixed>
*/
private function replaceDeep(array $data, int &$count): array
{
foreach ($data as $key => $item) {
if (is_array($item)) {
$data[$key] = $this->replaceDeep($item, $count);
continue;
}
if (! is_string($item)) {
continue;
}
$replaced = strtr($item, self::REPLACEMENTS);
if ($replaced !== $item) {
$data[$key] = $replaced;
$count++;
}
}
return $data;
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace Database\Seeders;
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Übernimmt die Bilder, die Seeder unter public/img/assets/ abgelegt haben (Marken, Magazin,
* Slideshow …), in die Mediathek (07.10.2026) – damit sie im Bildauswahl-Dialog erscheinen.
*
* Name in der Mediathek ist der bisherige Pfad (z. B. `marken/cabinet/ankleide-solitaer-eiche-tano.webp`).
* Die Editoren speichern beim Auswählen genau diesen Namen, also bleiben alle Verweise gültig; der
* Admin (media_url()) zeigt das Bild aus der Mediathek. Die Datei wird kopiert, das Original unter
* public/img/assets/ bleibt liegen – die Website liefert weiter von dort (WebP-Fassungen).
*
* Übernommen wird nur, was Marken, Magazin oder CMS-Einträge (samt Standardtexten) tatsächlich
* verwenden. Bereits vorhandene Einträge werden übersprungen – mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=AssetMediaImportSeeder
*/
class AssetMediaImportSeeder extends Seeder
{
public function run(): void
{
$disk = config('flux-cms.media.disk', 'public');
$originals = config('flux-cms.media.originals_path', 'cms/media/originals');
$service = app(MediaConversionService::class);
$imported = 0;
foreach ($this->referencedAssets() as $value => $collection) {
if (CmsMedia::query()->where('filename', $value)->exists()) {
continue;
}
$source = public_path('img/assets/'.$value);
$extension = strtolower(pathinfo($source, PATHINFO_EXTENSION));
$path = $originals.'/'.Str::random(40).'.'.$extension;
Storage::disk($disk)->put($path, file_get_contents($source));
$isSvg = $extension === 'svg';
$dimensions = $isSvg ? false : @getimagesize($source);
$media = CmsMedia::create([
'filename' => $value,
'disk' => $disk,
'path' => $path,
'type' => 'image',
'mime_type' => $isSvg ? 'image/svg+xml' : (mime_content_type($source) ?: 'image/'.$extension),
'file_size' => filesize($source),
'original_width' => $dimensions ? $dimensions[0] : null,
'original_height' => $dimensions ? $dimensions[1] : null,
'collection' => $collection,
'conversions' => [],
]);
if (! $isSvg) {
$service->generateThumbnail($media);
}
$imported++;
}
$this->command?->info($imported === 0
? 'Mediathek: alle verwendeten Bilder sind bereits übernommen.'
: "Mediathek: {$imported} Bilder aus public/img/assets übernommen.");
}
/**
* Bildpfade, die Marken, Magazin oder CMS verwenden und die unter public/img/assets/ liegen,
* mit der Sammlung, in die sie kommen (die erste Quelle zählt: Marken, Magazin, Website).
*
* @return array<string, string>
*/
public function referencedAssets(): array
{
$values = [];
$collect = function (mixed $value, string $collection) use (&$collect, &$values): void {
if (is_array($value)) {
array_walk($value, fn ($item) => $collect($item, $collection));
return;
}
if (! is_string($value) || ! preg_match('/\.(jpe?g|png|webp|svg|gif)$/i', $value)) {
return;
}
$value = ltrim(trim($value), '/');
if (str_contains($value, '://') || str_starts_with($value, 'storage/') || str_starts_with($value, 'immobile/')) {
return;
}
if (is_file(public_path('img/assets/'.$value))) {
$values[$value] ??= $collection;
}
};
foreach (CmsBrand::query()->get() as $brand) {
$collect([$brand->logo, $brand->image, $brand->gallery, $brand->sections], 'Marken');
}
foreach (CmsArticle::query()->get() as $article) {
$collect($article->toArray(), 'Magazin');
}
foreach (CmsContent::query()->get() as $row) {
$collect($row->getTranslations('value'), 'Website');
}
$collect(trans('b2in.themes.'.config('app.theme', 'b2in'), [], 'de'), 'Website');
ksort($values);
return $values;
}
}

View file

@ -77,7 +77,7 @@ class BrandPageTextSeeder extends Seeder
'description' => [
'de' => '<p>Ein Schrank ist selten der Grund, warum jemand ein Zuhause liebt. Er ist aber sehr oft der Grund, warum ein Raum funktioniert – oder eben nicht. Und Hand aufs Herz: Die schönste Dachschräge nützt wenig, wenn darunter seit dem Einzug drei Kartons stehen, für die nie ein Platz gefunden wurde.</p>'
.'<p>CABINET plant Einbauschränke, die sich dem Raum anpassen statt umgekehrt: von Wand zu Wand, vom Boden bis zur Decke, um die Schräge herum und am Heizkörper vorbei.</p>'
.'<p>Unsere Aufgabe ist es herauszufinden, was genau dieser eine Raum leisten muss. Bei uns in der Bielefelder Rathausstraße entsteht daraus nicht einfach ein Schrank, sondern eine Lösung, die den vorhandenen Platz vollständig nutzt.</p>',
.'<p>Unsere Aufgabe ist es, herauszufinden, was genau dieser eine Raum leisten muss. Bei uns in der Bielefelder Rathausstraße entsteht daraus nicht einfach ein Schrank, sondern eine Lösung, die den vorhandenen Platz vollständig nutzt.</p>',
'en' => '<p>A wardrobe is rarely the reason someone loves a home. It is very often the reason a room works – or does not.</p>',
],
'cta_title' => ['de' => 'Ihr Raum. Persönlich geplant.', 'en' => 'Your room. Personally planned.'],
@ -93,7 +93,7 @@ class BrandPageTextSeeder extends Seeder
'CABINET plant jedes Element für seinen Platz. Der Schrank endet damit nicht dort, wo das Raster aufhört, sondern dort, wo der Raum aufhört. Was sonst als schwer nutzbare Restfläche übrig bleibt, wird zu Stauraum.',
],
'detail' => [
'title' => 'Wo Standardlösungen an ihre Grenzen kommen',
'title' => 'Wo Standardlösungen an ihre Grenzen stoßen',
'paragraphs' => [
'Planbar sind beispielsweise Schränke unter Dachschrägen, raumhohe Fronten bis unter die Decke, passgenaue Nischenlösungen zwischen zwei Wänden, Übergänge zu vorhandenen Möbeln sowie Aussparungen für Fensterbänke, Vorsprünge und Installationen.',
'Auch der Sockel, die Fußleiste und der Abschluss zur Decke gehören zur Planung. Genau an diesen Übergängen entscheidet sich, ob ein Schrank wie ein Möbelstück vor der Wand wirkt oder wie ein selbstverständlicher Teil des Raumes.',
@ -103,12 +103,12 @@ class BrandPageTextSeeder extends Seeder
[
'title' => 'Ein Bild und ein grobes Maß reichen für den Anfang',
'paragraphs' => [
'Für den Anfang reicht uns ein Bild und ein grobes Maß völlig aus.',
'Sie müssen also nicht erst alles millimetergenau ausmessen. Ein paar Fotos von der Raumsituation und die ungefähren Maße reichen, damit wir schon eine gute Planung machen und Ihnen ein konkretes Angebot erstellen können.',
'Am Anfang reicht das grobe Maß. Am Ende entscheidet der Millimeter.',
'Sie müssen also nicht erst alles millimetergenau ausmessen, bevor Sie zu uns kommen. Ein paar Fotos, ein Grundriss und die ungefähren Maße reichen, damit wir Sie beraten, planen und Ihnen ein konkretes Angebot erstellen können.',
'Danach gehen wir gemeinsam ins Detail: Aufteilung, Ausstattung, Materialien, Farben und natürlich der Preis.',
'Wenn alles grundsätzlich passt, kommen wir zum professionellen Aufmaß bei Ihnen vor Ort. Danach machen wir die Feinplanung fertig und geben den Schrank zur Bestellung frei.',
'Wenn Sie das Angebot annehmen und die Anzahlung erfolgt ist, übernehmen wir das professionelle Aufmaß bei Ihnen vor Ort. Ab jetzt zählt jeder Millimeter: Auf Grundlage dieser Maße machen wir die Feinplanung fertig, geben die Fertigung frei und bestellen Ihren Schrank.',
],
'claim' => 'So läuft’s: Foto + grobe Maße → Beratung → Planung & Angebot → Aufmaß vor Ort → Feinplanung → Bestellung',
'claim' => 'So läuft’s: Foto / Grundriss + grobe Maße → Beratung → Planung & Angebot → Angebotsannahme → Anzahlung → professionelles Aufmaß → Feinplanung / Fertigungsfreigabe → Bestellung / Fertigung',
],
[
'title' => 'Innen entscheidet sich, ob ein Schrank funktioniert',
@ -369,7 +369,7 @@ class BrandPageTextSeeder extends Seeder
'claim' => 'Das Unternehmen stärkt das Wir. Der Einzelne macht daraus das Beste.',
],
[
'title' => 'Ruhe und Flexibilität sind planbar.',
'title' => 'Ruhe und Flexibilität sind planbar',
'paragraphs' => [
'Akustik, mobile Elemente und flexible Raumlösungen helfen dabei, Bereiche für Kommunikation, Konzentration und Rückzug zu schaffen – und sie später an veränderte Teams und Arbeitsweisen anzupassen.',
],

View file

@ -78,7 +78,7 @@ class CmsArticleSeeder extends Seeder
'de' => is_array($rowDe['content'] ?? null) ? $rowDe['content'] : [],
'en' => is_array($rowEn['content'] ?? null) ? $rowEn['content'] : [],
],
'is_published' => true,
'is_published' => ($rowDe['published'] ?? true) !== false,
];
}

View file

@ -53,7 +53,7 @@ class CmsBrandSeeder extends Seeder
'en' => 'Bespoke wardrobe solutions, planned to the millimetre – built for rooms where standard will not do.',
],
'summary' => [
'de' => 'Wo Standardmaße an ihre Grenzen kommen, fängt CABINET an. Schranklösungen, die exakt in Ihren Raum geplant werden – von der Dachschräge über die Nische bis zur raumhohen Front. Gefertigt in der Qualität, die man erst beim zweiten Hinschauen ganz erfasst.
'de' => 'Wo Standardmaße an ihre Grenzen stoßen, fängt CABINET an. Schranklösungen, die exakt in Ihren Raum geplant werden – von der Dachschräge über die Nische bis zur raumhohen Front. Gefertigt in der Qualität, die man erst beim zweiten Hinschauen ganz erfasst.
Erleben Sie die Möglichkeiten in unserem Showroom in Bielefeld.',
'en' => 'Where standard dimensions reach their limits, CABINET begins. Wardrobe solutions planned precisely for your room – from sloped ceilings and alcoves to full-height fronts. Built to a quality you only fully grasp on second look.
@ -70,7 +70,7 @@ Experience the possibilities in our Bielefeld showroom.',
'en' => '<p>CABINET stands for built-in solutions that adapt to the room rather than the other way round. Every element is planned for its place – from sloped-ceiling wardrobes to full-height hallway storage. Made in Germany, installed by specialists who know the difference between fitting and belonging.</p>',
],
'services' => [
['title' => 'Aufmaß vor Ort', 'description' => 'Wir messen millimetergenau in Ihren Räumen – Grundlage jeder Planung.'],
['title' => 'Aufmaß vor Ort', 'description' => 'Nach Angebotsannahme und Anzahlung erfolgt das professionelle Aufmaß bei Ihnen vor Ort. Es bildet die Grundlage für die finale Feinplanung und Fertigungsfreigabe.'],
['title' => 'Individuelle Planung', 'description' => 'Grundriss, Nutzung und Stil bestimmen die Lösung, nicht der Katalog.'],
['title' => 'Fertigung in Deutschland', 'description' => 'Materialien und Verarbeitung, die auf Dauer angelegt sind.'],
['title' => 'Montage durch Fachleute', 'description' => 'Wir liefern und montieren – bis zum letzten Detail.'],
@ -131,11 +131,11 @@ Consultation and planning in our kitchen studio.',
'name' => ['de' => 'Febrü', 'en' => 'Febrü'],
'tagline' => ['de' => 'Arbeiten, wo man gerne bleibt.', 'en' => 'Work where you like to stay.'],
'teaser' => [
'de' => 'Möbel für konzentriertes Arbeiten – vom durchdachten Home-Office bis zur kompletten Büroeinrichtung.',
'de' => 'Möbel für konzentriertes Arbeiten – vom durchdachten Homeoffice bis zur kompletten Büroeinrichtung.',
'en' => 'Furniture for focused work – from a well-considered home office to a complete office fit-out.',
],
'summary' => [
'de' => 'Ob Home-Office oder ganze Büroetage: Febrü macht Möbel für konzentriertes Arbeiten. Höhenverstellbare Tische, durchdachte Stauraumlösungen und Sitzmöbel, die auch nach acht Stunden noch überzeugen – mit einem Design, das ins Zuhause genauso passt wie ins Unternehmen.',
'de' => 'Ob Homeoffice oder ganze Büroetage: Febrü macht Möbel für konzentriertes Arbeiten. Höhenverstellbare Tische, durchdachte Stauraumlösungen und Sitzmöbel, die auch nach acht Stunden noch überzeugen – mit einem Design, das ins Zuhause genauso passt wie ins Unternehmen.',
'en' => 'Whether a home office or a whole office floor: Febrü makes furniture for focused work. Height-adjustable desks, well-considered storage and seating that still convinces after eight hours – with a design that suits the home as much as the company.',
],
'highlights' => [
@ -148,7 +148,7 @@ Consultation and planning in our kitchen studio.',
'en' => '<p>Good work begins with the furniture. Febrü builds desks, chairs and storage that relieve the back and bring order to a room – first for the home, where more and more work happens, and equally for offices and companies.</p>',
],
'services' => [
['title' => 'Home-Office', 'description' => 'Ein Arbeitsplatz, der sich ins Zuhause fügt statt es zu besetzen.'],
['title' => 'Homeoffice', 'description' => 'Ein Arbeitsplatz, der sich ins Zuhause fügt, statt es zu besetzen.'],
['title' => 'Ergonomie', 'description' => 'Höhenverstellbare Tische und Sitzlösungen für langes Arbeiten.'],
['title' => 'Büroeinrichtung', 'description' => 'Vom einzelnen Arbeitsplatz bis zur kompletten Fläche.'],
['title' => 'Akustik und Ordnung', 'description' => 'Stauraum und Raumgliederung, die Konzentration ermöglichen.'],

View file

@ -31,28 +31,11 @@ class CmsContentSeeder extends Seeder
*/
public array $onlySections = [];
/**
* Sections to skip (not migrated, handled elsewhere or irrelevant).
*
* netzwerk_cabinet_partner / netzwerk_immobilien_hint: siehe NetzwerkPageCmsSeeder
* (nur einfügen wenn leer, damit manuelle CMS-Anpassungen nicht überschrieben werden).
*
* @var array<string>
*/
protected array $skipSections = [
'netzwerk_cabinet_partner',
'netzwerk_immobilien_hint',
];
public function run(): void
{
/** @var array<string, array{0: string, 1: string}> $sectionMap */
$sectionMap = config('cms_section_map.sections', []);
/** @var array<string, string> $langKeys */
$langKeys = config('cms_section_map.lang_keys', []);
/** @var array<string, mixed> $configTheme */
$configTheme = config('content.themes.b2in', []);
/** @var array<string, mixed> $langDe */
$langDe = Lang::get('b2in.themes.b2in', [], 'de');
/** @var array<string, mixed> $langEn */
@ -68,11 +51,6 @@ class CmsContentSeeder extends Seeder
$langEn = [];
}
/** @var array<string, mixed> $themeDe */
$themeDe = array_merge($configTheme, $langDe);
/** @var array<string, mixed> $themeEn */
$themeEn = array_merge($configTheme, $langEn);
$created = 0;
$updated = 0;
$skipped = 0;
@ -82,20 +60,14 @@ class CmsContentSeeder extends Seeder
continue;
}
if ($this->onlySections === [] && in_array($sectionKey, $this->skipSections, true)) {
continue;
}
$langSectionKey = $langKeys[$sectionKey] ?? $sectionKey;
if (! array_key_exists($langSectionKey, $themeDe)) {
$this->command?->warn("Unmapped or missing theme data for section: {$sectionKey} (lang key: {$langSectionKey}) – skipping.");
if (! array_key_exists($sectionKey, $langDe)) {
$this->command?->warn("Unmapped or missing theme data for section: {$sectionKey} – skipping.");
continue;
}
$sectionDataDe = $themeDe[$langSectionKey];
$sectionDataEn = $themeEn[$langSectionKey] ?? $sectionDataDe;
$sectionDataDe = $langDe[$sectionKey];
$sectionDataEn = $langEn[$sectionKey] ?? $sectionDataDe;
[$group, $key] = $groupKeyPair;
$type = $this->detectType($sectionDataDe);

View file

@ -5,15 +5,168 @@ namespace Database\Seeders;
use App\Models\CmsProject;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Lang;
class CmsProjectSeeder extends Seeder
{
private const PROJECT_SLUG = 'azizi-creek-views-4';
/**
* Seed the CMS projects table from resources/lang/{locale}/b2in.php
* (themes.b2in.immobilien_projects.projects.{slug}).
* Projektdaten Azizi Creek Views 4 – bis 06.10.2026 als Sektion themes.b2in.immobilien_projects
* in den Lang-Dateien, die keine Seite mehr anzeigt (Archiv: dev/2026-08-28/archiv-2026-10-06/).
*
* @var array<string, array<string, mixed>>
*/
private const SOURCE = [
'de' => [
'slug' => 'azizi-creek-views-4',
'title' => 'Azizi Developments: Creek Views 4',
'location' => 'Al Jaddaf, Dubai',
'status' => 'NEW LAUNCH',
'launch_date' => '03.03.2026',
'price_from' => '<span class="whitespace-nowrap">ab 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'image' => 'expose/a1/image-4.jpeg',
'highlights' => [
'Prime Waterfront Views',
'1BR: <span class="whitespace-nowrap">ab 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'Exklusives 3BR Penthouse (Single Inventory)',
'High Rental Demand & Capital Appreciation',
],
'quick_facts' => [
[
'icon' => 'home-modern',
'label' => 'Typen',
'value' => '1BR & 3BR Penthouse',
],
[
'icon' => 'squares-2x2',
'label' => 'Größe',
'value' => '543 – 2.346 sqft',
],
[
'icon' => 'building-office-2',
'label' => 'Einheiten',
'value' => 'Nur 132 (Limited)',
],
[
'icon' => 'user',
'label' => 'Entwickler',
'value' => 'Azizi Developments',
],
],
'investment_case' => [
'title' => 'Starkes Investment, hohe Nachfrage.',
'text' => 'Creek Views 4 bietet eine strategische Top-Lage in Al Jaddaf. Die Kombination aus limitiertem Angebot (nur 132 Einheiten) und Premium-Ausstattung macht dieses Projekt zur idealen Wahl für Investoren, die auf Wertsteigerung (Capital Appreciation) und hohe Mietnachfrage abzielen.',
'views' => [
'Road View',
'Sitting & Play Area View',
'Neighbour View',
],
],
'gallery' => [
'expose/a1/image-4.jpeg',
'expose/a1/image-3.jpeg',
'expose/a1/image-2.jpeg',
'expose/a1/image-5.jpeg',
'expose/a1/image-6.jpeg',
'expose/a1/image-7.jpeg',
],
'location_info' => [
'title' => 'Strategische Location: Al Jaddaf',
'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai',
'points' => [
'Direkte Anbindung an Dubai Creek und Waterfront',
'Wenige Minuten bis Downtown Dubai & Burj Khalifa',
'Exzellente Infrastruktur und wachsender Stadtteil',
],
],
'contact' => [
'title' => 'Sichern Sie sich eine der 132 Einheiten.',
'subtitle' => 'Ihr Ansprechpartner: Marcel Scheibe',
'options' => [
'' => 'Ich interessiere mich für...',
'1br' => '1 Bedroom Apartment',
'3br_penthouse' => '3BR Penthouse (Single Unit)',
'general' => 'Allgemeine Beratung',
],
],
],
'en' => [
'slug' => 'azizi-creek-views-4',
'title' => 'Azizi Developments: Creek Views 4',
'location' => 'Al Jaddaf, Dubai',
'status' => 'NEW LAUNCH',
'launch_date' => '03.03.2026',
'price_from' => '<span class="whitespace-nowrap">from 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'image' => 'expose/a1/image-4.jpeg',
'highlights' => [
'Prime Waterfront Views',
'1BR: <span class="whitespace-nowrap">from 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'Exclusive 3BR Penthouse (Single Inventory)',
'High Rental Demand & Capital Appreciation',
],
'quick_facts' => [
[
'icon' => 'home-modern',
'label' => 'Types',
'value' => '1BR & 3BR Penthouse',
],
[
'icon' => 'squares-2x2',
'label' => 'Size',
'value' => '543 – 2,346 sqft',
],
[
'icon' => 'building-office-2',
'label' => 'Units',
'value' => 'Only 132 (Limited)',
],
[
'icon' => 'user',
'label' => 'Developer',
'value' => 'Azizi Developments',
],
],
'investment_case' => [
'title' => 'Strong Investment, High Demand.',
'text' => 'Creek Views 4 offers a strategic top location in Al Jaddaf. The combination of limited supply (only 132 units) and premium finishes makes this project the ideal choice for investors targeting capital appreciation and high rental demand.',
'views' => [
'Road View',
'Sitting & Play Area View',
'Neighbour View',
],
],
'gallery' => [
'expose/a1/image-4.jpeg',
'expose/a1/image-3.jpeg',
'expose/a1/image-2.jpeg',
'expose/a1/image-5.jpeg',
'expose/a1/image-6.jpeg',
'expose/a1/image-7.jpeg',
],
'location_info' => [
'title' => 'Strategic Location: Al Jaddaf',
'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai',
'points' => [
'Direct connection to Dubai Creek and Waterfront',
'Just minutes to Downtown Dubai & Burj Khalifa',
'Excellent infrastructure and growing district',
],
],
'contact' => [
'title' => 'Secure one of the 132 units.',
'subtitle' => 'Your contact: Marcel Scheibe',
'options' => [
'' => 'I am interested in...',
'1br' => '1 Bedroom Apartment',
'3br_penthouse' => '3BR Penthouse (Single Unit)',
'general' => 'General Consultation',
],
],
],
];
/**
* Seed the CMS projects table from {@see self::SOURCE}.
*
* Zusätzliche Blöcke investor_trust / furniture_benefit sind in den Lang-Dateien
* nicht enthalten und werden hier mit DE/EN ergänzt.
@ -33,20 +186,8 @@ class CmsProjectSeeder extends Seeder
*/
private function getProjects(): array
{
$key = 'b2in.themes.b2in.immobilien_projects.projects.'.self::PROJECT_SLUG;
/** @var array<string, mixed> $de */
$de = Lang::get($key, [], 'de');
/** @var array<string, mixed> $en */
$en = Lang::get($key, [], 'en');
if ($de === [] || ! is_array($de)) {
return [];
}
if (! is_array($en)) {
$en = [];
}
$de = self::SOURCE['de'];
$en = self::SOURCE['en'];
$launchDate = isset($de['launch_date']) && is_string($de['launch_date'])
? Carbon::createFromFormat('d.m.Y', $de['launch_date'])

View file

@ -0,0 +1,72 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Macht jedes Feld, das die Website kennt, im CMS bearbeitbar (07.10.2026).
*
* Ein CMS-Eintrag zeigt im Editor nur die Felder, die in ihm stehen. Kam ein Feld später in die
* Lang-Datei (etwa „Zur Marke“ in „Aus der Region gefertigt“), fehlt es in älteren Einträgen und
* die Website nimmt still den Standardtext. Der Seeder ergänzt solche Felder der obersten Ebene
* aus der Lang-Datei – vorhandene Werte bleiben unangetastet, Felder aus
* {@see UnusedCmsContentCleanupSeeder::UNUSED_FIELDS} kommen nicht zurück.
* Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=CmsSectionFieldsSeeder
*/
class CmsSectionFieldsSeeder extends Seeder
{
public function run(): void
{
/** @var array<string, array{0: string, 1: string}> $sections */
$sections = config('cms_section_map.sections', []);
$theme = config('app.theme', 'b2in');
$added = [];
foreach ($sections as $sectionKey => [$group, $key]) {
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
if (! $row) {
continue;
}
$skip = UnusedCmsContentCleanupSeeder::UNUSED_FIELDS["{$group}.{$key}"] ?? [];
$value = $row->getTranslations('value');
$fields = [];
foreach ($value as $locale => $content) {
$defaults = trans("b2in.themes.{$theme}.{$sectionKey}", [], $locale);
if (! is_array($content) || ! is_array($defaults)) {
continue;
}
foreach ($defaults as $field => $default) {
if (! array_key_exists($field, $content) && ! in_array($field, $skip, true)) {
$content[$field] = $default;
$fields[$field] = true;
}
}
$value[$locale] = $content;
}
if ($fields === []) {
continue;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache($group);
$added[] = "{$group}.{$key} (".implode(', ', array_keys($fields)).')';
}
$this->command?->info($added === []
? 'CMS-Felder: alle Einträge vollständig.'
: 'CMS-Felder ergänzt: '.implode('; ', $added).'.');
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace Database\Seeders\Concerns;
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Eloquent\Model;
/**
* Textersetzungen über alle gepflegten Inhalte: CMS-Sektionen (alle Sprachen), Marken und
* Magazinbeiträge. Ersetzt wird per strtr() – idempotent, solange der neue Text den alten nicht
* enthält. `$exact` ersetzt nur Werte, die vollständig übereinstimmen.
*/
trait ReplacesCmsTexts
{
/** @var list<string> */
private array $brandFields = [
'name', 'tagline', 'teaser', 'summary', 'description', 'widget_label', 'cta_title', 'cta_text',
'origin_city', 'origin_distance', 'origin_role', 'highlights', 'services', 'sections', 'gallery',
];
/** @var list<string> */
private array $articleFields = ['title', 'subtitle', 'content'];
/**
* @param array<string, string> $replacements
* @param array<string, string> $exact
* @return int Anzahl geänderter Textstellen
*/
protected function replaceCmsTexts(array $replacements, array $exact = []): int
{
$count = 0;
$groups = [];
foreach (CmsContent::query()->get() as $row) {
$changes = 0;
$row->value = $this->replaceDeep($row->getTranslations('value'), $replacements, $exact, $changes);
if ($changes > 0) {
$row->save();
$groups[$row->group] = true;
$count += $changes;
}
}
foreach (array_keys($groups) as $group) {
app(CmsContentService::class)->clearCache($group);
}
foreach (CmsBrand::query()->get() as $brand) {
$count += $this->replaceInModel($brand, $this->brandFields, $replacements, $exact);
}
foreach (CmsArticle::query()->get() as $article) {
$count += $this->replaceInModel($article, $this->articleFields, $replacements, $exact);
}
return $count;
}
/**
* @param list<string> $fields
* @param array<string, string> $replacements
* @param array<string, string> $exact
*/
private function replaceInModel(Model $model, array $fields, array $replacements, array $exact): int
{
$changes = 0;
$translatable = property_exists($model, 'translatable') ? $model->translatable : [];
foreach ($fields as $field) {
$isTranslatable = in_array($field, $translatable, true);
$value = $isTranslatable ? $model->getTranslations($field) : $model->getAttribute($field);
if (! is_array($value)) {
continue;
}
$before = $changes;
$value = $this->replaceDeep($value, $replacements, $exact, $changes);
if ($changes > $before) {
$isTranslatable ? $model->setTranslations($field, $value) : $model->setAttribute($field, $value);
}
}
if ($changes > 0) {
$model->save();
}
return $changes;
}
/**
* @param array<mixed> $data
* @param array<string, string> $replacements
* @param array<string, string> $exact
* @return array<mixed>
*/
private function replaceDeep(array $data, array $replacements, array $exact, int &$count): array
{
foreach ($data as $key => $item) {
if (is_array($item)) {
$data[$key] = $this->replaceDeep($item, $replacements, $exact, $count);
continue;
}
if (! is_string($item)) {
continue;
}
$replaced = $exact[$item] ?? strtr($item, $replacements);
if ($replaced !== $item) {
$data[$key] = $replaced;
$count++;
}
}
return $data;
}
}

View file

@ -0,0 +1,287 @@
<?php
namespace Database\Seeders;
use App\Models\CmsArticle;
use Database\Seeders\Concerns\ReplacesCmsTexts;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Korrekturen vom 08.10.2026 (dev/2026-08-28/Korrekturen-2026-10-08.md):
*
* - Hero-Slideshow: zwei Febrü-Motive an Position 4 und 9.
* - „Regional verbunden“: die tatsächlich unterstützten Vereine (Arminia, TC Bad Salzuflen,
* TC Hiddenhausen, TC Blau Weiss Halle) mit einheitlichen Kurztexten.
* - Ablauf der Maßanfertigung überall gleich: grobe Maße für Beratung, Planung und Angebot –
* das professionelle Aufmaß erst nach Angebotsannahme und Anzahlung (CABINET, Herkunft,
* Netzwerk, Über uns, FAQ).
* - Magazin: Maßanfertigungs-Beitrag neu (aus der Lang-Datei), allgemeiner Abschluss statt
* Immobilien-Hinweis, „Supply-Chain“ und „Local for Local“ offline, Datum und Lesezeit deutsch.
* - FAQ Projektausstattung ohne Garantie-/Eskalationssprache, Otterpohl „erhöht die
* Planungssicherheit deutlich“.
*
* Die neuen Texte stehen auch in den Lang-Dateien und Quell-Seedern (frische Installation).
* Ersetzt wird nur der bisherige Wortlaut – im CMS selbst Umformuliertes bleibt.
* Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=ContentCorrections20261008Seeder
*/
class ContentCorrections20261008Seeder extends Seeder
{
use ReplacesCmsTexts;
/** @var array<string, string> Teiltexte, die ersetzt werden (Deutsch und Englisch) */
public const REPLACEMENTS = [
// CABINET: „Ein Bild und ein grobes Maß reichen für den Anfang“
'So läuft’s: Foto + grobe Maße → Beratung → Planung & Angebot → Aufmaß vor Ort → Feinplanung → Bestellung' => 'So läuft’s: Foto / Grundriss + grobe Maße → Beratung → Planung & Angebot → Angebotsannahme → Anzahlung → professionelles Aufmaß → Feinplanung / Fertigungsfreigabe → Bestellung / Fertigung',
'Für den Anfang reichen uns ein Bild und ein grobes Maß völlig aus.' => 'Am Anfang reicht das grobe Maß. Am Ende entscheidet der Millimeter.',
'Sie müssen also nicht erst alles millimetergenau ausmessen. Ein paar Fotos von der Raumsituation und die ungefähren Maße reichen, damit wir schon eine gute Planung machen und Ihnen ein konkretes Angebot erstellen können.' => 'Sie müssen also nicht erst alles millimetergenau ausmessen, bevor Sie zu uns kommen. Ein paar Fotos, ein Grundriss und die ungefähren Maße reichen, damit wir Sie beraten, planen und Ihnen ein konkretes Angebot erstellen können.',
'Wenn alles grundsätzlich passt, kommen wir zum professionellen Aufmaß bei Ihnen vor Ort. Danach machen wir die Feinplanung fertig und geben den Schrank zur Bestellung frei.' => 'Wenn Sie das Angebot annehmen und die Anzahlung erfolgt ist, übernehmen wir das professionelle Aufmaß bei Ihnen vor Ort. Ab jetzt zählt jeder Millimeter: Auf Grundlage dieser Maße machen wir die Feinplanung fertig, geben die Fertigung frei und bestellen Ihren Schrank.',
'Wir messen millimetergenau in Ihren Räumen – Grundlage jeder Planung.' => 'Nach Angebotsannahme und Anzahlung erfolgt das professionelle Aufmaß bei Ihnen vor Ort. Es bildet die Grundlage für die finale Feinplanung und Fertigungsfreigabe.',
// „Aus der Region gefertigt“ – Ablauf am Beispiel CABINET
'Beraten, aufgemessen und geplant wird bei uns im CABINET Store Bielefeld.' => 'Beraten und geplant wird bei uns im CABINET Store Bielefeld, aufgemessen nach Ihrer Zusage bei Ihnen vor Ort.',
'Im CABINET Store nehmen wir Maß, planen bis ins Detail und stimmen jede Front und jede Innenaufteilung mit Ihnen ab.' => 'Im CABINET Store planen wir mit Ihnen bis ins Detail und stimmen jede Front und jede Innenaufteilung ab – für den Anfang reichen Fotos und grobe Maße. Nach Ihrer Zusage nehmen wir vor Ort exakt Maß.',
'CABINET fertigt Ihren Schrank in der eigenen Manufaktur – nach den Maßen aus der Planung.' => 'CABINET fertigt Ihren Schrank in der eigenen Manufaktur – nach den exakten Maßen aus dem Aufmaß vor Ort.',
'Consulting, measuring and planning happen with us at the CABINET Store Bielefeld. Assembly takes place on site – with our joinery,' => 'Consulting and planning happen with us at the CABINET Store Bielefeld; the exact measurements are taken at your home once you give the go-ahead. Assembly takes place on site – with the Otterpohl joinery,',
'At the CABINET Store we take measurements, plan down to the last detail and agree every front and interior layout with you.' => 'At the CABINET Store we plan with you down to the last detail and agree every front and interior layout – photos and rough measurements are enough to start. Once you give the go-ahead, we take exact measurements on site.',
'CABINET builds your wardrobe in its own manufactory – to the measurements from the planning.' => 'CABINET builds your wardrobe in its own manufactory – to the exact measurements taken on site.',
// Marken: „Mehr als Marken: unser Netzwerk“
'Wir kommen zu Ihnen, nehmen Maß und planen mit Ihnen gemeinsam.' => 'Wir planen mit Ihnen gemeinsam – zunächst nach Fotos und groben Maßen. Das exakte Aufmaß vor Ort übernehmen wir nach Ihrer Zusage.',
'We come to you, measure up and plan together with you.' => 'We plan together with you – first from photos and rough measurements. We take the exact measurements on site once you give the go-ahead.',
// Über uns: Zitat im Seitenkopf
'ein Ansprechpartner, vom ersten Aufmaß bis nach der Montage.' => 'ein Ansprechpartner, vom ersten Gespräch bis nach der Montage.',
'one point of contact, from the first measurement to after installation.' => 'one point of contact, from the first conversation to after installation.',
// Magazin: Abschluss jeder Beitragsseite
'Entdecken Sie mehr über <span class="text-white font-medium">Immobilien-Investments</span> und das <span class="text-white font-medium">B2in-Ökosystem</span>.' => 'Weitere Einblicke rund um Einrichtung, Planung und Projekte finden Sie im <span class="text-white font-medium">B2in Magazin</span>.',
'Discover more about <span class="text-white font-medium">real estate investments</span> and the <span class="text-white font-medium">B2in ecosystem</span>.' => 'Further insights into interiors, planning and projects can be found in the <span class="text-white font-medium">B2in Magazine</span>.',
// Otterpohl: Montage
'Das garantiert Ihnen absolute Planungssicherheit.' => 'Das erhöht die Planungssicherheit deutlich.',
];
/** @var array<string, string> Ganze Werte, die nur als Ganzes getauscht werden */
public const EXACT = [
'Aufmaß & Planung' => 'Planung & Aufmaß',
'Measuring & planning' => 'Planning & measuring',
// FAQ „Wie läuft eine Beratung bei Ihnen ab?“
'Am Anfang steht ein Gespräch – im Showroom, bei Ihnen zu Hause oder telefonisch. Wir schauen uns an, welche Räume es betrifft, wie Sie sie nutzen und was Ihnen wichtig ist. Danach folgt das Aufmaß vor Ort, anschließend die Planung. Sie sehen Ihre Lösung, bevor etwas gefertigt wird. Termine vereinbaren Sie über das Formular auf dieser Seite oder direkt telefonisch.' => self::FAQ_CONSULTATION_DE,
'It starts with a conversation – in the showroom, at your home or by phone. We look at the rooms involved, how you use them and what matters to you. Measuring up on site follows, then the planning. You see your solution before anything is made. Appointments can be arranged through the form on this page or by phone.' => self::FAQ_CONSULTATION_EN,
// FAQ „Was kostet eine Maßanfertigung …?“
'Das hängt vom Raum, den Materialien und der Ausstattung ab – eine seriöse Zahl lässt sich erst nach dem Aufmaß nennen. Was sich sagen lässt: Maßanfertigung nutzt Flächen, die ein Standardmöbel liegen lässt – Schrägen, Nischen, ungewöhnliche Höhen. Sie zahlen für den Raum, den Sie sonst verschenken. Das Angebot bekommen Sie vor der Fertigung, verbindlich und aufgeschlüsselt.' => self::FAQ_COST_DE,
'That depends on the room, the materials and the fittings – a serious figure is only possible after measuring up. What can be said: bespoke uses the space standard furniture leaves behind – slopes, niches, unusual heights. You pay for the space you would otherwise give away. You receive the quote before anything is made, binding and itemised.' => self::FAQ_COST_EN,
// FAQ „Was bedeutet Projektausstattung bei B2in?“
'Für Objekte, Büros und Projektentwicklungen übernehmen wir die vollständige Ausstattung – koordiniert aus einer Hand. Wir steuern die Beschaffung von Möbeln und Innenausstattung operativ und strategisch. Das bedeutet: Wir sichern Lieferverträge ab, überwachen Meilensteine direkt bei den Herstellern und eskalieren bei Abweichungen sofort auf Managementebene. Unser Ziel ist absolute Vertragssicherheit und termingerechte Lieferung ohne Reibungsverluste.' => self::FAQ_PROJECT_DE,
'For properties, offices and developments we take on the complete fit-out, coordinated from a single source. We manage the procurement of furniture and interiors both operationally and strategically: we secure supply contracts, monitor milestones directly with the manufacturers and escalate deviations immediately. The goal is contractual reliability and on-time delivery without friction.' => self::FAQ_PROJECT_EN,
];
public const FAQ_CONSULTATION_DE = 'Am Anfang steht ein Gespräch – im Showroom, telefonisch oder auf Wunsch bei Ihnen vor Ort. Für eine erste Planung reichen uns in der Regel Fotos, ein Grundriss und grobe Maße. Damit können wir bereits Ideen entwickeln, die Planung konkretisieren und Ihnen ein entsprechendes Angebot erstellen. Wenn Ihnen die Planung gefällt und Sie unser Angebot annehmen, erfolgt nach der Anzahlung das professionelle Aufmaß bei Ihnen vor Ort. Auf Grundlage dieser exakten Maße erstellen wir die Feinplanung und geben die Möbel anschließend zur Fertigung frei.';
public const FAQ_CONSULTATION_EN = 'It starts with a conversation – in the showroom, by phone or, if you like, at your home. For a first plan, photos, a floor plan and rough measurements are usually all we need. With these we can develop ideas, refine the planning and prepare a quote. If you like the plan and accept our quote, we take the professional measurements on site once the deposit has been paid. Based on these exact measurements we complete the detailed planning and then release the furniture for production.';
public const FAQ_COST_DE = 'Das hängt vom Raum, den Materialien und der Ausstattung ab. Bereits auf Basis von Fotos, Grundriss und groben Maßen können wir eine konkrete Planung und ein entsprechendes Angebot erstellen. Das professionelle Aufmaß erfolgt nach Angebotsannahme und Anzahlung und dient anschließend der finalen Feinplanung und Fertigungsfreigabe; zeigen sich dabei vor Ort relevante Abweichungen, stimmen wir Anpassungen mit Ihnen ab. Maßanfertigung nutzt Flächen, die ein Standardmöbel liegen lässt – Schrägen, Nischen, ungewöhnliche Höhen. Sie zahlen für den Raum, den Sie sonst verschenken.';
public const FAQ_COST_EN = 'That depends on the room, the materials and the fittings. Based on photos, a floor plan and rough measurements we can already prepare a concrete plan and a matching quote. The professional measurement on site follows once the quote is accepted and the deposit paid, and serves the final detailed planning and production release; if relevant deviations show up on site, we agree any adjustments with you. Bespoke uses the space standard furniture leaves behind – slopes, niches, unusual heights. You pay for the space you would otherwise give away.';
public const FAQ_PROJECT_DE = 'Für Objekte, Büros und Projektentwicklungen übernehmen wir die vollständige Einrichtung – koordiniert aus einer Hand, von der Küche bis zur letzten Blume. Sie haben einen zentralen deutschen Vertragspartner und einen festen Ansprechpartner, der Planung, Hersteller und Gewerke zusammenhält. Dahinter steht ein eingespieltes Herstellernetzwerk, dessen Abläufe wir kennen – so bleibt auch ein großes Projekt übersichtlich.';
public const FAQ_PROJECT_EN = 'For properties, offices and developments we take on the complete fit-out – coordinated from a single source, from the kitchen to the last flower. You have one central German contracting partner and a fixed point of contact who keeps planning, manufacturers and trades together. Behind this is a well-established network of manufacturers whose processes we know – so even a large project stays manageable.';
/** @var list<string> Beiträge aus der früheren Ausrichtung – offline, bis es neue gibt */
public const UNPUBLISHED_ARTICLES = [
'supply-chain-management-vertragssicherheit',
'local-for-local-regionaler-moebelhandel',
];
public const REWRITTEN_ARTICLE = 'massanfertigung-warum-der-millimeter-zaehlt';
/** @var list<string> Bisherige Vereinsnamen – solange einer davon drinsteht, kommt die neue Liste */
public const FORMER_SPONSORS = ['TC Bad Waldliesborn-Horn', 'TC Hövelhof'];
private const MONTHS = [
'januar' => 'Januar', 'january' => 'Januar', 'februar' => 'Februar', 'february' => 'Februar',
'märz' => 'März', 'march' => 'März', 'april' => 'April', 'mai' => 'Mai', 'may' => 'Mai',
'juni' => 'Juni', 'june' => 'Juni', 'juli' => 'Juli', 'july' => 'Juli', 'august' => 'August',
'september' => 'September', 'oktober' => 'Oktober', 'october' => 'Oktober', 'november' => 'November',
'dezember' => 'Dezember', 'december' => 'Dezember',
];
public function run(): void
{
$texts = $this->replaceCmsTexts(self::REPLACEMENTS, self::EXACT);
$slides = $this->addSlides();
$sponsors = $this->replaceSponsors();
$articles = $this->updateArticles();
$this->command?->info(sprintf(
'Korrekturen 08.10.: %d Textstelle(n), %d Slide(s), Vereine %s, %d Beitrag/Beiträge aktualisiert.',
$texts, $slides, $sponsors ? 'neu' : 'unverändert', $articles,
));
}
/**
* Fügt die Slides der Lang-Datei, die im CMS fehlen, an derselben Stelle ein (hinter dem Bild,
* das in der Lang-Datei davor steht). Fehlt dieses Bild, bleibt die Liste unangetastet.
*/
private function addSlides(): int
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first();
if (! $row) {
return 0;
}
$value = $row->getTranslations('value');
$added = 0;
foreach ($value as $locale => $content) {
$defaults = trans('b2in.themes.b2in.hero.slides', [], $locale);
if (! is_array($content) || ! is_array($content['slides'] ?? null) || ! is_array($defaults)) {
continue;
}
foreach ($defaults as $index => $slide) {
$images = array_column($content['slides'], 'image');
if (in_array($slide['image'], $images, true)) {
continue;
}
// Nur wenn das Nachbarbild aus der Lang-Datei in der Liste steht – eine ganz eigene
// Liste im CMS bleibt so, wie sie ist.
$previous = $index > 0 ? array_search($defaults[$index - 1]['image'], $images, true) : false;
if ($previous === false) {
continue;
}
array_splice($content['slides'], $previous + 1, 0, [$slide]);
$added++;
}
$value[$locale] = $content;
}
if ($added > 0) {
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache('home');
}
return $added;
}
private function replaceSponsors(): bool
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'sponsoring')->first();
if (! $row) {
return false;
}
$value = $row->getTranslations('value');
$changed = false;
foreach ($value as $locale => $content) {
$names = array_column((array) ($content['logos'] ?? []), 'name');
$defaults = trans('b2in.themes.b2in.sponsoring.logos', [], $locale);
if (! is_array($content) || ! is_array($defaults)) {
continue;
}
if (array_intersect($names, self::FORMER_SPONSORS) !== []) {
$value[$locale]['logos'] = $defaults;
$changed = true;
continue;
}
// Vereinsseiten nachtragen, wo der Verein schon steht, aber noch keine URL hat.
$urls = array_column($defaults, 'url', 'name');
foreach ((array) ($content['logos'] ?? []) as $index => $logo) {
$url = $urls[$logo['name'] ?? ''] ?? null;
if ($url !== null && blank($logo['url'] ?? null)) {
$value[$locale]['logos'][$index]['url'] = $url;
$changed = true;
}
}
}
if ($changed) {
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache('home');
}
return $changed;
}
/**
* Maßanfertigungs-Beitrag aus der Lang-Datei, zwei Beiträge offline, Datum und Lesezeit deutsch.
*/
private function updateArticles(): int
{
$count = 0;
$source = collect(trans('b2in.articles', [], 'de'))->firstWhere('slug', self::REWRITTEN_ARTICLE);
$sourceEn = collect(trans('b2in.articles', [], 'en'))->firstWhere('slug', self::REWRITTEN_ARTICLE);
foreach (CmsArticle::query()->get() as $article) {
if (in_array($article->slug, self::UNPUBLISHED_ARTICLES, true)) {
$article->is_published = false;
}
if ($article->slug === self::REWRITTEN_ARTICLE && is_array($source)
&& str_contains((string) $article->getTranslation('title', 'de'), 'Warum der Millimeter über den Raum entscheidet')) {
foreach (['title', 'subtitle', 'content'] as $field) {
$article->setTranslation($field, 'de', $source[$field]);
if (is_array($sourceEn)) {
$article->setTranslation($field, 'en', $sourceEn[$field]);
}
}
}
$article->date_label = self::germanDate((string) $article->date_label);
$article->read_time = self::germanReadTime((string) $article->read_time);
if ($article->isDirty()) {
$article->save();
$count++;
}
}
return $count;
}
/** „August 26, 2026“ / „März 10, 2026“ → „26. August 2026“; schon Deutsches bleibt. */
public static function germanDate(string $value): string
{
if (! preg_match('/^\s*(\p{L}+)\s+(\d{1,2}),\s*(\d{4})\s*$/u', $value, $match)) {
return $value;
}
$month = self::MONTHS[mb_strtolower($match[1])] ?? null;
return $month === null ? $value : "{$match[2]}. {$month} {$match[3]}";
}
/** „5 min read“ → „5 Min. Lesezeit“; schon Deutsches bleibt. */
public static function germanReadTime(string $value): string
{
return preg_match('/^\s*(\d+)\s*min(?:\.|utes?)?\s*read\s*$/i', $value, $match)
? "{$match[1]} Min. Lesezeit"
: $value;
}
}

View file

@ -24,7 +24,6 @@ class DatabaseSeeder extends Seeder
$this->call([
CmsContentSeeder::class,
CmsLegalSeeder::class,
NetzwerkPageCmsSeeder::class,
CmsArticleSeeder::class,
CmsBrandSeeder::class,
CmsProjectSeeder::class,

View file

@ -0,0 +1,74 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Vertrauenspunkte unten im Hero (Abstimmung 07.10.2026): Die Startseite bekommt „Deutsche
* Manufakturen“, „Planung bis Montage“ und „Zwei Showrooms in Bielefeld“ – ersetzt werden nur die
* bisherigen Standardpunkte. Der Hero auf /marken bekommt „Maßarbeit vom Handwerk“, „Werk und
* Ausstellung erlebbar“ und „Küche, Einbau, Arbeitsplatz“, wenn dort noch keine gepflegt sind.
* Die Texte kommen aus der Lang-Datei (`stats`). Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=HeroTrustItemsSeeder
*/
class HeroTrustItemsSeeder extends Seeder
{
/** @var array<string, list<string>> Bisherige Standardpunkte der Startseite je Sprache */
public const FORMER_HOME_STATS = [
'de' => ['Premium-Marken', 'Maßgefertigte Lösungen', 'Persönliche Beratung'],
'en' => ['Premium brands', 'Bespoke solutions', 'Personal consulting'],
];
public function run(): void
{
$updated = array_filter([
$this->apply('home', 'hero', 'hero', fn (mixed $current, string $locale) => $current === (self::FORMER_HOME_STATS[$locale] ?? null)),
$this->apply('marken', 'hero', 'marken_hero', fn (mixed $current) => ! filled($current)),
]);
$this->command?->info($updated === []
? 'Hero-Vertrauenspunkte: bereits aktuell, nichts geändert.'
: 'Hero-Vertrauenspunkte gesetzt: '.implode(', ', $updated).'.');
}
/**
* @param callable(mixed, string): bool $shouldReplace
*/
private function apply(string $group, string $key, string $langSection, callable $shouldReplace): ?string
{
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
if (! $row) {
return null;
}
$value = $row->getTranslations('value');
$changed = false;
foreach ($value as $locale => $content) {
$stats = trans("b2in.themes.b2in.{$langSection}.stats", [], $locale);
if (! is_array($content) || ! is_array($stats) || ! $shouldReplace($content['stats'] ?? null, $locale)) {
continue;
}
$content['stats'] = $stats;
$value[$locale] = $content;
$changed = true;
}
if (! $changed) {
return null;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache($group);
return "{$group}/{$key}";
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Slideshow im Hero der Startseite (Abstimmung 07.10.2026).
*
* Trägt die neun Bilder aus dev/2026-08-28/images/slideshow (als WebP unter
* public/img/assets/b2in/slideshow/) samt Wechselintervall in den CMS-Eintrag home/hero ein –
* je Sprache aus resources/lang/{de,en}/b2in.php. Steht dort schon eine Liste `slides`, bleibt sie,
* wie sie gepflegt ist – nur fehlende Texte für die Infokarte (`caption`) kommen bei den gelieferten
* Bildern dazu. Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=HomeHeroSlidesSeeder
*/
class HomeHeroSlidesSeeder extends Seeder
{
public function run(): void
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first();
if (! $row) {
$this->command?->info('Hero-Slideshow: home/hero nicht im CMS – die Lang-Datei gilt.');
return;
}
$value = $row->getTranslations('value');
$changed = false;
foreach ($value as $locale => $content) {
$defaults = trans('b2in.themes.b2in.hero', [], $locale);
if (! is_array($content) || ! is_array($defaults) || ! is_array($defaults['slides'] ?? null)) {
continue;
}
if (! array_key_exists('slides', $content)) {
$value[$locale] = $content + ['interval' => $defaults['interval'] ?? 7, 'slides' => $defaults['slides']];
$changed = true;
continue;
}
$withCaptions = $this->addMissingCaptions((array) $content['slides'], $defaults['slides']);
if ($withCaptions !== $content['slides']) {
$value[$locale]['slides'] = $withCaptions;
$changed = true;
}
}
if (! $changed) {
$this->command?->info('Hero-Slideshow: Slides bereits gepflegt, nichts geändert.');
return;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache('home');
$this->command?->info('Hero-Slideshow: Bilder bzw. Texte der Infokarte eingetragen.');
}
/**
* Ergänzt `caption` bei Slides, die eines der gelieferten Bilder zeigen und noch keinen Text haben.
*
* @param array<int, mixed> $slides
* @param array<int, array<string, mixed>> $defaults
* @return array<int, mixed>
*/
private function addMissingCaptions(array $slides, array $defaults): array
{
$captions = array_column($defaults, 'caption', 'image');
return array_map(function ($slide) use ($captions) {
if (! is_array($slide) || array_key_exists('caption', $slide) || ! isset($captions[$slide['image'] ?? ''])) {
return $slide;
}
return $slide + ['caption' => $captions[$slide['image']]];
}, $slides);
}
}

View file

@ -13,6 +13,10 @@ use Illuminate\Database\Seeder;
* und persönlichen Lösungen.“ und entfernt den zweiten Button „Termin anfragen“ (der feste
* Termin-Button unten rechts bleibt). Die Überschrift wird nur ersetzt, solange noch die alte
* „B2in Interior – Premium-Einrichtung …“ drinsteht – eine eigene Formulierung im CMS bleibt.
*
* Abstimmung 07.10.2026: Zeile „B2in Interior“ über der Überschrift (`eyebrow`, nur wenn noch
* keine gepflegt ist), Untertitel „Wir vereinen …“ (ersetzt nur den bisherigen) und das erste
* Slideshow-Bild trägt in der Karte „B2in Interior“ statt „Rotpunkt Küchen“.
* Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=HomeHeroTextSeeder
@ -31,6 +35,24 @@ class HomeHeroTextSeeder extends Seeder
],
];
/** @var array<string, array{old: string, new: string}> */
public const SUBTITLES = [
'de' => [
'old' => 'Von der einzelnen Maßanfertigung bis zur vollständigen Ausstattung',
'new' => 'Wir vereinen CABINET, Rotpunkt Küchen, Febrü und ein Netzwerk regionaler Experten, um individuelle Interior-Lösungen für Ihr Zuhause oder Ihr Projekt zu realisieren.',
],
'en' => [
'old' => 'From a single bespoke piece to a complete fit-out',
'new' => 'We bring together CABINET, Rotpunkt Küchen, Febrü and a network of regional experts to create individual interior solutions for your home or your project.',
],
];
public const EYEBROW = 'B2in Interior';
public const FIRST_SLIDE_IMAGE = 'b2in/slideshow/rotpunkt-fenix-house.webp';
public const FIRST_SLIDE_LABEL = ['old' => 'Rotpunkt Küchen', 'new' => 'B2in Interior'];
public function run(): void
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first();
@ -56,6 +78,26 @@ class HomeHeroTextSeeder extends Seeder
$changed = true;
}
$subtitle = self::SUBTITLES[$locale] ?? null;
if ($subtitle && str_contains((string) ($content['subtitle'] ?? ''), $subtitle['old'])) {
$content['subtitle'] = $subtitle['new'];
$changed = true;
}
if (! filled($content['eyebrow'] ?? null)) {
$content['eyebrow'] = self::EYEBROW;
$changed = true;
}
foreach ((array) ($content['slides'] ?? []) as $index => $slide) {
if (is_array($slide) && ($slide['image'] ?? null) === self::FIRST_SLIDE_IMAGE
&& ($slide['label'] ?? null) === self::FIRST_SLIDE_LABEL['old']) {
$content['slides'][$index]['label'] = self::FIRST_SLIDE_LABEL['new'];
$changed = true;
}
}
if (array_key_exists('cta2_text', $content) || array_key_exists('cta2_link', $content)) {
unset($content['cta2_text'], $content['cta2_link']);
$changed = true;
@ -74,6 +116,6 @@ class HomeHeroTextSeeder extends Seeder
$row->save();
app(CmsContentService::class)->clearCache('home');
$this->command?->info('Hero: Überschrift gesetzt, zweiter Button entfernt.');
$this->command?->info('Hero: Texte aktualisiert (Überschrift, Zeile darüber, Untertitel, erste Slide).');
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Startseite, „Gebaut auf Expertise und Vertrauen“ (Abstimmung 07.10.2026): „Expertise und
* Vertrauen“ in der Akzentfarbe. Ersetzt nur die bisherige Überschrift ohne Hervorhebung –
* eine eigene Formulierung im CMS bleibt. Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=HomeVisionTitleSeeder
*/
class HomeVisionTitleSeeder extends Seeder
{
/** @var array<string, array{old: string, new: string}> */
public const TITLES = [
'de' => ['old' => 'Gebaut auf Expertise und Vertrauen', 'new' => 'Gebaut auf <span class="text-secondary">Expertise und Vertrauen</span>'],
'en' => ['old' => 'Built on Expertise and Trust', 'new' => 'Built on <span class="text-secondary">Expertise and Trust</span>'],
];
public function run(): void
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'vision_section')->first();
if (! $row) {
$this->command?->info('Vision: Sektion nicht im CMS – die Lang-Datei gilt.');
return;
}
$value = $row->getTranslations('value');
$changed = false;
foreach ($value as $locale => $content) {
$title = self::TITLES[$locale] ?? null;
if (is_array($content) && $title && ($content['title'] ?? null) === $title['old']) {
$value[$locale]['title'] = $title['new'];
$changed = true;
}
}
if (! $changed) {
$this->command?->info('Vision: bereits aktuell, nichts geändert.');
return;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache('home');
$this->command?->info('Vision: Überschrift mit Hervorhebung gesetzt.');
}
}

View file

@ -51,10 +51,6 @@ class InteriorRelaunchCmsSeeder extends Seeder
'our_story',
'faq',
'contact_form',
'netzwerk_hero',
'netzwerk_teasers',
'netzwerk_image_break',
'netzwerk_cabinet_partner',
];
public function run(): void

View file

@ -0,0 +1,126 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Rechtstexte aktualisieren (06.10.2026, Nachtrag 22).
*
* Impressum: § 5 DDG statt TMG, §§ 7–10 DDG, § 18 Abs. 2 MStV statt § 55 RStV, „Amtsgericht“ ausgeschrieben,
* Überschrift „Verbraucherstreitbeilegung“ (die EU-OS-Plattform ist abgeschaltet). Nutzungsbedingungen:
* Leistungsbeschreibung nach dem Interior-Relaunch, ohne „(WICHTIG!)“. Datenschutz und Cookie-Richtlinie:
* TDDDG statt TTDSG, Rechtsgrundlage für technisch notwendige Cookies, GA4 statt „IP-Anonymisierung“,
* Beispiele für vorvertragliche Anfragen, Abschnitt „Hosting“ (Hetzner, Rechenzentrum Falkenstein) und
* E-Mail über united-domains, Server-Logs höchstens 14 Tage.
* Überall info@b2in.eu (das Impressum nannte info@b2in.de). Stand jeweils 06.10.2026.
*
* Tauscht nur genau diese Stellen in der Gruppe „legal“; im CMS geänderte Texte bleiben sonst unberührt.
* Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=LegalTextUpdateSeeder
*/
class LegalTextUpdateSeeder extends Seeder
{
/** @var array<string, string> alte Stelle → neue Stelle */
public const REPLACEMENTS = [
'Angaben gemäß § 5 TMG' => 'Angaben gemäß § 5 DDG',
'Registergericht: Amtsg. Bad Oeynhausen' => 'Registergericht: Amtsgericht Bad Oeynhausen',
'Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV' => 'Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV',
'>EU-Streitschlichtung<' => '>Verbraucherstreitbeilegung<',
'gemäß § 7 Abs. 1 TMG' => 'gemäß § 7 Abs. 1 DDG',
'Nach §§ 8 bis 10 TMG' => 'Nach §§ 8 bis 10 DDG',
'info@b2in.de' => 'info@b2in.eu',
'Industriestr. 25, 91710 Gunzenhausen, betrieben.' => 'Industriestr. 25, 91710 Gunzenhausen, betrieben; der Server steht in deren Rechenzentrum in Falkenstein (Sachsen), Deutschland.',
'Folgende Informationen werden dabei ohne Ihr Zutun erfasst und bis zur automatisierten Löschung gespeichert:' => 'Folgende Informationen werden dabei ohne Ihr Zutun erfasst, höchstens 14 Tage gespeichert und danach automatisch gelöscht:',
'Stand: 13.03.2026' => 'Stand: 06.10.2026',
'B2in ist das Netzwerk, das exklusives Design und internationale Immobilien verbindet („Connecting Design and Property“). Über unsere Website bieten wir unverbindliche Informationen zu unseren Dienstleistungen, Immobilienprojekten (z. B. in Dubai oder Lissabon), Supply-Chain-Management und unserem Einrichtungsnetzwerk an.' => 'Unter B2in Interior bieten wir hochwertige Einrichtung mit ausgewählten Marken, Maßanfertigung und persönlicher Beratung für private Räume und Projekte an; ergänzend informieren wir über ausgewählte internationale Immobilienprojekte. Über unsere Website bieten wir unverbindliche Informationen zu unseren Leistungen, Marken, Einrichtungs- und Ausstattungsprojekten sowie zu Immobilienprojekten (z. B. in Dubai) an.',
'Unverbindlichkeit von Immobilien- und Investmentinformationen (WICHTIG!)' => 'Unverbindlichkeit von Immobilien- und Investmentinformationen',
'(z. B. Immobilien-Investment oder Supply-Chain-Management)' => '(z. B. Beratung, Planung und Angebot für Ihre Einrichtung oder Anfragen zu Immobilienprojekten)',
'(z. B. das Speichern Ihrer Consent-Einstellungen). Die Rechtsgrundlage hierfür ist Art. 6 Abs. 1 lit. f DSGVO.' => '(z. B. das Speichern Ihrer Consent-Einstellungen). Die Rechtsgrundlage hierfür ist § 25 Abs. 2 Nr. 2 TDDDG sowie Art. 6 Abs. 1 lit. f DSGVO.',
'(z. B. das Speichern Ihrer Consent-Einstellungen). Sie können nicht deaktiviert werden. Die Rechtsgrundlage hierfür ist Art. 6 Abs. 1 lit. f DSGVO.' => '(z. B. das Speichern Ihrer Consent-Einstellungen). Sie können nicht deaktiviert werden. Die Rechtsgrundlage hierfür ist § 25 Abs. 2 Nr. 2 TDDDG sowie Art. 6 Abs. 1 lit. f DSGVO.',
'§ 25 Abs. 1 TTDSG/TDDDG' => '§ 25 Abs. 1 TDDDG',
'<strong>IP-Anonymisierung:</strong> Wir haben auf dieser Website die Funktion IP-Anonymisierung aktiviert. Dadurch wird Ihre IP-Adresse von Google innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum vor der Übermittlung in die USA gekürzt.' => '<strong>IP-Adressen:</strong> Wir setzen Google Analytics 4 ein. Dort werden IP-Adressen nicht protokolliert oder gespeichert; Google nutzt sie lediglich, um daraus grobe Standortdaten abzuleiten, und verwirft sie anschließend.',
];
/**
* Ergänzungen der Datenschutzerklärung: Abschnitt „Hosting“ (Hetzner) mit neuer Nummerierung und
* E-Mail über united-domains, Server-Logs höchstens 14 Tage. Nur anwenden, solange der Hosting-Abschnitt fehlt – sonst würde der
* ergänzte Satz bei jedem Lauf erneut angehängt.
*
* @var array<string, string>
*/
public const PRIVACY_ADDITIONS = [
'<h2 class="text-lg font-semibold text-foreground mb-2">2. Datenerfassung auf unserer Website (Server-Log-Dateien)</h2>' => '<h2 class="text-lg font-semibold text-foreground mb-2">2. Hosting</h2>
<p class="text-muted-foreground">
Unsere Website wird auf einem Cloud-Server der Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, betrieben; der Server steht in deren Rechenzentrum in Falkenstein (Sachsen), Deutschland. Alle beim Aufruf der Website anfallenden Daten – insbesondere die unter Ziffer 3 genannten Server-Log-Daten und die Angaben aus dem Kontaktformular – werden auf diesem Server verarbeitet. Rechtsgrundlage ist unser berechtigtes Interesse an einem sicheren und zuverlässigen Betrieb unserer Website (Art. 6 Abs. 1 lit. f DSGVO). Mit Hetzner besteht ein Vertrag über Auftragsverarbeitung nach Art. 28 DSGVO.
</p>
</div>
<div>
<h2 class="text-lg font-semibold text-foreground mb-2">3. Datenerfassung auf unserer Website (Server-Log-Dateien)</h2>',
'<h2 class="text-lg font-semibold text-foreground mb-2">3. Kontaktformular und E-Mail-Kontakt</h2>' => '<h2 class="text-lg font-semibold text-foreground mb-2">4. Kontaktformular und E-Mail-Kontakt</h2>',
'<h2 class="text-lg font-semibold text-foreground mb-2">4. Cookies</h2>' => '<h2 class="text-lg font-semibold text-foreground mb-2">5. Cookies</h2>',
'<h2 class="text-lg font-semibold text-foreground mb-2">5. Google Analytics</h2>' => '<h2 class="text-lg font-semibold text-foreground mb-2">6. Google Analytics</h2>',
'<h2 class="text-lg font-semibold text-foreground mb-2">6. Externe Links (z. B. Google Maps)</h2>' => '<h2 class="text-lg font-semibold text-foreground mb-2">7. Externe Links (z. B. Google Maps)</h2>',
'<h2 class="text-lg font-semibold text-foreground mb-2">7. SSL- bzw. TLS-Verschlüsselung</h2>' => '<h2 class="text-lg font-semibold text-foreground mb-2">8. SSL- bzw. TLS-Verschlüsselung</h2>',
'<h2 class="text-lg font-semibold text-foreground mb-2">8. Ihre Rechte als betroffene Person</h2>' => '<h2 class="text-lg font-semibold text-foreground mb-2">9. Ihre Rechte als betroffene Person</h2>',
'Diese Daten geben wir nicht ohne Ihre Einwilligung weiter.' => 'Diese Daten geben wir nicht ohne Ihre Einwilligung weiter. Unsere Domains und unser E-Mail-Postfach liegen bei der united-domains GmbH, Gautinger Straße 10, 82319 Starnberg; E-Mails, die Sie uns schreiben, und unsere Antworten werden über deren Server übertragen und dort gespeichert. Auch mit united-domains besteht ein Vertrag über Auftragsverarbeitung nach Art. 28 DSGVO.',
];
public function run(): void
{
$count = 0;
foreach (CmsContent::query()->where('group', 'legal')->get() as $row) {
$value = $row->getTranslations('value');
$updated = $this->replaceDeep($value, $count, self::REPLACEMENTS);
if (! str_contains(json_encode($updated, JSON_UNESCAPED_UNICODE) ?: '', 'Hetzner Online GmbH')) {
$updated = $this->replaceDeep($updated, $count, self::PRIVACY_ADDITIONS);
}
if ($updated !== $value) {
$row->value = $updated;
$row->save();
}
}
app(CmsContentService::class)->clearCache('legal');
$this->command?->info($count > 0
? "Rechtstexte: {$count} Feld(er) aktualisiert."
: 'Rechtstexte: bereits aktuell oder nicht im CMS – nichts geändert.');
}
/**
* @param array<mixed> $data
* @param array<string, string> $replacements
* @return array<mixed>
*/
private function replaceDeep(array $data, int &$count, array $replacements): array
{
foreach ($data as $key => $item) {
if (is_array($item)) {
$data[$key] = $this->replaceDeep($item, $count, $replacements);
continue;
}
if (! is_string($item)) {
continue;
}
$replaced = strtr($item, $replacements);
if ($replaced !== $item) {
$data[$key] = $replaced;
$count++;
}
}
return $data;
}
}

View file

@ -1,90 +0,0 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Lang;
class NetzwerkPageCmsSeeder extends Seeder
{
/**
* Füllt nur fehlende Netzwerk-Seitenblöcke (CABINET-Premiumpartner, Immobilien-Hinweis) aus den Lang-Dateien.
* Bestehende CMS-Einträge werden nicht überschrieben.
*
* @var array<string, array{0: string, 1: string}>
*/
protected array $sections = [
'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'],
'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'],
];
public function run(): void
{
/** @var array<string, mixed> $langDe */
$langDe = Lang::get('b2in.themes.b2in', [], 'de');
/** @var array<string, mixed> $langEn */
$langEn = Lang::get('b2in.themes.b2in', [], 'en');
if (! is_array($langDe)) {
$this->command?->warn('NetzwerkPageCmsSeeder: b2in.themes.b2in (de) fehlt.');
return;
}
if (! is_array($langEn)) {
$langEn = [];
}
$created = 0;
foreach ($this->sections as $sectionKey => [$group, $key]) {
if (CmsContent::query()->where('group', $group)->where('key', $key)->exists()) {
$this->command?->info("CMS {$group}.{$key} existiert bereits – übersprungen.");
continue;
}
$sectionDataDe = $langDe[$sectionKey] ?? null;
if ($sectionDataDe === null) {
$this->command?->warn("Lang-Block {$sectionKey} fehlt – übersprungen.");
continue;
}
$sectionDataEn = $langEn[$sectionKey] ?? $sectionDataDe;
CmsContent::query()->create([
'group' => $group,
'key' => $key,
'type' => $this->detectType($sectionDataDe),
'value' => [
'de' => $sectionDataDe,
'en' => $sectionDataEn,
],
'order' => 0,
]);
$created++;
$this->command?->info("CMS {$group}.{$key} angelegt.");
}
if ($created > 0) {
app(CmsContentService::class)->clearCache();
}
}
private function detectType(mixed $data): string
{
if (is_array($data)) {
return 'json';
}
if (is_string($data) && preg_match('/<[^>]+>/', $data)) {
return 'html';
}
return 'text';
}
}

View file

@ -128,7 +128,7 @@ Sometimes a room needs more than a wardrobe or a sideboard. A staircase becomes
['title' => 'Individuelle Möbel & Sonderanfertigungen', 'description' => 'Möbel nach Maß, besondere Konstruktionen und individuelle Lösungen – nach Planung, Idee, Skizze oder konkreter Anforderung.'],
['title' => 'Innenausbau', 'description' => 'Von einzelnen Ausbauarbeiten bis zum ganzheitlichen Raumkonzept – inklusive individueller Verkleidungen und maßgefertigter Einbauten.'],
['title' => 'Türen & Treppen', 'description' => 'Individuelle Türen, Treppen und konstruktive Lösungen, die sich in Architektur und Einrichtung einfügen.'],
['title' => 'Küchen', 'description' => 'Individuelle Küchenlösungen und handwerkliche Ergänzungen auch dort, wo klassische Küchenprogramme an ihre Grenzen kommen.'],
['title' => 'Küchen', 'description' => 'Individuelle Küchenlösungen und handwerkliche Ergänzungen auch dort, wo klassische Küchenprogramme an ihre Grenzen stoßen.'],
['title' => 'Wandverkleidungen & besondere Details', 'description' => 'Holzverkleidungen, Lamellen, Paneele und individuelle Gestaltungselemente, die Architektur und Einrichtung miteinander verbinden.'],
['title' => 'Objektbau', 'description' => 'Individuelle Lösungen für Büros, Verkaufsräume und gewerblich genutzte Flächen – vom Einzelmöbel bis zum kompletten Ausbau.'],
['title' => 'Lack & Echtholzfurnier', 'description' => 'Individuelle Lackierungen und Furniere für Lösungen, bei denen Farbe, Material und Oberfläche exakt aufeinander abgestimmt werden sollen.'],
@ -167,7 +167,7 @@ Sometimes a room needs more than a wardrobe or a sideboard. A staircase becomes
'title' => 'Montage',
'paragraphs' => [
'Eine perfekte Montage ist keine Glückssache, sondern das Ergebnis starker Logistik. Wir sorgen dafür, dass alles Hand in Hand geht:',
'- Sichere Prüfung & Terminierung: Wir vereinbaren Ihren Montagetermin erst, wenn Ihre Lieferung vollständig und unversehrt bei uns geprüft wurde. Das garantiert Ihnen absolute Planungssicherheit.',
'- Sichere Prüfung & Terminierung: Wir vereinbaren Ihren Montagetermin erst, wenn Ihre Lieferung bei uns eingegangen und auf Vollständigkeit und Unversehrtheit geprüft ist. Das erhöht die Planungssicherheit deutlich.',
'- Perfekt vorbereitete Lieferung: Wir prüfen vorab die Anliefersituation bei Ihnen vor Ort. Die Lieferung erfolgt mit unserem modernen Fuhrpark, optimal geschützt durch Spezialfolien und Decken.',
'- Flexibler Renovierungsschutz: Ihr Bauprojekt verzögert sich? Kein Problem. Wir lagern Ihre Möbel fachgerecht und sicher ein.',
'- Erweiterter Komfort: Auf Wunsch liefern wir Umzugskartons, lagern Möbel zwischen oder passen vorhandene Möbelstücke direkt an.',

View file

@ -0,0 +1,95 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Zeile über der Hero-Überschrift (`eyebrow`) auf Marken, Über uns und Kontakt (Abstimmung
* 07.10.2026, wie auf der Startseite). Ergänzt sie in den CMS-Einträgen aus der Lang-Datei,
* nur wenn dort noch keine gepflegt ist – oder noch der erste Vorschlag „Unsere Marken“ drinsteht
* (seit 07.10. auch auf Marken „B2in Interior“). Auf Über uns fällt das vorangestellte
* „Über B2in:“ aus der Überschrift, das steht jetzt in der Zeile darüber.
* Fehlt ein Eintrag im CMS, gilt ohnehin die Lang-Datei. Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=PageHeroEyebrowSeeder
*/
class PageHeroEyebrowSeeder extends Seeder
{
/** @var array<string, array{group: string, key: string, lang: string, path: list<string>}> */
public const SECTIONS = [
'marken' => ['group' => 'marken', 'key' => 'hero', 'lang' => 'marken_hero', 'path' => []],
'about' => ['group' => 'about', 'key' => 'hero', 'lang' => 'about_hero', 'path' => []],
'contact' => ['group' => 'contact', 'key' => 'contact_form', 'lang' => 'contact_form', 'path' => ['hero']],
];
/** @var list<string> Frühere Vorschläge, die durch den Text der Lang-Datei ersetzt werden */
public const FORMER_EYEBROWS = ['Unsere Marken', 'Our brands'];
/** @var array<string, list<string>> Abschnitt → Präfixe, die aus der Überschrift fallen */
public const TITLE_PREFIXES = [
'about' => [
'<span class="text-secondary">Über B2in:</span> ',
'<span class="text-secondary">About B2in:</span> ',
],
];
public function run(): void
{
$updated = [];
foreach (self::SECTIONS as $name => $section) {
$row = CmsContent::query()->where('group', $section['group'])->where('key', $section['key'])->first();
if (! $row) {
continue;
}
$value = $row->getTranslations('value');
$changed = false;
foreach ($value as $locale => $content) {
if (! is_array($content)) {
continue;
}
$langPath = implode('.', ['b2in.themes.b2in', $section['lang'], ...$section['path'], 'eyebrow']);
$eyebrow = trans($langPath, [], $locale);
$fieldPath = implode('.', [...$section['path'], 'eyebrow']);
$current = data_get($content, $fieldPath);
if ($eyebrow !== $langPath && $current !== $eyebrow
&& (! filled($current) || in_array($current, self::FORMER_EYEBROWS, true))) {
data_set($content, $fieldPath, $eyebrow);
$changed = true;
}
foreach (self::TITLE_PREFIXES[$name] ?? [] as $prefix) {
$titlePath = implode('.', [...$section['path'], 'title']);
$title = (string) data_get($content, $titlePath, '');
if (str_starts_with($title, $prefix)) {
data_set($content, $titlePath, substr($title, strlen($prefix)));
$changed = true;
}
}
$value[$locale] = $content;
}
if ($changed) {
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache($section['group']);
$updated[] = $name;
}
}
$this->command?->info($updated === []
? 'Hero-Zeilen: bereits aktuell, nichts geändert.'
: 'Hero-Zeilen ergänzt: '.implode(', ', $updated).'.');
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace Database\Seeders;
use Database\Seeders\Concerns\ReplacesCmsTexts;
use Illuminate\Database\Seeder;
/**
* Lektorat aller angezeigten Texte (06.10.2026, Nachtrag 22).
*
* Tauscht genau die Textstellen aus {@see self::REPLACEMENTS} – Tippfehler, gerade Anführungszeichen,
* Kommas, „…“ statt „...“, Otterpohl als vierte Kernmarke in Aufzählungen, „die Tischlerei Otterpohl“
* statt „unsere Tischlerei“ – in allen CMS-Sektionen
* (auch Rechtstexten), Marken und Magazinbeiträgen. Alles andere bleibt; steht eine Stelle nicht
* (mehr) drin, passiert dort nichts. Mehrfaches Ausführen ist unbedenklich. Die Lang-Dateien und
* Marken-Seeder tragen dieselben Korrekturen für frische Installationen.
*
* php artisan db:seed --class=ProofreadingCorrectionsSeeder
*/
class ProofreadingCorrectionsSeeder extends Seeder
{
use ReplacesCmsTexts;
/** @var array<string, string> alte Stelle → neue Stelle */
public const REPLACEMENTS = [
'Rotpunkt Küchen jetzt bei B2in Interior – jetzt Beratungstermin sichern' => 'Rotpunkt Küchen neu bei B2in Interior – jetzt Beratungstermin sichern',
'CABINET, Rotpunkt Küchen, Febrü und ein Netzwerk' => 'CABINET, Rotpunkt Küchen, Febrü, Otterpohl Möbelservice und ein Netzwerk',
'mit CABINET, Rotpunkt Küchen, Febrü und einem Netzwerk' => 'mit CABINET, Rotpunkt Küchen, Febrü, Otterpohl Möbelservice und einem Netzwerk',
'CABINET, Rotpunkt Küchen und Febrü –' => 'CABINET, Rotpunkt Küchen, Febrü und Otterpohl Möbelservice –',
'von der Montage über das Aufmaß bis zu' => 'vom Aufmaß über die Montage bis zu',
'"Meine Mission' => '„Meine Mission',
'Zuhause der Menschen."' => 'Zuhause der Menschen.“',
'Unser Kontakt-Nummer' => 'Unsere Telefonnummer',
'Unser Büro-Standort' => 'Unser Bürostandort',
'Ihre Nachricht...' => 'Ihre Nachricht …',
'Wird gesendet...' => 'Wird gesendet …',
'Bitte wählen...' => 'Bitte wählen …',
'Warum das Dubai Escrow-System' => 'Warum das Dubai-Escrow-System',
'einen der sichersten und transparentesten regulatorischen Rahmenwerke der Welt geschaffen, der Investorengelder' => 'eines der sichersten und transparentesten regulatorischen Rahmenwerke der Welt geschaffen, das Investorengelder',
'"Hidden Champions"' => '„Hidden Champions“',
'Diese Logistik macht den Standort unvergleichlich.' => 'Diese Anbindung macht den Standort unvergleichlich.',
'über Tausende Kilometer Entfernung' => 'aus Tausenden Kilometern Entfernung',
'die in Dubai florierende Umsätze generiert' => 'die in Dubai hohe Umsätze generiert',
'"Turnkey"' => '„Turnkey“',
'oft gesichert durch Zugänge zu deutschen' => 'oft gesichert durch Zugang zu deutschen',
'um sofortige Lösungen zu erzwingen' => 'um schnelle Lösungen zu erzwingen',
'("Hidden Gems")' => '(„Hidden Gems“)',
'Das Konzept "Local for Local"' => 'Das Konzept „Local for Local“',
'"Was ist heute in meiner Nähe verfügbar?"' => '„Was ist heute in meiner Nähe verfügbar?“',
'Support your Locals – Ein Gewinn' => 'Support your Locals – ein Gewinn',
'Das "David gegen Goliath"-Prinzip' => 'Das „David-gegen-Goliath“-Prinzip',
'der für einen Schrank zu schmal und für nichts zu breit ist' => 'der für einen Schrank zu schmal und zum Leerlassen zu breit ist',
'„Maß oder nicht"' => '„Maß oder nicht“',
'Registergericht: Amtsg.' => 'Registergericht: Amtsg.',
'Irland („Google")' => 'Irland („Google“)',
'von „http://" auf „https://" wechselt' => 'von „http://“ auf „https://“ wechselt',
'(„B2in", „wir", „uns")' => '(„B2in“, „wir“, „uns“)',
'(„Connecting Design and Property")' => '(„Connecting Design and Property“)',
'<strong>B2In GmbH</strong>' => '<strong>B2in GmbH</strong>',
'Unsere Aufgabe ist es herauszufinden,' => 'Unsere Aufgabe ist es, herauszufinden,',
'Für den Anfang reicht uns ein Bild und ein grobes Maß völlig aus.' => 'Für den Anfang reichen uns ein Bild und ein grobes Maß völlig aus.',
'vom durchdachten Home-Office' => 'vom durchdachten Homeoffice',
'Ob Home-Office oder ganze Büroetage' => 'Ob Homeoffice oder ganze Büroetage',
'ins Zuhause fügt statt es zu besetzen' => 'ins Zuhause fügt, statt es zu besetzen',
'Ruhe und Flexibilität sind planbar.' => 'Ruhe und Flexibilität sind planbar',
'erst, wenn Ihre Lieferung vollständig und unversehrt bei uns geprüft wurde' => 'erst, wenn Ihre Lieferung bei uns eingegangen und auf Vollständigkeit und Unversehrtheit geprüft ist',
'an ihre Grenzen kommen' => 'an ihre Grenzen stoßen',
'mit unserer Tischlerei, die auf Wunsch' => 'mit der Tischlerei Otterpohl, die auf Wunsch',
'Unsere Tischlerei baut auf und ergänzt' => 'Die Tischlerei Otterpohl baut auf und ergänzt',
];
/** @var array<string, string> ganze Werte, die nur als Ganzes getauscht werden (Leistungstitel) */
public const EXACT_VALUES = [
'Home-Office' => 'Homeoffice',
];
public function run(): void
{
$count = $this->replaceCmsTexts(self::REPLACEMENTS, self::EXACT_VALUES);
$this->command?->info($count > 0
? "Lektorat: {$count} Textstelle(n) korrigiert."
: 'Lektorat: bereits aktuell, nichts geändert.');
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\Setting;
use Illuminate\Database\Seeder;
/**
* Blendet die Seite /referenzen samt Menüpunkt aus (Abstimmung 06.10.2026: noch keine Referenzen).
*
* Legt die Einstellung pages.referenzen_enabled mit „aus“ an – aber nur, wenn es sie noch nicht gibt.
* Wurde der Schalter im CMS-Modul „Referenzen“ schon bedient, bleibt dessen Stand.
*
* php artisan db:seed --class=ReferencesPageSwitchSeeder
*/
class ReferencesPageSwitchSeeder extends Seeder
{
public function run(): void
{
$setting = Setting::query()->firstOrCreate(
['group' => 'pages', 'key' => 'referenzen_enabled'],
['value' => '0', 'type' => 'boolean', 'description' => 'Seite /referenzen und Menüpunkt „Referenzen“ anzeigen'],
);
$this->command?->info($setting->wasRecentlyCreated
? 'Referenzen: Seite und Menüpunkt ausgeblendet.'
: 'Referenzen: Schalter steht bereits auf „'.($setting->value === '1' ? 'an' : 'aus').'“ – unverändert.');
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
/**
* Räumt das CMS auf (Nachtrag 22, 06.10.2026): Sektionen, die keine erreichbare Seite mehr
* anzeigt, werden gelöscht – die alten Seiten Netzwerk, Partner, Ecosystem und Interior
* (leiten auf /marken um), die frühere Immobilien-Seite, die Startseiten-Blöcke vor dem
* Interior-Relaunch und das Führungsteam. Seit 07.10.2026 auch der Hero des alten Entwurfs
* (`home.hero_slider`, Seite /entwurf).
*
* Vor dem Löschen schreibt der Seeder die betroffenen Einträge vollständig (beide Sprachen)
* nach storage/app/private/cms-archiv/cms-content-geloescht-{Zeitstempel}.json (Disk „local“). Außerdem:
* - „Abschluss auf Über uns“ zieht von netzwerk/partner_cta nach about/partner_cta – die
* Gruppe „netzwerk“ gibt es danach nicht mehr.
* - Felder, die keine Vorlage ausgibt, fallen aus den betroffenen Sektionen heraus
* ({@see self::UNUSED_FIELDS}) – etwa die Kennzahlen im Abschluss der Über-uns-Seite.
*
* Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=UnusedCmsContentCleanupSeeder
*/
class UnusedCmsContentCleanupSeeder extends Seeder
{
/** @var list<string> Gruppe.Schlüssel der toten Sektionen */
public const UNUSED = [
'about.leadership_team',
'home.brand_worlds', 'home.ecosystem_core', 'home.hero_slider', 'home.integriertes_modell_b2in', 'home.synergie_section',
'immobilien.bruecke', 'immobilien.hero', 'immobilien.hero_v2', 'immobilien.image_break', 'immobilien.kaufprozess',
'immobilien.mindset', 'immobilien.moebel_vorteil', 'immobilien.portfolio', 'immobilien.projects_meta',
'immobilien.trust', 'immobilien.warum_dubai',
'global.cta_section_portfolio',
'netzwerk.broker_section', 'netzwerk.cabinet_partner', 'netzwerk.commitment_section', 'netzwerk.cta',
'netzwerk.dark_stats_section', 'netzwerk.digital_core', 'netzwerk.ecosystem_hero', 'netzwerk.ecosystem_hub',
'netzwerk.ecosystem_result', 'netzwerk.ecosystem_start', 'netzwerk.ecosystem_stats', 'netzwerk.end_customer_section',
'netzwerk.final_commitment', 'netzwerk.hero', 'netzwerk.image_break', 'netzwerk.immobilien_hint',
'netzwerk.interior_brands', 'netzwerk.interior_concept', 'netzwerk.interior_hero', 'netzwerk.interior_process',
'netzwerk.interior_trust', 'netzwerk.interior_zielgruppen', 'netzwerk.partner_benefits',
'netzwerk.partner_benefits_broker', 'netzwerk.partner_benefits_developer', 'netzwerk.partner_benefits_retailer',
'netzwerk.partner_benefits_supplier', 'netzwerk.partner_card_section', 'netzwerk.partner_hero',
'netzwerk.partner_process', 'netzwerk.supplier_section', 'netzwerk.supply_chain_intro', 'netzwerk.teasers',
];
/** @var array<string, list<string>> Gruppe.Schlüssel → Felder, die keine Vorlage ausgibt */
public const UNUSED_FIELDS = [
'about.partner_cta' => ['stats'],
'contact.contact_form' => ['social_media'],
'magazin.list' => ['load_more'],
'immobilien.magazin_list' => ['load_more'],
'magazin.detail' => ['share_article'],
// Kacheln kommen aus der Markenverwaltung; die Liste war im CMS ohne Wirkung (07.10.2026).
'home.core_brands' => ['brands'],
];
public function run(): void
{
$moved = $this->movePartnerCta();
$stripped = $this->stripUnusedFields();
if ($stripped > 0) {
$this->command?->info("CMS-Aufräumen: ungenutzte Felder aus {$stripped} Sektion(en) entfernt.");
}
$rows = $this->unusedRows();
if ($rows === []) {
$this->command?->info('CMS-Aufräumen: keine toten Sektionen (mehr) vorhanden'.($moved ? ', partner_cta verschoben.' : '.'));
return;
}
$path = 'cms-archiv/cms-content-geloescht-'.now()->format('Y-m-d_His').'.json';
Storage::disk('local')->put($path, json_encode(
array_map(fn (CmsContent $row) => [
'group' => $row->group,
'key' => $row->key,
'type' => $row->type,
'order' => $row->order,
'value' => $row->getTranslations('value'),
], $rows),
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
));
$groups = [];
foreach ($rows as $row) {
$groups[$row->group] = true;
$row->delete();
}
foreach (array_keys($groups) as $group) {
app(CmsContentService::class)->clearCache($group);
}
$this->command?->info(sprintf(
'CMS-Aufräumen: %d tote Sektionen gelöscht, Sicherung unter %s%s',
count($rows),
Storage::disk('local')->path($path),
$moved ? ', partner_cta nach „about“ verschoben.' : '.',
));
}
/**
* @return list<CmsContent>
*/
private function unusedRows(): array
{
return CmsContent::query()->get()
->filter(fn (CmsContent $row) => in_array($row->group.'.'.$row->key, self::UNUSED, true))
->values()
->all();
}
/**
* Zieht den Abschluss der Über-uns-Seite in die Gruppe „about“.
*/
private function movePartnerCta(): bool
{
$old = CmsContent::query()->where('group', 'netzwerk')->where('key', 'partner_cta')->first();
$current = CmsContent::query()->where('group', 'about')->where('key', 'partner_cta')->first();
$changed = false;
if ($old && ! $current) {
$old->group = 'about';
$old->save();
$current = $old;
$changed = true;
} elseif ($old) {
$old->delete();
$changed = true;
}
if ($changed) {
app(CmsContentService::class)->clearCache('netzwerk');
app(CmsContentService::class)->clearCache('about');
}
return $changed;
}
/**
* Entfernt die Felder aus {@see self::UNUSED_FIELDS} in allen Sprachen.
*/
private function stripUnusedFields(): int
{
$count = 0;
foreach (self::UNUSED_FIELDS as $section => $fields) {
[$group, $key] = explode('.', $section, 2);
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
if (! $row) {
continue;
}
$value = $row->getTranslations('value');
$stripped = array_map(
fn ($content) => is_array($content) ? array_diff_key($content, array_flip($fields)) : $content,
$value,
);
if ($stripped === $value) {
continue;
}
$row->value = $stripped;
$row->save();
app(CmsContentService::class)->clearCache($group);
$count++;
}
return $count;
}
}