Compare commits
13 commits
master
...
wip/featur
| Author | SHA1 | Date | |
|---|---|---|---|
| 268a1f5f54 | |||
| 3211569fee | |||
| 90aa079cf7 | |||
| 7953a8c9a1 | |||
| 1242d4bce8 | |||
| 3958a1fe89 | |||
| c8899e2d7d | |||
| 093e0ece51 | |||
| 3ee7b44a77 | |||
| f971f416db | |||
| a52347845e | |||
| 4ef3a4d8f1 | |||
| 6d53243ea6 |
343 changed files with 20149 additions and 834 deletions
|
|
@ -80,3 +80,8 @@ DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
|
|||
# Nützlich in der Entwicklung, um Seed-Daten und manuelle Testeinträge zu erhalten.
|
||||
# Hinweis: Migrationen müssen mindestens einmal für die Test-DB ausgeführt werden: DB_DATABASE=testing sail artisan migrate
|
||||
# TESTING_PRESERVE_DATABASE=false
|
||||
|
||||
# Seed-Admin (DatabaseSeeder)
|
||||
SEED_ADMIN_NAME="Admin"
|
||||
SEED_ADMIN_EMAIL=admin@b2in.test
|
||||
SEED_ADMIN_PASSWORD=
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -57,4 +57,14 @@ Icon
|
|||
_static/
|
||||
_work/
|
||||
_storage/
|
||||
dev/immobilien 07-05-2026/AZIZI SharePoint
|
||||
dev/immobilien 07-05-2026/AZIZI SharePoint
|
||||
# Deployment-Archive werden bei Bedarf neu erzeugt
|
||||
dev/**/deploy/*.tar.gz
|
||||
|
||||
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
|
||||
/_b2in_interior_shop/
|
||||
/docs/
|
||||
|
||||
# Original-Bildzulieferungen (mehrere MB je Datei) bleiben ausserhalb der Versionierung;
|
||||
# die optimierten WebP-Fassungen liegen unter public/img/assets/marken/.
|
||||
/dev/2026-08-28/images/
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ Domains are configured in `config/domains.php` with environment variables:
|
|||
|
||||
Domain-specific configurations include:
|
||||
- **portal.b2in.test**: Admin backend with Flux UI theme
|
||||
- **b2in.test**: Anthracite/Dynamic Blue color scheme, Inter/IBM Plex Sans fonts
|
||||
- **b2in.test**: two color worlds on one theme – Interior (default: Tanne `#202c2b`, Creme `#f7f4ef`, Sand `#d9cbbf`, Ocker `#d39e46`, Clay `#a38f82`, Anthrazit `#2d2926`; Inter/EB Garamond) and Immobilien (`body.world-immobilien`: Anthracite/Dynamic Blue, Inter/IBM Plex Sans). The world comes from `b2in_world()` (app/helpers.php), the logo per world from `ThemeHelper::getLogoPath()`
|
||||
- **b2a.test**: Azur Blue/Liberty Red color scheme, Inter/Merriweather fonts
|
||||
- **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts
|
||||
- **style2own.test**: Imperial Blue/Sand Gold color scheme, Inter/EB Garamond fonts
|
||||
|
|
|
|||
|
|
@ -5,11 +5,25 @@ namespace App\Helpers;
|
|||
class ThemeHelper
|
||||
{
|
||||
/**
|
||||
* Get the logo path based on the current theme
|
||||
* Logo-Pfad für das aktuelle Theme.
|
||||
*
|
||||
* Für b2in hängt die Datei zusätzlich an der Farbwelt: die Interior-Welt trägt das Logo
|
||||
* mit dem Zusatz „Interior“ und ockerfarbenem Bogen, die Immobilien-Welt das Logo ohne
|
||||
* Zusatz mit blauem Bogen. Ohne Angabe wird die Welt aus der aktuellen Route gelesen.
|
||||
*/
|
||||
public static function getLogoPath(string $type = 'positive'): string
|
||||
public static function getLogoPath(string $type = 'positive', ?string $world = null): string
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
if ($theme === 'b2in' && ($world ?? b2in_world()) === 'immobilien') {
|
||||
return self::getLogoPathForBrand('b2in-immobilien', $type);
|
||||
}
|
||||
|
||||
// Entwurf in der Farbwelt vom 24.09. (Nachtrag 19): Bogen in Gold statt Ocker.
|
||||
if ($theme === 'b2in' && b2in_palette() === 'greige') {
|
||||
return "img/logos/b2in-interior-logo-gold-{$type}.svg";
|
||||
}
|
||||
|
||||
return self::getLogoPathForBrand($theme, $type);
|
||||
}
|
||||
|
||||
|
|
@ -22,21 +36,25 @@ class ThemeHelper
|
|||
|
||||
$logoMap = [
|
||||
'b2in' => [
|
||||
'positive' => 'img/logos/b2in-interior-logo-positive.svg',
|
||||
'negative' => 'img/logos/b2in-interior-logo-negative.svg',
|
||||
],
|
||||
'b2in-immobilien' => [
|
||||
'positive' => 'img/logos/b2in-logo-positive.svg',
|
||||
'negative' => 'img/logos/b2in-logo-negative.svg'
|
||||
'negative' => 'img/logos/b2in-logo-negative.svg',
|
||||
],
|
||||
'b2a' => [
|
||||
'positive' => 'img/logos/b2a-logo-positiv.svg',
|
||||
'negative' => 'img/logos/b2a-logo-negativ.svg'
|
||||
'negative' => 'img/logos/b2a-logo-negativ.svg',
|
||||
],
|
||||
'stileigentum' => [
|
||||
'positive' => 'img/logos/stileigentum-logo-positiv.svg',
|
||||
'negative' => 'img/logos/stileigentum-logo-negativ.svg'
|
||||
'negative' => 'img/logos/stileigentum-logo-negativ.svg',
|
||||
],
|
||||
'style2own' => [
|
||||
'positive' => 'img/logos/style2own-logo-positiv.svg',
|
||||
'negative' => 'img/logos/style2own-logo-negativ.svg'
|
||||
]
|
||||
'negative' => 'img/logos/style2own-logo-negativ.svg',
|
||||
],
|
||||
];
|
||||
|
||||
return $logoMap[$brand][$type] ?? $logoMap['b2in'][$type];
|
||||
|
|
@ -48,6 +66,7 @@ class ThemeHelper
|
|||
public static function getFaviconPath(): string
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
return "img/favicons/{$theme}-favicon.ico";
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +76,7 @@ class ThemeHelper
|
|||
public static function getThemeCssPath(): string
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
return "resources/css/web/theme-{$theme}.css";
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +86,7 @@ class ThemeHelper
|
|||
public static function getDomainConfig(): array
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
return config("domains.domains.{$theme}", []);
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +96,7 @@ class ThemeHelper
|
|||
public static function getPrimaryColor(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['color_scheme']['primary'] ?? '#2b3f51';
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +106,7 @@ class ThemeHelper
|
|||
public static function getSecondaryColor(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['color_scheme']['secondary'] ?? '#20a0da';
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +116,7 @@ class ThemeHelper
|
|||
public static function getPrimaryFont(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['font_family']['primary'] ?? 'Inter';
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +126,7 @@ class ThemeHelper
|
|||
public static function getSecondaryFont(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['font_family']['secondary'] ?? 'IBM Plex Sans';
|
||||
}
|
||||
|
||||
|
|
@ -111,12 +136,14 @@ class ThemeHelper
|
|||
public static function getDomainName(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['domain_name'] ?? 'B2IN';
|
||||
}
|
||||
|
||||
public static function getDomainUrl(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['url'] ?? config('app.url');
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +154,7 @@ class ThemeHelper
|
|||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
$config = config("domains.domains.{$brand}", []);
|
||||
|
||||
|
||||
return [
|
||||
'primary' => $config['color_scheme']['primary'] ?? '#2b3f51',
|
||||
'secondary' => $config['color_scheme']['secondary'] ?? '#20a0da',
|
||||
|
|
@ -141,14 +168,14 @@ class ThemeHelper
|
|||
public static function getBrandName(?string $brand = null): string
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
|
||||
|
||||
$brandNames = [
|
||||
'b2in' => 'B2IN',
|
||||
'b2a' => 'B2A',
|
||||
'stileigentum' => 'StilEigentum',
|
||||
'style2own' => 'Style2Own',
|
||||
];
|
||||
|
||||
|
||||
return $brandNames[$brand] ?? strtoupper($brand);
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +185,7 @@ class ThemeHelper
|
|||
public static function getBrandConfig(?string $brand = null): array
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
|
||||
return config("domains.domains.{$brand}", []);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
app/Http/Controllers/DisplayEmbedController.php
Normal file
34
app/Http/Controllers/DisplayEmbedController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Display;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* Öffentliche, schreibgeschützte Einbettung eines Displays auf der Website.
|
||||
*
|
||||
* Bewusst getrennt von {@see \App\Http\Controllers\Api\DisplayPreviewController}: Die
|
||||
* Preview-Routen arbeiten mit `preview_token` und zeigen den **Entwurf**. Nach außen darf weder
|
||||
* ein Token noch ein unveröffentlichter Stand gelangen. Diese Route kennt nur die numerische ID
|
||||
* und liefert ausschließlich veröffentlichte Playlists aktiver Displays.
|
||||
*/
|
||||
class DisplayEmbedController extends Controller
|
||||
{
|
||||
public function show(Display $display): BinaryFileResponse
|
||||
{
|
||||
abort_unless($display->is_active, 404);
|
||||
|
||||
$playlist = $display->livePlaylist()->withCount('modules')->first();
|
||||
|
||||
abort_if($playlist === null || $playlist->modules_count === 0, 404);
|
||||
|
||||
// Der Player liest die ID aus dem Pfad (/display/{id}) und holt sich die Konfiguration
|
||||
// über die öffentliche Route /api/display/{display}/config – dieselbe Quelle wie das
|
||||
// physische Display. Keine doppelte Pflege.
|
||||
return response()->file(public_path('_cabinet/display/index.html'), [
|
||||
'Cache-Control' => 'no-cache, must-revalidate',
|
||||
'X-Robots-Tag' => 'noindex',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -98,13 +98,35 @@ class CabinetInfoTablet extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
|
||||
* Zeit-Properties, die beim Speichern aus dem DOM uebernommen werden duerfen.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function hourProperties(): array
|
||||
{
|
||||
return [
|
||||
'hoursMondayOpen', 'hoursMondayClose',
|
||||
'hoursTuesdayOpen', 'hoursTuesdayClose',
|
||||
'hoursWednesdayOpen', 'hoursWednesdayClose',
|
||||
'hoursThursdayOpen', 'hoursThursdayClose',
|
||||
'hoursFridayOpen', 'hoursFridayClose',
|
||||
'hoursSaturdayOpen', 'hoursSaturdayClose',
|
||||
'hoursSundayOpen', 'hoursSundayClose',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Flux-Zeit-Picker meldet das Leeren per X nicht zuverlaessig an wire:model.
|
||||
* Deshalb liest das Formular die Werte beim Absenden aus dem DOM und uebergibt sie hier.
|
||||
* Nur die Zeitfelder duerfen so gesetzt werden – alles andere kommt aus wire:model.
|
||||
*
|
||||
* @param array<string, string|null> $hours Zeit-Picker-Werte aus dem DOM
|
||||
*/
|
||||
public function save(array $hours = []): void
|
||||
{
|
||||
foreach ($hours as $prop => $value) {
|
||||
if (property_exists($this, $prop)) {
|
||||
$this->{$prop} = $value ?? '';
|
||||
foreach ($this->hourProperties() as $prop) {
|
||||
if (array_key_exists($prop, $hours)) {
|
||||
$this->{$prop} = is_string($hours[$prop]) ? $hours[$prop] : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public string $mediaSubline = '';
|
||||
|
||||
public int $mediaDuration = 10;
|
||||
/** Anzeigedauer eines Bildes in Sekunden als Eingabetext, Komma oder Punkt erlaubt („1,5“). */
|
||||
public string $mediaDuration = '10';
|
||||
|
||||
public bool $mediaIsActive = true;
|
||||
|
||||
|
|
@ -247,6 +248,12 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public function saveItem(): void
|
||||
{
|
||||
if ($this->itemType === 'media' && $this->mediaType === 'image' && self::parseDuration($this->mediaDuration) === null) {
|
||||
$this->addError('mediaDuration', 'Bitte eine Dauer zwischen 0,5 und 120 Sekunden angeben, z. B. 1,5.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $this->buildItemContent();
|
||||
$isActive = $this->getActiveFlag();
|
||||
|
||||
|
|
@ -279,6 +286,41 @@ class DisplayVersionEditor extends Component
|
|||
$this->refreshModulePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sekunden aus der Eingabe lesen: „1,5“, „1.5“ und „10“ sind gültig, gerundet auf Zehntel.
|
||||
* Erlaubt sind 0,5 bis 120 Sekunden; alles andere ergibt null.
|
||||
*/
|
||||
public static function parseDuration(string|int|float $input): ?float
|
||||
{
|
||||
$normalized = str_replace(',', '.', trim((string) $input));
|
||||
|
||||
if ($normalized === '' || ! is_numeric($normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = round((float) $normalized, 1);
|
||||
|
||||
if ($seconds < 0.5 || $seconds > 120) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sekunden für die Anzeige: ganze Zahlen ohne Nachkommastelle, sonst mit Komma („1,5“).
|
||||
*/
|
||||
public static function formatDuration(int|float|string|null $seconds): string
|
||||
{
|
||||
$seconds = (float) str_replace(',', '.', (string) ($seconds ?? 10));
|
||||
|
||||
if (floor($seconds) === $seconds) {
|
||||
return (string) (int) $seconds;
|
||||
}
|
||||
|
||||
return str_replace('.', ',', (string) round($seconds, 1));
|
||||
}
|
||||
|
||||
public function deleteItem(int $id): void
|
||||
{
|
||||
DisplayVersionItem::findOrFail($id)->delete();
|
||||
|
|
@ -437,7 +479,7 @@ class DisplayVersionEditor extends Component
|
|||
$this->mediaUrl = $content['media_url'] ?? '';
|
||||
$this->mediaHeadline = $content['headline'] ?? '';
|
||||
$this->mediaSubline = $content['subline'] ?? '';
|
||||
$this->mediaDuration = $content['duration_seconds'] ?? 10;
|
||||
$this->mediaDuration = self::formatDuration($content['duration_seconds'] ?? 10);
|
||||
$this->mediaIsActive = $isActive;
|
||||
}
|
||||
|
||||
|
|
@ -501,7 +543,7 @@ class DisplayVersionEditor extends Component
|
|||
'media_url' => $this->mediaUrl,
|
||||
'headline' => $this->mediaHeadline,
|
||||
'subline' => $this->mediaSubline,
|
||||
'duration_seconds' => $this->mediaDuration,
|
||||
'duration_seconds' => self::parseDuration($this->mediaDuration) ?? 10,
|
||||
],
|
||||
'slide' => [
|
||||
'type' => 'detail',
|
||||
|
|
@ -663,7 +705,7 @@ class DisplayVersionEditor extends Component
|
|||
$this->mediaUrl = '';
|
||||
$this->mediaHeadline = '';
|
||||
$this->mediaSubline = '';
|
||||
$this->mediaDuration = 10;
|
||||
$this->mediaDuration = '10';
|
||||
$this->mediaIsActive = true;
|
||||
$this->slideShowLogo = true;
|
||||
$this->slideLogoUrl = '';
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ class AboutHero extends Component
|
|||
{
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_about_hero). */
|
||||
public string $section = 'about_hero';
|
||||
|
||||
public function mount(string $section = 'about_hero')
|
||||
{
|
||||
$this->content = cms_theme_section('about_hero');
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
25
app/Livewire/Web/Components/Sections/AudienceSwitch.php
Normal file
25
app/Livewire/Web/Components/Sections/AudienceSwitch.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class AudienceSwitch extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('audience_switch');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.audience-switch');
|
||||
}
|
||||
}
|
||||
89
app/Livewire/Web/Components/Sections/BrandDetail.php
Normal file
89
app/Livewire/Web/Components/Sections/BrandDetail.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
class BrandDetail extends Component
|
||||
{
|
||||
public string $slug = '';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $brand = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $otherBrands = [];
|
||||
|
||||
/**
|
||||
* Galerie getrennt nach realisierten Projekten und Inspirationen.
|
||||
*
|
||||
* @var array{projects: array<int, array<string, mixed>>, inspirations: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public array $gallery = ['projects' => [], 'inspirations' => []];
|
||||
|
||||
public function mount(string $slug): void
|
||||
{
|
||||
$brands = CmsBrand::forFrontend();
|
||||
|
||||
abort_unless(isset($brands[$slug]), 404);
|
||||
|
||||
$this->slug = $slug;
|
||||
$this->brand = $brands[$slug];
|
||||
$this->gallery = CmsBrand::groupGallery($this->brand['gallery'] ?? []);
|
||||
$this->otherBrands = collect($brands)
|
||||
->reject(fn (array $brand, string $key) => $key === $slug)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$craft = $this->craftSection($slug, $brands);
|
||||
|
||||
if ($craft !== null) {
|
||||
$this->brand['sections'][] = $craft;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Handwerk-Abschluss (Lang-Sektion `brand_craft`, Nachtrag 15): steht als letzter
|
||||
* Abschnitt auf jeder Markenseite – außer auf der Seite der Handwerksmarke selbst. Der Link
|
||||
* dorthin erscheint nur, wenn diese Marke veröffentlicht ist und eine Detailseite hat.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $brands
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function craftSection(string $slug, array $brands): ?array
|
||||
{
|
||||
$content = cms_theme_section('brand_craft');
|
||||
|
||||
if (! is_array($content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$craftSlug = (string) ($content['brand_slug'] ?? '');
|
||||
|
||||
if ($craftSlug !== '' && $craftSlug === $slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sections = CmsBrand::normalizeSections([$content]);
|
||||
|
||||
if ($sections === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$section = $sections[0];
|
||||
$section['anchor'] = 'handwerk';
|
||||
|
||||
if ($craftSlug !== '' && ! empty($brands[$craftSlug]['has_detail_page'])) {
|
||||
$section['link'] = route('marken.show', ['slug' => $craftSlug]);
|
||||
$section['link_text'] = (string) ($content['link_text'] ?? $brands[$craftSlug]['name'] ?? '');
|
||||
}
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.brand-detail');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/BrandOverview.php
Normal file
25
app/Livewire/Web/Components/Sections/BrandOverview.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
class BrandOverview extends Component
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $brands = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$this->brands = array_values(CmsBrand::forFrontend());
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.brand-overview');
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,13 @@ class CTASection extends Component
|
|||
|
||||
public $section = '';
|
||||
|
||||
public function mount($bg = 'bg-secondary', $section = 'cta_section')
|
||||
public function mount($bg = 'cta-band', $section = 'cta_section')
|
||||
{
|
||||
// Der Standard-Abschluss hat je Welt eigene Texte und fuehrt zur Kontaktseite der Welt.
|
||||
if ($section === 'cta_section' && b2in_world() === 'immobilien') {
|
||||
$section = 'cta_section_immobilien';
|
||||
}
|
||||
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
$this->bg = $bg;
|
||||
|
|
|
|||
30
app/Livewire/Web/Components/Sections/CoreBrands.php
Normal file
30
app/Livewire/Web/Components/Sections/CoreBrands.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
class CoreBrands extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $brands = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('core_brands');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->brands = array_values(CmsBrand::forFrontend());
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.core-brands');
|
||||
}
|
||||
}
|
||||
72
app/Livewire/Web/Components/Sections/DisplayFeed.php
Normal file
72
app/Livewire/Web/Components/Sections/DisplayFeed.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayPlaylist;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Spiegelt ein Showroom-Display im Hochformat (9:16) auf der Website.
|
||||
*
|
||||
* Quelle ist dasselbe veröffentlichte Playlist wie beim physischen Display – Marcel pflegt
|
||||
* weiterhin nur im Display-CMS, hier entsteht keine zweite Pflegestelle.
|
||||
*/
|
||||
class DisplayFeed extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public ?int $displayId = null;
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public bool $showLink = true;
|
||||
|
||||
public function mount(string $bg = '', bool $showLink = true): void
|
||||
{
|
||||
$section = cms_theme_section('display_feed');
|
||||
$content = is_array($section) ? $section : [];
|
||||
|
||||
$this->bg = $bg;
|
||||
$this->showLink = $showLink;
|
||||
$this->displayId = $this->resolveDisplayId($content);
|
||||
|
||||
// Ohne Display bleiben auch die Texte leer – sie hätten sonst nichts zu beschriften und
|
||||
// stünden trotzdem im Livewire-Snapshot der Seite.
|
||||
$this->content = $this->displayId === null ? [] : $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Im CMS hinterlegtes Display, sonst das erste aktive mit veröffentlichter Playlist.
|
||||
*
|
||||
* Ohne passendes Display rendert die Sektion nichts – lieber keine Sektion als ein
|
||||
* Rahmen, in dem sich ewig ein Ladebalken dreht.
|
||||
*
|
||||
* @param array<string, mixed> $content
|
||||
*/
|
||||
private function resolveDisplayId(array $content): ?int
|
||||
{
|
||||
$configured = $content['display_id'] ?? null;
|
||||
|
||||
$query = Display::query()
|
||||
->where('is_active', true)
|
||||
->whereHas('playlists', fn ($q) => $q->where('status', DisplayPlaylist::STATUS_PUBLISHED)
|
||||
->whereHas('modules'));
|
||||
|
||||
if (is_numeric($configured) && (int) $configured > 0) {
|
||||
$display = (clone $query)->whereKey((int) $configured)->first();
|
||||
|
||||
if ($display !== null) {
|
||||
return $display->id;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->orderBy('id')->value('id');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.display-feed');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,42 +2,96 @@
|
|||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Hero des Startseiten-Entwurfs (Nachtrag 19, Briefing Marcel 24.09.): Rahmen mit Goldkante,
|
||||
* eine Überschrift, ein Button und statt des festen Bildes eine ruhige Slideshow.
|
||||
*
|
||||
* Die Slides kommen aus der Sektion `home/hero_slider` (Liste `slides`), sobald Marcel dort
|
||||
* welche pflegt. Solange die Liste leer ist, laufen die Titelbilder der veröffentlichten
|
||||
* Kernmarken in ihrer Reihenfolge – Marken ohne Bild fallen weg.
|
||||
*/
|
||||
class HeroSlider extends Component
|
||||
{
|
||||
public $content = [];
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public $currentSlide = 0;
|
||||
/** @var array<int, array{image: string, alt: string, label: string, caption: string}> */
|
||||
public array $slides = [];
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->content = cms_theme_section('hero_slider');
|
||||
$section = cms_theme_section('hero_slider');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->slides = $this->slidesFromContent() ?: $this->slidesFromBrands();
|
||||
}
|
||||
|
||||
public function nextSlide()
|
||||
/**
|
||||
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
|
||||
*/
|
||||
private function slidesFromContent(): array
|
||||
{
|
||||
if (! empty($this->content['slides'])) {
|
||||
$this->currentSlide = ($this->currentSlide + 1) % count($this->content['slides']);
|
||||
$slides = [];
|
||||
|
||||
foreach ((array) ($this->content['slides'] ?? []) as $slide) {
|
||||
if (! is_array($slide) || trim((string) ($slide['image'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_key_exists('active', $slide) && ! filter_var($slide['active'], FILTER_VALIDATE_BOOLEAN)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = trim((string) ($slide['label'] ?? $slide['title'] ?? ''));
|
||||
|
||||
$slides[] = [
|
||||
'image' => (string) $slide['image'],
|
||||
'alt' => trim((string) ($slide['alt'] ?? $label)),
|
||||
'label' => $label,
|
||||
'caption' => trim((string) ($slide['caption'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $slides;
|
||||
}
|
||||
|
||||
public function previousSlide()
|
||||
/**
|
||||
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
|
||||
*/
|
||||
private function slidesFromBrands(): array
|
||||
{
|
||||
if (! empty($this->content['slides'])) {
|
||||
$totalSlides = count($this->content['slides']);
|
||||
$this->currentSlide = ($this->currentSlide - 1 + $totalSlides) % $totalSlides;
|
||||
$slides = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $brand) {
|
||||
$image = trim((string) ($brand['image'] ?? ''));
|
||||
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($brand['name'] ?? '');
|
||||
|
||||
$slides[] = [
|
||||
'image' => $image,
|
||||
'alt' => $name.' – '.strip_tags((string) ($brand['tagline'] ?? '')),
|
||||
'label' => $name,
|
||||
'caption' => strip_tags((string) ($brand['tagline'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
return $slides;
|
||||
}
|
||||
|
||||
public function setSlide($index)
|
||||
public function getIntervalMsProperty(): int
|
||||
{
|
||||
if (! empty($this->content['slides']) && $index >= 0 && $index < count($this->content['slides'])) {
|
||||
$this->currentSlide = $index;
|
||||
}
|
||||
$seconds = (float) ($this->content['interval'] ?? 7);
|
||||
|
||||
return (int) round(max(3, min(30, $seconds)) * 1000);
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.web.components.sections.hero-slider');
|
||||
}
|
||||
|
|
|
|||
25
app/Livewire/Web/Components/Sections/ImmobilienTeaser.php
Normal file
25
app/Livewire/Web/Components/Sections/ImmobilienTeaser.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ImmobilienTeaser extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('immobilien_teaser');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.immobilien-teaser');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Livewire\Component;
|
||||
|
||||
class MagazinDetail extends Component
|
||||
{
|
||||
public $articleId;
|
||||
public $slug;
|
||||
|
||||
public $article;
|
||||
|
||||
|
|
@ -14,25 +15,41 @@ class MagazinDetail extends Component
|
|||
|
||||
public $content;
|
||||
|
||||
public function mount($id = 1)
|
||||
/** Rueckweg zur Magazinliste der Welt, zu der der Beitrag gehoert. */
|
||||
public string $listUrl = '';
|
||||
|
||||
public function mount($slug)
|
||||
{
|
||||
$this->articleId = (int) $id;
|
||||
$this->slug = (string) $slug;
|
||||
$this->loadArticle();
|
||||
$this->listUrl = $this->articleWorld() === CmsArticle::WORLD_IMMOBILIEN
|
||||
? route('immobilien.magazin')
|
||||
: route('magazin');
|
||||
$this->loadRelatedArticles();
|
||||
$this->loadThemeContent();
|
||||
}
|
||||
|
||||
private function articleWorld(): string
|
||||
{
|
||||
return (string) ($this->article['world'] ?? CmsArticle::WORLD_INTERIOR);
|
||||
}
|
||||
|
||||
private function loadArticle()
|
||||
{
|
||||
$articles = $this->getArticlesData();
|
||||
$this->article = $articles[$this->articleId] ?? $articles[1];
|
||||
|
||||
// Unbekannte Slugs haben frueher stillschweigend Artikel 1 ausgeliefert – mit Status 200.
|
||||
// Das erzeugt Duplicate Content unter beliebig vielen URLs.
|
||||
abort_unless(isset($articles[$this->slug]), 404);
|
||||
|
||||
$this->article = $articles[$this->slug];
|
||||
}
|
||||
|
||||
private function loadRelatedArticles()
|
||||
{
|
||||
$articles = $this->getArticlesData();
|
||||
$this->relatedArticles = collect($articles)
|
||||
->filter(fn ($article, $key) => $key != $this->articleId)
|
||||
$this->relatedArticles = collect($this->getArticlesData())
|
||||
->filter(fn (array $article) => ($article['world'] ?? CmsArticle::WORLD_INTERIOR) === $this->articleWorld())
|
||||
->reject(fn (array $article, string $slug) => $slug === $this->slug)
|
||||
->take(2)
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
@ -43,9 +60,18 @@ class MagazinDetail extends Component
|
|||
$this->content = cms_theme_section('magazin_detail');
|
||||
}
|
||||
|
||||
private function getArticlesData()
|
||||
/** @var array<string, array<string, mixed>>|null */
|
||||
private ?array $articlesCache = null;
|
||||
|
||||
/**
|
||||
* Wird beim Rendern zweimal gebraucht (Artikel + verwandte Artikel) – deshalb gemerkt,
|
||||
* damit die Detailseite nur eine Abfrage auslöst.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function getArticlesData(): array
|
||||
{
|
||||
return trans('b2in.articles');
|
||||
return $this->articlesCache ??= CmsArticle::forFrontend();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Livewire\Component;
|
||||
|
||||
class MagazinList extends Component
|
||||
|
|
@ -10,34 +11,41 @@ class MagazinList extends Component
|
|||
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Farbwelt der Liste: `interior` (/magazin) oder `immobilien` (/immobilien/magazin). */
|
||||
public string $world = CmsArticle::WORLD_INTERIOR;
|
||||
|
||||
public function mount(string $world = CmsArticle::WORLD_INTERIOR)
|
||||
{
|
||||
$this->world = in_array($world, CmsArticle::WORLDS, true) ? $world : CmsArticle::WORLD_INTERIOR;
|
||||
$this->loadPosts();
|
||||
$this->loadThemeContent();
|
||||
}
|
||||
|
||||
private function loadPosts()
|
||||
{
|
||||
$articles = trans('b2in.articles');
|
||||
$this->posts = collect($articles)->map(function ($article) {
|
||||
$this->posts = collect(CmsArticle::forFrontend($this->world))->map(function (array $article) {
|
||||
return [
|
||||
'id' => $article['id'],
|
||||
'slug' => $article['slug'],
|
||||
'title' => $article['title'],
|
||||
'excerpt' => $article['content']['intro'], // Using intro as excerpt
|
||||
'excerpt' => $article['content']['intro'] ?? '',
|
||||
'image' => $article['image'],
|
||||
'date' => $article['date'],
|
||||
'readTime' => $article['readTime'],
|
||||
];
|
||||
})->all();
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
private function loadThemeContent()
|
||||
{
|
||||
$this->content = cms_theme_section('magazin_list');
|
||||
$this->content = cms_theme_section(
|
||||
$this->world === CmsArticle::WORLD_IMMOBILIEN ? 'magazin_list_immobilien' : 'magazin_list'
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.magazin-list');
|
||||
return view('livewire.web.components.sections.magazin-list', [
|
||||
'posts' => $this->posts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
app/Livewire/Web/Components/Sections/MarkenCta.php
Normal file
25
app/Livewire/Web/Components/Sections/MarkenCta.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class MarkenCta extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('marken_cta');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.marken-cta');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/MarkenGrowth.php
Normal file
25
app/Livewire/Web/Components/Sections/MarkenGrowth.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class MarkenGrowth extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('marken_growth');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.marken-growth');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/MarkenNetwork.php
Normal file
25
app/Livewire/Web/Components/Sections/MarkenNetwork.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class MarkenNetwork extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('marken_network');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.marken-network');
|
||||
}
|
||||
}
|
||||
83
app/Livewire/Web/Components/Sections/OriginSection.php
Normal file
83
app/Livewire/Web/Components/Sections/OriginSection.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
|
||||
* (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
|
||||
*
|
||||
* Überschrift, Karte, Drehscheibe und Ablauf-Block kommen aus dem CMS (Fallback Lang-Datei).
|
||||
* Die Standorte kommen aus den veröffentlichten Marken: Ort, Entfernung und Rolle pflegt
|
||||
* Marcel in der Markenverwaltung. Die Positionen in der schematischen Karte gehören zur
|
||||
* Zeichnung und bleiben hier im Code.
|
||||
*/
|
||||
class OriginSection extends Component
|
||||
{
|
||||
/**
|
||||
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
|
||||
*
|
||||
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
|
||||
*
|
||||
* @var array<string, array{x: int, y: int, label: string}>
|
||||
*/
|
||||
public const MAP_POSITIONS = [
|
||||
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
|
||||
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
|
||||
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
|
||||
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
|
||||
];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('origin_section');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Marken mit gepflegtem Fertigungsort, in der Reihenfolge der Marken.
|
||||
*
|
||||
* Eine unveröffentlichte Marke fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
|
||||
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
|
||||
*
|
||||
* @return 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
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
$city = trim((string) ($brand['origin_city'] ?? ''));
|
||||
$name = trim((string) ($brand['name'] ?? ''));
|
||||
|
||||
if ($city === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'slug' => (string) $slug,
|
||||
'city' => $city,
|
||||
'brand' => $name,
|
||||
'role' => trim((string) ($brand['origin_role'] ?? '')),
|
||||
'distance' => trim((string) ($brand['origin_distance'] ?? '')),
|
||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
'map' => self::MAP_POSITIONS[$slug] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.origin-section');
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,13 @@ class OurStory extends Component
|
|||
{
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_story). */
|
||||
public string $section = 'our_story';
|
||||
|
||||
public function mount(string $section = 'our_story')
|
||||
{
|
||||
$this->content = cms_theme_section('our_story');
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ class OurValues extends Component
|
|||
{
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_values). */
|
||||
public string $section = 'our_values';
|
||||
|
||||
public function mount(string $section = 'our_values')
|
||||
{
|
||||
$this->content = cms_theme_section('our_values');
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
34
app/Livewire/Web/Components/Sections/PageHero.php
Normal file
34
app/Livewire/Web/Components/Sections/PageHero.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Seitenkopf für Unterseiten: Breadcrumb, Überschrift, Text und optional ein Bild daneben.
|
||||
*
|
||||
* Liegt auf derselben abgesetzten grauen Fläche wie der Hero der Startseite, damit sich der
|
||||
* Kopf klar von den Inhaltsblöcken darunter abhebt.
|
||||
*/
|
||||
class PageHero extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public string $breadcrumb = '';
|
||||
|
||||
public function mount(string $section = 'marken_hero', string $breadcrumb = '', string $bg = ''): void
|
||||
{
|
||||
$content = cms_theme_section($section);
|
||||
$this->content = is_array($content) ? $content : [];
|
||||
$this->breadcrumb = $breadcrumb !== '' ? $breadcrumb : __('ui.brands');
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.page-hero');
|
||||
}
|
||||
}
|
||||
113
app/Livewire/Web/Components/Sections/ReferenceProjects.php
Normal file
113
app/Livewire/Web/Components/Sections/ReferenceProjects.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use App\Models\CmsReference;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Realisierte Projekte auf /referenzen.
|
||||
*
|
||||
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
|
||||
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
|
||||
* die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien, über alle Marken
|
||||
* hinweg gesammelt. Visualisierungen und Entwürfe bleiben in beiden Fällen draußen.
|
||||
*/
|
||||
class ReferenceProjects extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $projects = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public int $limit = 0;
|
||||
|
||||
public function mount(string $bg = '', int $limit = 0): void
|
||||
{
|
||||
$section = cms_theme_section('reference_projects');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
$this->limit = $limit;
|
||||
|
||||
$projects = $this->fromReferences();
|
||||
|
||||
if ($projects === []) {
|
||||
$projects = $this->fromBrandGalleries();
|
||||
}
|
||||
|
||||
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function 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 = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
foreach (CmsBrand::groupGallery($brand['gallery'] ?? [])['projects'] as $index => $item) {
|
||||
$projects[] = [
|
||||
'key' => $slug.'-'.$index,
|
||||
'image' => $item['image'],
|
||||
'caption' => $item['caption'],
|
||||
'meta' => '',
|
||||
'description' => '',
|
||||
'gallery' => [],
|
||||
'brands' => [[
|
||||
'slug' => $slug,
|
||||
'name' => (string) ($brand['name'] ?? ''),
|
||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.reference-projects');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/Sponsoring.php
Normal file
25
app/Livewire/Web/Components/Sections/Sponsoring.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Sponsoring extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('sponsoring');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.sponsoring');
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,35 @@ class AnnouncementBar extends Component
|
|||
{
|
||||
public array $content = [];
|
||||
|
||||
/** Farbwelt der Seite – jede Welt hat ihre eigene Meldung. */
|
||||
public string $world = 'interior';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->content = cms_theme_section('announcement_bar');
|
||||
$this->world = b2in_world();
|
||||
|
||||
// Interior: global.announcement_bar, Immobilien: global.announcement_bar_immobilien.
|
||||
$section = cms_theme_section($this->world === 'immobilien' ? 'announcement_bar_immobilien' : 'announcement_bar');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel, unter dem sich der Browser das Wegklicken merkt.
|
||||
*
|
||||
* Leitet sich aus dem Inhalt ab, nicht nur aus der gepflegten `id`: Wird die Leiste im CMS
|
||||
* neu belegt, ohne die `id` zu aendern, bekaemen Wiederkehrer die neue Meldung sonst nie
|
||||
* zu sehen, weil ihr alter Dismiss-Eintrag im localStorage weitergilt.
|
||||
*/
|
||||
public function getDismissKeyProperty(): string
|
||||
{
|
||||
$fingerprint = implode('|', [
|
||||
$this->content['id'] ?? 'default',
|
||||
$this->content['badge'] ?? '',
|
||||
$this->content['text'] ?? '',
|
||||
$this->content['link_url'] ?? '',
|
||||
]);
|
||||
|
||||
return 'announcement_dismissed_'.substr(md5($fingerprint), 0, 12);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
166
app/Livewire/Web/Components/UI/AppointmentWidget.php
Normal file
166
app/Livewire/Web/Components/UI/AppointmentWidget.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Ui;
|
||||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Schwebende Terminanfrage – auf allen Seiten unten rechts.
|
||||
*
|
||||
* Setzt wie die übrigen Formulare der Seite auf {@see ContactFormService} und {@see SpamDetector}
|
||||
* auf, statt auf Acme\ContactForm\Livewire\ContactForm: dessen mitgelieferte View bringt ein
|
||||
* eigenes (blaues) Layout mit, das nicht zum Auftritt passt. Honeypot, Rate-Limit und
|
||||
* Spam-Erkennung kommen so trotzdem aus dem Paket.
|
||||
*/
|
||||
class AppointmentWidget extends Component
|
||||
{
|
||||
/** Feste Anliegen neben den gepflegten Marken. */
|
||||
public const EXTRA_TOPICS = [
|
||||
'projektausstattung' => 'Projektausstattung',
|
||||
'immobilien' => 'Immobilien',
|
||||
'sonstiges' => 'Sonstiges',
|
||||
];
|
||||
|
||||
/** Farbwelt der Seite – in der Immobilien-Welt ist das Anliegen vorbelegt. */
|
||||
public string $world = 'interior';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $phone = '';
|
||||
|
||||
public string $topic = '';
|
||||
|
||||
public string $preferredDate = '';
|
||||
|
||||
public string $message = '';
|
||||
|
||||
public bool $privacy = false;
|
||||
|
||||
public bool $success = false;
|
||||
|
||||
/** @var string Honeypot – muss leer bleiben. */
|
||||
public string $website = '';
|
||||
|
||||
public ?int $formLoadedAt = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$section = cms_theme_section('appointment_widget');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->formLoadedAt = time();
|
||||
$this->world = b2in_world();
|
||||
|
||||
if ($this->world === 'immobilien') {
|
||||
$this->topic = 'immobilien';
|
||||
}
|
||||
|
||||
$this->applyBrandContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf einer Markenseite trägt der Button die Beschriftung dieser Marke.
|
||||
*
|
||||
* Die Zulieferung sieht ausdrücklich keinen zweiten CTA-Button vor, sondern eine
|
||||
* kontextbezogene Beschriftung des vorhandenen Widgets. Da das Widget im Layout steckt und
|
||||
* nicht je Seite eingebunden wird, liest es den Kontext aus der Route.
|
||||
*/
|
||||
private function applyBrandContext(): void
|
||||
{
|
||||
$route = request()->route();
|
||||
|
||||
if ($route?->getName() !== 'marken.show') {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = (string) $route->parameter('slug');
|
||||
$brand = CmsBrand::forFrontend()[$slug] ?? null;
|
||||
$label = is_array($brand) ? trim((string) ($brand['widget_label'] ?? '')) : '';
|
||||
|
||||
if ($label !== '') {
|
||||
$this->content['button_label'] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auswahl „Anliegen": gepflegte Kernmarken zuerst, danach die festen Einträge.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getTopicsProperty(): array
|
||||
{
|
||||
$brands = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
$brands[$slug] = (string) ($brand['name'] ?? $slug);
|
||||
}
|
||||
|
||||
return $brands + self::EXTRA_TOPICS;
|
||||
}
|
||||
|
||||
public function getWhatsappUrlProperty(): string
|
||||
{
|
||||
return whatsapp_link();
|
||||
}
|
||||
|
||||
public function submit(ContactFormService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email:rfc', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:255'],
|
||||
'preferredDate' => ['nullable', 'date', 'after_or_equal:today'],
|
||||
'message' => ['nullable', 'string', 'max:2000'],
|
||||
'privacy' => ['accepted'],
|
||||
'website' => ['nullable', 'string', 'max:0'],
|
||||
], [
|
||||
'preferredDate.after_or_equal' => __('ui.appointment.date_in_past'),
|
||||
]);
|
||||
|
||||
$topicLabel = $this->topics[$this->topic] ?? '';
|
||||
|
||||
$payload = [
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'topic' => $topicLabel,
|
||||
'preferred_date' => $this->preferredDate,
|
||||
'message' => $this->message,
|
||||
'website' => $this->website,
|
||||
'source' => request()->path(),
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'is_spam' => false,
|
||||
];
|
||||
|
||||
$payload['is_spam'] = SpamDetector::fromConfig()->detect($payload, $this->formLoadedAt);
|
||||
|
||||
$subject = ($this->world === 'immobilien' ? 'Terminanfrage B2in Immobilien' : 'Terminanfrage B2in Interior')
|
||||
.($topicLabel !== '' ? ': '.$topicLabel : '');
|
||||
$service->handle($payload, $subject, 'appointment-widget');
|
||||
|
||||
$this->success = true;
|
||||
$this->reset(['name', 'email', 'phone', 'topic', 'preferredDate', 'message', 'privacy', 'website']);
|
||||
$this->topic = $this->world === 'immobilien' ? 'immobilien' : '';
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function startOver(): void
|
||||
{
|
||||
$this->success = false;
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.web.components.ui.appointment-widget');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ namespace App\Livewire\Web\Components\Ui;
|
|||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class ContactForm extends Component
|
||||
|
|
@ -34,16 +36,109 @@ class ContactForm extends Component
|
|||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public function mount(): void
|
||||
/** Farbwelt der Seite (`interior` | `immobilien`). */
|
||||
public string $world = 'interior';
|
||||
|
||||
public function mount(string $world = 'interior'): void
|
||||
{
|
||||
$this->content = cms_theme_section('contact_form');
|
||||
$this->world = $world === 'immobilien' ? 'immobilien' : 'interior';
|
||||
$this->content = $this->loadContent();
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
/**
|
||||
* Inhalte der Welt. Die Immobilien-Welt liegt als Overlay über der Interior-Sektion:
|
||||
* `contact_form_immobilien` trägt nur, was abweicht (Einstieg, Betreffe) – Adresse,
|
||||
* Beschriftungen und Social Media erbt sie von `contact_form`.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function loadContent(): array
|
||||
{
|
||||
$base = cms_theme_section('contact_form');
|
||||
$base = is_array($base) ? $base : [];
|
||||
|
||||
if ($this->world !== 'immobilien') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$overlay = cms_theme_section('contact_form_immobilien');
|
||||
|
||||
if (! is_array($overlay)) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return array_replace_recursive($base, $overlay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Betreffe in Gruppen: Interior (mit den gepflegten Kernmarken vorneweg), Immobilien, B2in.
|
||||
* Die Welt der Seite bestimmt die Reihenfolge – ihre Gruppe steht oben.
|
||||
*
|
||||
* Ohne `subject_groups` (aeltere CMS-Staende) faellt das Formular auf die flache Liste zurueck.
|
||||
*
|
||||
* @return array<string, array{label: string, subjects: array<string, string>}>
|
||||
*/
|
||||
public function getSubjectGroupsProperty(): array
|
||||
{
|
||||
$groups = $this->content['form']['subject_groups'] ?? null;
|
||||
|
||||
if (! is_array($groups) || $groups === []) {
|
||||
$flat = $this->content['form']['subjects'] ?? [];
|
||||
unset($flat['']);
|
||||
|
||||
return $flat === [] ? [] : ['all' => ['label' => '', 'subjects' => $flat]];
|
||||
}
|
||||
|
||||
$brands = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
$brands[$slug] = (string) ($brand['name'] ?? $slug);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($groups as $key => $group) {
|
||||
$subjects = is_array($group['subjects'] ?? null) ? $group['subjects'] : [];
|
||||
|
||||
if ($key === 'interior') {
|
||||
$subjects = $brands + $subjects;
|
||||
}
|
||||
|
||||
$out[$key] = [
|
||||
'label' => (string) ($group['label'] ?? ''),
|
||||
'subjects' => $subjects,
|
||||
];
|
||||
}
|
||||
|
||||
$first = $this->world === 'immobilien' ? 'immobilien' : 'interior';
|
||||
|
||||
if (isset($out[$first])) {
|
||||
$out = [$first => $out[$first]] + $out;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle waehlbaren Betreffe als flache Liste (Schluessel → Beschriftung).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getSubjectsProperty(): array
|
||||
{
|
||||
return $this->content['form']['subjects'] ?? [];
|
||||
$flat = [];
|
||||
|
||||
foreach ($this->getSubjectGroupsProperty() as $group) {
|
||||
$flat += $group['subjects'];
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
public function getSubjectPlaceholderProperty(): string
|
||||
{
|
||||
return (string) ($this->content['form']['subjects'][''] ?? $this->content['form']['subject_placeholder'] ?? __('ui.contact_form.subject_placeholder'));
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
|
|
@ -64,7 +159,7 @@ class ContactForm extends Component
|
|||
'firstName' => ['required', 'string', 'max:255'],
|
||||
'lastName' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email:rfc', 'max:255'],
|
||||
'subject' => ['required', 'string'],
|
||||
'subject' => ['required', 'string', Rule::in(array_keys($this->subjects))],
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:255'],
|
||||
|
|
@ -90,8 +185,9 @@ class ContactForm extends Component
|
|||
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
|
||||
|
||||
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
|
||||
$prefix = $this->world === 'immobilien' ? 'Kontaktanfrage B2in Immobilien' : 'Kontaktanfrage B2in';
|
||||
$subjectLine = $subjectLabel
|
||||
? 'Kontaktanfrage B2in: ' . $subjectLabel
|
||||
? $prefix.': '.$subjectLabel
|
||||
: __('contact-form::contact-form.default_subject');
|
||||
$service->handle($payload, $subjectLine, 'contact-form');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,62 @@ use Livewire\Component;
|
|||
|
||||
class Footer extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
|
||||
public string $world = 'interior';
|
||||
|
||||
/** @var array<string, array{label: string, url: string, active: bool}> */
|
||||
public array $worlds = [];
|
||||
|
||||
public string $logoPath = '';
|
||||
|
||||
/**
|
||||
* Negatives Logo für die Farbvariante „Modern“ (Nachtrag 18): dort liegt der Interior-Footer
|
||||
* dunkel in Tanne. Die Palette schaltet im Browser, deshalb liegen beide Logos im Markup und
|
||||
* das Stylesheet blendet das passende ein. Leer in der Immobilien-Welt, die ohnehin das
|
||||
* negative Logo trägt.
|
||||
*/
|
||||
public string $logoPathDark = '';
|
||||
|
||||
/**
|
||||
* Fuer die Immobilien-Welt zeigen die gemeinsamen Footer-Links auf die Seiten dieser Welt.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const IMMOBILIEN_LINKS = [
|
||||
'/faq' => '/immobilien/faq',
|
||||
'/about' => '/immobilien/ueber-b2in',
|
||||
'/magazin' => '/immobilien/magazin',
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$section = cms_theme_section('footer');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->world = b2in_world();
|
||||
|
||||
if ($this->world === 'immobilien' && is_array($this->content['links'] ?? null)) {
|
||||
$this->content['links'] = array_map(function ($link) {
|
||||
if (is_array($link) && isset(self::IMMOBILIEN_LINKS[$link['url'] ?? ''])) {
|
||||
$link['url'] = self::IMMOBILIEN_LINKS[$link['url']];
|
||||
}
|
||||
|
||||
return $link;
|
||||
}, $this->content['links']);
|
||||
}
|
||||
$this->worlds = b2in_worlds($this->world);
|
||||
|
||||
// Der Interior-Footer liegt auf Sand (helle Fläche) und trägt deshalb das positive
|
||||
// Logo; der Immobilien-Footer bleibt dunkel und nutzt die negative Variante.
|
||||
$variant = $this->world === 'immobilien' ? 'negative' : 'positive';
|
||||
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath($variant, $this->world);
|
||||
$this->logoPathDark = $this->world === 'immobilien'
|
||||
? ''
|
||||
: \App\Helpers\ThemeHelper::getLogoPath('negative', $this->world);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.ui.footer');
|
||||
|
|
|
|||
|
|
@ -16,13 +16,33 @@ class Header extends Component
|
|||
'en' => 'EN',
|
||||
];
|
||||
|
||||
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
|
||||
public string $world = 'interior';
|
||||
|
||||
/** @var array<string, array{label: string, url: string, active: bool}> */
|
||||
public array $worlds = [];
|
||||
|
||||
public string $logoPath = '';
|
||||
|
||||
/** Pfad der Seite, in mount() festgehalten – ein Livewire-Request traegt ihn nicht mehr. */
|
||||
public string $currentPath = '/';
|
||||
|
||||
/** Ziel des Kontakt-Buttons: /contact (Interior) bzw. /immobilien/kontakt (Immobilien). */
|
||||
public string $contactUrl = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
|
||||
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
|
||||
$this->currentLocale = app()->getLocale();
|
||||
$this->world = b2in_world();
|
||||
$this->worlds = b2in_worlds($this->world);
|
||||
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath('positive', $this->world);
|
||||
$this->currentPath = '/'.ltrim(request()->path(), '/');
|
||||
$this->contactUrl = route($this->world === 'immobilien' ? 'immobilien.contact' : 'contact');
|
||||
|
||||
$this->content = cms_theme_section('header');
|
||||
// Jede Welt hat ihre eigene Navigation (CMS: global.header bzw. global.header_immobilien).
|
||||
$this->content = cms_theme_section($this->world === 'immobilien' ? 'header_immobilien' : 'header');
|
||||
|
||||
// Ensure required keys exist
|
||||
if (!isset($this->content['portal_login'])) {
|
||||
|
|
@ -57,27 +77,43 @@ class Header extends Component
|
|||
{
|
||||
$this->isMobileMenuOpen = false;
|
||||
}
|
||||
public function isActiveRoute($url)
|
||||
/**
|
||||
* Aktiver Navigationspunkt: exakter Treffer, sonst der laengste Eintrag, dessen Pfad ein
|
||||
* Praefix der Seite ist. So markiert /immobilien/magazin nur „Magazin“, nicht auch
|
||||
* „Immobilien“. Anker (#…) zaehlen nicht zum Pfad.
|
||||
*/
|
||||
public function isActiveRoute($url): bool
|
||||
{
|
||||
$currentPath = request()->path();
|
||||
$currentPath = '/' . ltrim($currentPath, '/');
|
||||
$path = $this->navPath((string) $url);
|
||||
|
||||
// Exact match
|
||||
if ($currentPath === $url) {
|
||||
if ($this->currentPath === $path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special handling for home route
|
||||
if ($url === '/' && ($currentPath === '/' || $currentPath === '')) {
|
||||
return true;
|
||||
if ($path === '/' || ! str_starts_with($this->currentPath, rtrim($path, '/').'/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if current path starts with the navigation URL (for sub-pages)
|
||||
if ($url !== '/' && str_starts_with($currentPath, rtrim($url, '/'))) {
|
||||
return true;
|
||||
foreach ($this->content['navigation'] ?? [] as $navItem) {
|
||||
$other = $this->navPath((string) ($navItem['url'] ?? ''));
|
||||
|
||||
if ($other === $this->currentPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlen($other) > strlen($path) && str_starts_with($this->currentPath, rtrim($other, '/').'/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private function navPath(string $url): string
|
||||
{
|
||||
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
|
||||
|
||||
return '/'.ltrim($path, '/');
|
||||
}
|
||||
public function render()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,12 +13,47 @@ class CmsArticle extends Model
|
|||
|
||||
protected $table = 'cms_articles';
|
||||
|
||||
/**
|
||||
* Alte numerische Artikel-IDs (resources/lang/{locale}/b2in.php) → Slug.
|
||||
*
|
||||
* Wird an drei Stellen gebraucht: beim Anlegen durch den CmsArticleSeeder, für den
|
||||
* Lang-Fallback und für die 301-Weiterleitung der alten /magazin/{id}-URLs.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const LEGACY_ID_TO_SLUG = [
|
||||
1 => 'escrow-system-dubai-investoren',
|
||||
2 => 'spotlight-al-jaddaf-hotspot',
|
||||
3 => 'turnkey-investments-moeblierung-rendite',
|
||||
4 => 'supply-chain-management-vertragssicherheit',
|
||||
5 => 'local-for-local-regionaler-moebelhandel',
|
||||
];
|
||||
|
||||
public const WORLD_INTERIOR = 'interior';
|
||||
|
||||
public const WORLD_IMMOBILIEN = 'immobilien';
|
||||
|
||||
/** @var array<int, string> */
|
||||
public const WORLDS = [self::WORLD_INTERIOR, self::WORLD_IMMOBILIEN];
|
||||
|
||||
/**
|
||||
* Beiträge aus dem Bestand, die zur Immobilien-Welt gehören (Dubai-Themen).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const LEGACY_IMMOBILIEN_SLUGS = [
|
||||
'escrow-system-dubai-investoren',
|
||||
'spotlight-al-jaddaf-hotspot',
|
||||
'turnkey-investments-moeblierung-rendite',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'title',
|
||||
'subtitle',
|
||||
'image',
|
||||
'category',
|
||||
'world',
|
||||
'date_label',
|
||||
'read_time',
|
||||
'author',
|
||||
|
|
@ -53,6 +88,104 @@ class CmsArticle extends Model
|
|||
return $query->orderBy('order')->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function scopeForWorld(Builder $query, string $world): Builder
|
||||
{
|
||||
return $query->where('world', $world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Artikel fürs Frontend, nach Slug indiziert.
|
||||
*
|
||||
* Das CMS gewinnt. Solange keine Artikel gepflegt sind, greift die Lang-Datei als Fallback –
|
||||
* dieselbe Reihenfolge wie bei cms_theme_section(), damit das Magazin auf einer frischen
|
||||
* Installation nicht leer ist.
|
||||
*
|
||||
* Mit `$world` nur die Beiträge dieser Farbwelt (Interior oder Immobilien) – die beiden
|
||||
* Magazinlisten sind getrennt, die Detailseite rendert in der Welt ihres Beitrags.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function forFrontend(?string $world = null): array
|
||||
{
|
||||
$maintained = static::query()->published()->exists();
|
||||
|
||||
if ($maintained) {
|
||||
return static::query()
|
||||
->published()
|
||||
->when($world !== null, fn (Builder $query) => $query->forWorld($world))
|
||||
->ordered()
|
||||
->get()
|
||||
->mapWithKeys(fn (self $article) => [$article->slug => $article->toFrontendArray()])
|
||||
->all();
|
||||
}
|
||||
|
||||
$articles = static::fallbackFromLang();
|
||||
|
||||
if ($world === null) {
|
||||
return $articles;
|
||||
}
|
||||
|
||||
return array_filter($articles, fn (array $article) => ($article['world'] ?? self::WORLD_INTERIOR) === $world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug zu einer alten numerischen Magazin-ID, falls bekannt.
|
||||
*/
|
||||
public static function slugForLegacyId(int|string $id): ?string
|
||||
{
|
||||
return self::LEGACY_ID_TO_SLUG[(int) $id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug eines Artikels aus der Lang-Datei.
|
||||
*
|
||||
* Neue Artikel bringen ihren Slug selbst mit (`'slug' => …`). Die fünf ursprünglichen
|
||||
* Artikel hatten keinen und werden weiter über {@see LEGACY_ID_TO_SLUG} aufgelöst – die
|
||||
* Konstante bleibt damit das, was sie ist: die Karte für die alten `/magazin/{id}`-URLs.
|
||||
*
|
||||
* @param array<string, mixed> $article
|
||||
*/
|
||||
public static function slugForLangArticle(int|string $id, array $article): ?string
|
||||
{
|
||||
$slug = $article['slug'] ?? null;
|
||||
|
||||
if (is_string($slug) && $slug !== '') {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
return self::slugForLegacyId($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function fallbackFromLang(): array
|
||||
{
|
||||
$articles = trans('b2in.articles');
|
||||
|
||||
if (! is_array($articles)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($articles as $id => $article) {
|
||||
if (! is_array($article)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = self::slugForLangArticle($id, $article);
|
||||
|
||||
if ($slug === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[$slug] = $article + ['slug' => $slug, 'world' => self::WORLD_INTERIOR];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
@ -65,6 +198,7 @@ class CmsArticle extends Model
|
|||
'subtitle' => $this->subtitle,
|
||||
'image' => $this->image,
|
||||
'category' => $this->category,
|
||||
'world' => $this->world ?? self::WORLD_INTERIOR,
|
||||
'date' => $this->date_label,
|
||||
'readTime' => $this->read_time,
|
||||
'author' => $this->author ?? [],
|
||||
|
|
|
|||
348
app/Models/CmsBrand.php
Normal file
348
app/Models/CmsBrand.php
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* Kernmarke mit eigener Detailseite (CABINET, Rotpunkt Küchen, Febrü …).
|
||||
*
|
||||
* Nicht zu verwechseln mit {@see Brand} – das ist die Produkt-/Partner-Marke.
|
||||
*/
|
||||
class CmsBrand extends Model
|
||||
{
|
||||
use HasFactory, HasTranslations;
|
||||
|
||||
/** Tatsaechlich ausgefuehrtes Projekt – ein Foto aus einem echten Auftrag. */
|
||||
public const GALLERY_PROJECT = 'project';
|
||||
|
||||
/** Entwurf, Rendering oder KI-gestuetzte Darstellung – nicht realisiert. */
|
||||
public const GALLERY_INSPIRATION = 'inspiration';
|
||||
|
||||
/** Einblick in Werkstatt, Fertigung oder Montage des Herstellers – weder Projekt noch Entwurf (Nachtrag 19). */
|
||||
public const GALLERY_COMPANY = 'company';
|
||||
|
||||
/** @var list<string> */
|
||||
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION, self::GALLERY_COMPANY];
|
||||
|
||||
protected $table = 'cms_brands';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'tagline',
|
||||
'teaser',
|
||||
'summary',
|
||||
'highlights',
|
||||
'description',
|
||||
'logo',
|
||||
'image',
|
||||
'services',
|
||||
'sections',
|
||||
'widget_label',
|
||||
'cta_title',
|
||||
'cta_text',
|
||||
'gallery',
|
||||
'website_url',
|
||||
'showroom',
|
||||
'origin_city',
|
||||
'origin_distance',
|
||||
'origin_role',
|
||||
'is_published',
|
||||
'order',
|
||||
];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $translatable = [
|
||||
'name',
|
||||
'tagline',
|
||||
'teaser',
|
||||
'summary',
|
||||
'description',
|
||||
'widget_label',
|
||||
'cta_title',
|
||||
'cta_text',
|
||||
'origin_city',
|
||||
'origin_distance',
|
||||
'origin_role',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'highlights' => 'array',
|
||||
'services' => 'array',
|
||||
'sections' => 'array',
|
||||
'gallery' => '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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Kernmarken fürs Frontend, nach Slug indiziert.
|
||||
*
|
||||
* Das CMS gewinnt. Solange keine Marke gepflegt ist, greift die Lang-Sektion `core_brands`
|
||||
* als Fallback – dieselbe Reihenfolge wie bei cms_theme_section(), damit die Startseite auf
|
||||
* einer frischen Installation nicht ohne Kernmarken dasteht.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function forFrontend(): array
|
||||
{
|
||||
$brands = static::query()->published()->ordered()->get();
|
||||
|
||||
if ($brands->isNotEmpty()) {
|
||||
return $brands
|
||||
->mapWithKeys(fn (self $brand) => [$brand->slug => $brand->toFrontendArray()])
|
||||
->all();
|
||||
}
|
||||
|
||||
return static::fallbackFromLang();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function fallbackFromLang(): array
|
||||
{
|
||||
$section = cms_theme_section('core_brands');
|
||||
$brands = is_array($section) ? ($section['brands'] ?? []) : [];
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
if (! is_array($brand) || empty($brand['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = $brand['slug'] ?? \Illuminate\Support\Str::slug((string) $brand['name']);
|
||||
|
||||
$entry = array_merge([
|
||||
'slug' => $slug,
|
||||
'tagline' => '',
|
||||
'teaser' => '',
|
||||
'summary' => '',
|
||||
'highlights' => [],
|
||||
'description' => '',
|
||||
'logo' => '',
|
||||
'image' => '',
|
||||
'services' => [],
|
||||
'sections' => [],
|
||||
'widget_label' => '',
|
||||
'cta_title' => '',
|
||||
'cta_text' => '',
|
||||
'gallery' => [],
|
||||
'website_url' => '',
|
||||
'showroom' => '',
|
||||
'origin_city' => '',
|
||||
'origin_distance' => '',
|
||||
'origin_role' => '',
|
||||
'has_detail_page' => false,
|
||||
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
|
||||
|
||||
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
|
||||
$entry['sections'] = self::normalizeSections($entry['sections']);
|
||||
|
||||
$out[$slug] = $entry;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenabschnitte in eine feste Form bringen.
|
||||
*
|
||||
* Jeder Abschnitt hat einen Titel und Absätze. Optional dazu:
|
||||
* - `claim` hervorgehobene Schlusszeile
|
||||
* - `quote` Zitat mit `quote_author`
|
||||
* - `detail` Vertiefung, die im Frontend aufklappbar dargestellt wird
|
||||
*
|
||||
* @param mixed $sections
|
||||
* @return list<array{title: string, paragraphs: list<string>, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list<string>}|null}>
|
||||
*/
|
||||
public static function normalizeSections($sections): array
|
||||
{
|
||||
if (! is_array($sections)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
if (! is_array($section)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paragraphs = self::paragraphList($section['paragraphs'] ?? []);
|
||||
$title = trim((string) ($section['title'] ?? ''));
|
||||
|
||||
if ($title === '' && $paragraphs === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$detail = null;
|
||||
|
||||
if (is_array($section['detail'] ?? null)) {
|
||||
$detailParagraphs = self::paragraphList($section['detail']['paragraphs'] ?? []);
|
||||
$detailTitle = trim((string) ($section['detail']['title'] ?? ''));
|
||||
|
||||
if ($detailTitle !== '' || $detailParagraphs !== []) {
|
||||
$detail = ['title' => $detailTitle, 'paragraphs' => $detailParagraphs];
|
||||
}
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'title' => $title,
|
||||
'paragraphs' => $paragraphs,
|
||||
'claim' => trim((string) ($section['claim'] ?? '')),
|
||||
'quote' => trim((string) ($section['quote'] ?? '')),
|
||||
'quote_author' => trim((string) ($section['quote_author'] ?? '')),
|
||||
'detail' => $detail,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $paragraphs
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function paragraphList($paragraphs): array
|
||||
{
|
||||
if (is_string($paragraphs)) {
|
||||
$paragraphs = preg_split('/\R{2,}/', $paragraphs) ?: [];
|
||||
}
|
||||
|
||||
if (! is_array($paragraphs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
fn ($p) => trim((string) (is_array($p) ? ($p['text'] ?? '') : $p)),
|
||||
$paragraphs,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie-Eintraege in eine feste Form bringen.
|
||||
*
|
||||
* Ein Eintrag ohne (oder mit unbekannter) Kategorie gilt als Inspiration, nicht als Projekt:
|
||||
* Ein Entwurf, der versehentlich als Referenz durchgeht, waere die schaedlichere Verwechslung.
|
||||
*
|
||||
* @param mixed $gallery
|
||||
* @return list<array{image: string, category: string, caption: string}>
|
||||
*/
|
||||
public static function normalizeGallery($gallery): array
|
||||
{
|
||||
if (! is_array($gallery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($gallery as $item) {
|
||||
$image = is_array($item) ? (string) ($item['image'] ?? '') : (string) $item;
|
||||
$image = trim($image);
|
||||
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$category = is_array($item) ? (string) ($item['category'] ?? '') : '';
|
||||
|
||||
$out[] = [
|
||||
'image' => $image,
|
||||
'category' => in_array($category, self::GALLERY_CATEGORIES, true)
|
||||
? $category
|
||||
: self::GALLERY_INSPIRATION,
|
||||
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie nach realisierten Projekten, Einblicken in die Werkstatt und Inspirationen getrennt.
|
||||
*
|
||||
* @param mixed $gallery
|
||||
* @return array{projects: list<array{image: string, category: string, caption: string}>, company: list<array{image: string, category: string, caption: string}>, inspirations: list<array{image: string, category: string, caption: string}>}
|
||||
*/
|
||||
public static function groupGallery($gallery): array
|
||||
{
|
||||
$items = self::normalizeGallery($gallery);
|
||||
|
||||
return [
|
||||
'projects' => array_values(array_filter(
|
||||
$items,
|
||||
fn (array $item) => $item['category'] === self::GALLERY_PROJECT,
|
||||
)),
|
||||
'company' => array_values(array_filter(
|
||||
$items,
|
||||
fn (array $item) => $item['category'] === self::GALLERY_COMPANY,
|
||||
)),
|
||||
'inspirations' => array_values(array_filter(
|
||||
$items,
|
||||
fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION,
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschriftung des Badges auf dem Galeriebild („Realisiertes Projekt“, „Aus der Werkstatt“, „Visualisierung“).
|
||||
*/
|
||||
public static function galleryBadge(string $category): string
|
||||
{
|
||||
return match ($category) {
|
||||
self::GALLERY_PROJECT => __('ui.brand_gallery_badge_project'),
|
||||
self::GALLERY_COMPANY => __('ui.brand_gallery_badge_company'),
|
||||
default => __('ui.brand_gallery_badge_inspiration'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toFrontendArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'name' => $this->name,
|
||||
'tagline' => $this->tagline,
|
||||
'teaser' => $this->teaser,
|
||||
'summary' => $this->summary,
|
||||
'highlights' => $this->highlights ?? [],
|
||||
'description' => $this->description,
|
||||
'logo' => $this->logo,
|
||||
'image' => $this->image,
|
||||
'services' => $this->services ?? [],
|
||||
'sections' => self::normalizeSections($this->sections),
|
||||
'widget_label' => (string) $this->widget_label,
|
||||
'cta_title' => (string) $this->cta_title,
|
||||
'cta_text' => (string) $this->cta_text,
|
||||
'gallery' => self::normalizeGallery($this->gallery),
|
||||
'website_url' => $this->website_url,
|
||||
'showroom' => $this->showroom,
|
||||
'origin_city' => (string) $this->origin_city,
|
||||
'origin_distance' => (string) $this->origin_distance,
|
||||
'origin_role' => (string) $this->origin_role,
|
||||
'has_detail_page' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
115
app/Models/CmsReference.php
Normal file
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,18 @@ class ThemeServiceProvider extends ServiceProvider
|
|||
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
|
||||
}
|
||||
|
||||
// Unbekannter Host (Vorschau-, Staging- oder IP-Aufruf): Bis hierher stünde in `url` noch
|
||||
// APP_URL bzw. der Vorgabewert aus config/domains.php – also eine *fremde* Domain. Alle
|
||||
// Assets und Links würden dorthin zeigen, obwohl die Instanz unter einem anderen Namen
|
||||
// läuft. Außerhalb von `production` gilt deshalb der tatsächliche Request-Origin.
|
||||
//
|
||||
// In `production` bleibt es bewusst bei der konfigurierten URL: dort darf ein gefälschter
|
||||
// Host-Header keine Links auf eine fremde Domain erzeugen.
|
||||
if ($domainConfig['domain_name'] !== $host && config('app.env') !== 'production') {
|
||||
$domainConfig['domain_name'] = $host;
|
||||
$domainConfig['url'] = Request::getSchemeAndHttpHost();
|
||||
}
|
||||
|
||||
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
|
||||
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
|
||||
$assetUrl = $domainConfig['url'];
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,8 +27,10 @@ class DisplayModuleSettings
|
|||
],
|
||||
DisplayVersionType::B2in->value => [
|
||||
'theme' => 'dark',
|
||||
'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
|
||||
'header_claim' => 'Connecting Design & Property',
|
||||
// Standard-Theme ist dunkel, das Logo liegt dort auf einem dunklen Scrim –
|
||||
// deshalb die negative (weisse) Variante.
|
||||
'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg',
|
||||
'header_claim' => 'Premium-Einrichtung. Persönlich gemacht.',
|
||||
'logo_position' => 'top-left',
|
||||
'claim_position' => 'top-right',
|
||||
'show_logo' => true,
|
||||
|
|
|
|||
106
app/helpers.php
106
app/helpers.php
|
|
@ -66,6 +66,35 @@ if (! function_exists('cms_theme_section')) {
|
|||
}
|
||||
}
|
||||
|
||||
if (! function_exists('whatsapp_link')) {
|
||||
/**
|
||||
* Klick-zum-Chat-Link, sofern im CMS eine Nummer hinterlegt ist (global.contact.whatsapp).
|
||||
*
|
||||
* Liefert einen leeren String, solange keine Nummer bestaetigt ist – der Button erscheint dann
|
||||
* gar nicht erst, statt auf eine erfundene Nummer zu zeigen.
|
||||
*/
|
||||
function whatsapp_link(): string
|
||||
{
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
// Die Nummer kann als eigener Eintrag oder als Feld der Sektion `global.contact` liegen.
|
||||
$number = $service->getIfExists('global.contact.whatsapp');
|
||||
|
||||
if ($number === null) {
|
||||
$contact = $service->getIfExists('global.contact');
|
||||
$number = is_array($contact) ? ($contact['whatsapp'] ?? null) : null;
|
||||
}
|
||||
|
||||
if (! is_string($number)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $number);
|
||||
|
||||
return $digits === '' ? '' : 'https://wa.me/'.$digits;
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('theme_image_url')) {
|
||||
/**
|
||||
* Bild-URL für Theme-/CMS-Inhalte: zuerst Medienbibliothek (CmsMedia, oft nur Dateiname z. B. *.webp),
|
||||
|
|
@ -177,3 +206,80 @@ if (! function_exists('media_url')) {
|
|||
return $resolved[$cacheKey];
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('b2in_world')) {
|
||||
/**
|
||||
* Farbwelt der aktuellen Seite: `interior` (Standard) oder `immobilien`.
|
||||
*
|
||||
* Die Immobilien-Welt umfasst die Immobilien-Routen samt Detailseiten und den
|
||||
* Entwicklungs-Vorschauen. Das Layout setzt den Wert als Body-Klasse
|
||||
* (`world-interior` / `world-immobilien`), theme-b2in.css schaltet daran die Farben um.
|
||||
* Livewire-Komponenten merken sich die Welt in mount(), weil ein späterer
|
||||
* Livewire-Request keine Seitenroute mehr trägt.
|
||||
*/
|
||||
function b2in_world(): string
|
||||
{
|
||||
if (app()->bound('b2in.world')) {
|
||||
return (string) app('b2in.world');
|
||||
}
|
||||
|
||||
$routeName = request()->route()?->getName();
|
||||
|
||||
if (is_string($routeName) && (str_starts_with($routeName, 'immobilien') || str_starts_with($routeName, 'dev.immobilien'))) {
|
||||
return 'immobilien';
|
||||
}
|
||||
|
||||
return 'interior';
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('b2in_palette')) {
|
||||
/**
|
||||
* Abweichende Farbpalette der aktuellen Seite – leer im Normalfall.
|
||||
*
|
||||
* Der Entwurf der Startseite (Nachtrag 19, /entwurf) bindet „greige“; Logo und Layout
|
||||
* richten sich danach. Die Wahl „Bisher/Neu“ des Farb-Schalters läuft dagegen im Browser
|
||||
* und taucht hier nicht auf.
|
||||
*/
|
||||
function b2in_palette(): string
|
||||
{
|
||||
return app()->bound('b2in.palette') ? (string) app('b2in.palette') : '';
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('b2in_worlds')) {
|
||||
/**
|
||||
* Die beiden Welten für den Welten-Wechsler in Kopf- und Fußzeile.
|
||||
*
|
||||
* @return array<string, array{label: string, url: string, active: bool}>
|
||||
*/
|
||||
function b2in_worlds(?string $current = null): array
|
||||
{
|
||||
$current = $current ?? b2in_world();
|
||||
|
||||
return [
|
||||
'interior' => [
|
||||
'label' => __('ui.world_interior'),
|
||||
'url' => url('/'),
|
||||
'active' => $current === 'interior',
|
||||
],
|
||||
'immobilien' => [
|
||||
'label' => __('ui.world_immobilien'),
|
||||
'url' => route('immobilien'),
|
||||
'active' => $current === 'immobilien',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('b2in_world_set')) {
|
||||
/**
|
||||
* Welt für den laufenden Request festlegen, wenn sie nicht an der Route hängt –
|
||||
* etwa auf der Magazin-Detailseite, die in der Welt ihres Beitrags rendert.
|
||||
* Muss vor dem Rendern von Kopf- und Fußzeile geschehen (beide halten die Welt in mount() fest).
|
||||
*/
|
||||
function b2in_world_set(string $world): void
|
||||
{
|
||||
app()->instance('b2in.world', $world);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,60 @@ return [
|
|||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Indexierung durch Suchmaschinen
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Nur die Produktivumgebung darf indexiert werden. Vorschau- und Testinstanzen
|
||||
| liefern damit automatisch `noindex, nofollow` – sonst konkurriert eine Kopie
|
||||
| der Website mit dem Original um dieselben Suchergebnisse.
|
||||
|
|
||||
| Bewusst als eigener Schalter und nicht nur an APP_ENV gekoppelt, damit eine
|
||||
| Vorschau notfalls auch mit APP_ENV=production noindex bleiben kann.
|
||||
|
|
||||
*/
|
||||
'indexable' => (bool) env('SITE_INDEXABLE', env('APP_ENV', 'production') === 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Farbvarianten-Schalter (B2in Interior)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Blendet auf den Interior-Seiten den schwimmenden Schalter ein, mit dem sich
|
||||
| die bisherige Creme-Palette und die hellere Variante „Modern“ vergleichen
|
||||
| lassen (Nachtrag 18). Nur für die Abstimmung gedacht – sobald eine Palette
|
||||
| entschieden ist, per B2IN_PALETTE_SWITCH=false abschalten.
|
||||
|
|
||||
*/
|
||||
|
||||
'palette_switch' => (bool) env('B2IN_PALETTE_SWITCH', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Feste Farbpalette der Interior-Welt (ohne Schalter)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| „modern“ = die hellere Variante „Neu“ (Nachtrag 18), „classic“ = Creme.
|
||||
| Gilt, solange der Schalter aus ist; mit Schalter ist es die Vorbelegung.
|
||||
| Seit der Abstimmung 25.09. steht die Seite auf „modern“.
|
||||
|
|
||||
*/
|
||||
|
||||
'palette' => env('B2IN_PALETTE', 'modern'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Entwurf der Startseite unter /entwurf
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Farbwelt „Greige“ mit Hero-Slideshow (Nachtrag 19), nur zur Abstimmung.
|
||||
| Abschalten, sobald der Entwurf entschieden ist.
|
||||
|
|
||||
*/
|
||||
|
||||
'home_draft' => (bool) env('B2IN_HOME_DRAFT', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|
|
|
|||
299
config/cms_pages.php
Normal file
299
config/cms_pages.php
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
<?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' => 'hero_slider', 'anchor' => 'hero', 'title' => 'Hero mit Slideshow (Entwurf)',
|
||||
'path' => '/entwurf',
|
||||
'description' => 'Der Hero des Entwurfs vom 24.09.: Überschrift, Text, Button und die Slideshow. Liste „slides“ leer = Titelbilder der Kernmarken; sonst je Eintrag image, label, caption und active.'],
|
||||
['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.'],
|
||||
['group' => 'marken', 'key' => 'craft', 'anchor' => 'handwerk', 'title' => 'Handwerk-Abschluss auf jeder Markenseite',
|
||||
'description' => 'Der letzte Abschnitt auf den Seiten von CABINET, Rotpunkt und Febrü: „Planung plus Handwerk“ mit Link zu Otterpohl. Einmal gepflegt, überall gleich.', 'path' => '/marken/cabinet'],
|
||||
],
|
||||
],
|
||||
|
||||
'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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -14,11 +14,31 @@ return [
|
|||
|
||||
'sections' => [
|
||||
'announcement_bar' => ['global', 'announcement_bar'],
|
||||
'announcement_bar_immobilien' => ['global', 'announcement_bar_immobilien'],
|
||||
'header' => ['global', 'header'],
|
||||
'hero_slider' => ['home', 'hero_slider'],
|
||||
'footer' => ['global', 'footer'],
|
||||
'cta_section' => ['global', 'cta_section'],
|
||||
'cta_section_immobilien' => ['global', 'cta_section_immobilien'],
|
||||
'appointment_widget' => ['global', 'appointment_widget'],
|
||||
'cta_section_portfolio' => ['global', 'cta_section_portfolio'],
|
||||
|
||||
'hero' => ['home', 'hero'],
|
||||
'audience_switch' => ['home', 'audience_switch'],
|
||||
'core_brands' => ['home', 'core_brands'],
|
||||
'origin_section' => ['home', 'origin_section'],
|
||||
|
||||
'marken_hero' => ['marken', 'hero'],
|
||||
'marken_network' => ['marken', 'network'],
|
||||
'marken_growth' => ['marken', 'growth'],
|
||||
'marken_cta' => ['marken', 'cta'],
|
||||
'brand_craft' => ['marken', 'craft'],
|
||||
|
||||
'referenzen_hero' => ['referenzen', 'hero'],
|
||||
'reference_projects' => ['referenzen', 'projects'],
|
||||
'display_feed' => ['referenzen', 'display_feed'],
|
||||
'sponsoring' => ['home', 'sponsoring'],
|
||||
'immobilien_teaser' => ['home', 'immobilien_teaser'],
|
||||
'founder_bar' => ['home', 'founder_bar'],
|
||||
'synergie_section' => ['home', 'synergie_section'],
|
||||
'ecosystem_core' => ['home', 'ecosystem_core'],
|
||||
|
|
@ -53,8 +73,18 @@ return [
|
|||
'interior_trust' => ['netzwerk', 'interior_trust'],
|
||||
|
||||
'faq' => ['faq', 'faq'],
|
||||
'faq_immobilien' => ['faq', 'faq_immobilien'],
|
||||
|
||||
// Immobilien-Welt: eigene Navigation, eigenes Magazin, eigene Ueber-uns-Seite
|
||||
'header_immobilien' => ['global', 'header_immobilien'],
|
||||
'magazin_list_immobilien' => ['immobilien', 'magazin_list'],
|
||||
'immobilien_about_hero' => ['immobilien', 'about_hero'],
|
||||
'immobilien_our_story' => ['immobilien', 'our_story'],
|
||||
'immobilien_about_image_break' => ['immobilien', 'about_image_break'],
|
||||
'immobilien_our_values' => ['immobilien', 'our_values'],
|
||||
|
||||
'contact_form' => ['contact', 'contact_form'],
|
||||
'contact_form_immobilien' => ['contact', 'contact_form_immobilien'],
|
||||
|
||||
'about_hero' => ['about', 'hero'],
|
||||
'our_story' => ['about', 'our_story'],
|
||||
|
|
|
|||
|
|
@ -71,13 +71,29 @@ return [
|
|||
'view_prefix' => 'b2in',
|
||||
'assets_dir' => 'build/b2in',
|
||||
'description' => 'Haupt-Landingpage mit Standard-Theme',
|
||||
// Interior-Welt (Standard). Die Immobilien-Welt behält das bisherige Blau,
|
||||
// siehe `worlds` und resources/css/web/theme-b2in.css.
|
||||
'color_scheme' => [
|
||||
'primary' => '#2b3f51', // Anthracite
|
||||
'secondary' => '#20a0da', // Dynamic Blue
|
||||
'primary' => '#202c2b', // Tanne
|
||||
'secondary' => '#d39e46', // Ocker
|
||||
],
|
||||
'font_family' => [
|
||||
'primary' => 'Inter',
|
||||
'secondary' => 'IBM Plex Sans',
|
||||
'secondary' => 'EB Garamond',
|
||||
],
|
||||
'worlds' => [
|
||||
'interior' => [
|
||||
'primary' => '#202c2b', // Tanne – Hauptton dunkel, Wortmarke
|
||||
'background' => '#f7f4ef', // Creme
|
||||
'surface' => '#d9cbbf', // Sand – Flächen, Footer
|
||||
'accent' => '#d39e46', // Ocker – Akzent, Logo-Bogen
|
||||
'secondary' => '#a38f82', // Clay – Sekundär, Produkte
|
||||
'text' => '#2d2926', // Anthrazit
|
||||
],
|
||||
'immobilien' => [
|
||||
'primary' => '#2b3f51', // Anthracite
|
||||
'accent' => '#20a0da', // Dynamic Blue
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
21
config/seeding.php
Normal file
21
config/seeding.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Seed-Admin
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Zugangsdaten für den Admin-Benutzer, den der DatabaseSeeder anlegt.
|
||||
| Gehören nicht ins Repository – über die .env setzen.
|
||||
|
|
||||
*/
|
||||
|
||||
'admin_name' => env('SEED_ADMIN_NAME', 'Admin'),
|
||||
'admin_email' => env('SEED_ADMIN_EMAIL', 'admin@b2in.test'),
|
||||
'admin_password' => env('SEED_ADMIN_PASSWORD', 'password'),
|
||||
|
||||
];
|
||||
|
|
@ -23,6 +23,7 @@ class CmsArticleFactory extends Factory
|
|||
'subtitle' => fake()->sentence(12),
|
||||
'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg',
|
||||
'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']),
|
||||
'world' => CmsArticle::WORLD_INTERIOR,
|
||||
'date_label' => fake()->date('F j, Y'),
|
||||
'read_time' => fake()->numberBetween(3, 10).' min read',
|
||||
'author' => [
|
||||
|
|
|
|||
70
database/factories/CmsBrandFactory.php
Normal file
70
database/factories/CmsBrandFactory.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<CmsBrand>
|
||||
*/
|
||||
class CmsBrandFactory extends Factory
|
||||
{
|
||||
protected $model = CmsBrand::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->company();
|
||||
|
||||
return [
|
||||
'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 999),
|
||||
'name' => ['de' => $name, 'en' => $name],
|
||||
'tagline' => ['de' => fake()->sentence(4), 'en' => fake()->sentence(4)],
|
||||
'teaser' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
|
||||
'description' => ['de' => '<p>'.fake()->paragraph(4).'</p>', 'en' => '<p>'.fake()->paragraph(4).'</p>'],
|
||||
'logo' => '',
|
||||
'image' => '',
|
||||
'services' => [
|
||||
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
|
||||
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
|
||||
],
|
||||
'gallery' => [],
|
||||
'website_url' => fake()->url(),
|
||||
'showroom' => 'Bielefeld',
|
||||
'is_published' => true,
|
||||
'order' => fake()->numberBetween(1, 20),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie mit realisierten Projekten und Inspirationen.
|
||||
*/
|
||||
public function withGallery(int $projects = 2, int $inspirations = 2): static
|
||||
{
|
||||
$items = [];
|
||||
|
||||
for ($i = 1; $i <= $projects; $i++) {
|
||||
$items[] = [
|
||||
'image' => 'projekt-'.$i.'.webp',
|
||||
'category' => CmsBrand::GALLERY_PROJECT,
|
||||
'caption' => 'Projekt '.$i,
|
||||
];
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= $inspirations; $i++) {
|
||||
$items[] = [
|
||||
'image' => 'inspiration-'.$i.'.webp',
|
||||
'category' => CmsBrand::GALLERY_INSPIRATION,
|
||||
'caption' => 'Inspiration '.$i,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->state(fn () => ['gallery' => $items]);
|
||||
}
|
||||
|
||||
public function unpublished(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_published' => false]);
|
||||
}
|
||||
}
|
||||
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,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Kernmarken (CABINET, Rotpunkt Küchen, Febrü) mit eigener Detailseite.
|
||||
*
|
||||
* Bewusst eine eigene Tabelle statt der bestehenden `brands`: die gehört zum
|
||||
* Produkt-/Partner-Kontext (Partner::brand(), Product::brand()) und hat eine andere Bedeutung.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cms_brands', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug')->unique();
|
||||
$table->json('name');
|
||||
$table->json('tagline')->nullable();
|
||||
$table->json('teaser')->nullable();
|
||||
$table->json('description')->nullable();
|
||||
$table->string('logo')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->json('services')->nullable();
|
||||
$table->json('gallery')->nullable();
|
||||
$table->string('website_url')->nullable();
|
||||
$table->string('showroom')->nullable();
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cms_brands');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Die Übersichtsseite /marken zeigt je Marke einen mittellangen Text plus drei Merkmale.
|
||||
*
|
||||
* Abgrenzung zu den vorhandenen Feldern:
|
||||
* teaser – Kurztext für die Kachel auf der Startseite
|
||||
* summary – Text auf der Übersichtsseite /marken (neu)
|
||||
* highlights – drei Stichpunkte auf /marken (neu)
|
||||
* description – Einleitung auf der Detailseite
|
||||
* services – Leistungen auf der Detailseite
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->json('summary')->nullable()->after('teaser');
|
||||
$table->json('highlights')->nullable()->after('summary');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->dropColumn(['summary', 'highlights']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Ausführliche Markenseiten nach den Zulieferungen vom 31.08.2026.
|
||||
*
|
||||
* Die gelieferten Texte (Rotpunkt, Febrü) bestehen aus mehreren Abschnitten, von denen einige
|
||||
* eine Vertiefung mitbringen, die laut Umsetzungshinweis aufklappbar dargestellt werden soll.
|
||||
* Dafür reichen `description` (Einleitung) und `services` (Kacheln) nicht aus.
|
||||
*
|
||||
* Ergänzt werden:
|
||||
* - `sections` Abschnitte der Seite, je mit Titel, Absätzen und optionaler Vertiefung
|
||||
* - `widget_label` Beschriftung des schwebenden Terminanfrage-Widgets auf dieser Markenseite
|
||||
* - `cta_title` Überschrift des Abschlussblocks
|
||||
* - `cta_text` Text des Abschlussblocks
|
||||
*
|
||||
* `sections` ist – wie `services`, `highlights` und `gallery` – bewusst nicht übersetzbar:
|
||||
* Die Texte liegen nur auf Deutsch vor. Die drei Textfelder folgen den übrigen Textfeldern
|
||||
* und sind übersetzbar.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->json('sections')->nullable()->after('services');
|
||||
$table->json('widget_label')->nullable()->after('sections');
|
||||
$table->json('cta_title')->nullable()->after('widget_label');
|
||||
$table->json('cta_text')->nullable()->after('cta_title');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->dropColumn(['sections', 'widget_label', 'cta_title', 'cta_text']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Magazinbeiträge gehören zu einer Farbwelt (Interior oder Immobilien).
|
||||
*
|
||||
* Die drei Dubai-Beiträge aus dem Bestand werden direkt der Immobilien-Welt zugeordnet,
|
||||
* damit sie nach der Trennung nicht in der Interior-Liste stehen.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_articles', function (Blueprint $table) {
|
||||
$table->string('world', 20)->default(CmsArticle::WORLD_INTERIOR)->index()->after('category');
|
||||
});
|
||||
|
||||
CmsArticle::query()
|
||||
->whereIn('slug', CmsArticle::LEGACY_IMMOBILIEN_SLUGS)
|
||||
->update(['world' => CmsArticle::WORLD_IMMOBILIEN]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_articles', function (Blueprint $table) {
|
||||
$table->dropIndex(['world']);
|
||||
$table->dropColumn('world');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
81
database/seeders/AudienceSwitchProjectTextSeeder.php
Normal file
81
database/seeders/AudienceSwitchProjectTextSeeder.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Projekttext auf der Startseite (Briefing Marcel 24.09.2026, Punkt 7; Nachtrag 19).
|
||||
*
|
||||
* In der Karte „Ausstattung für Ihr Projekt“ (home/audience_switch) wird „Mit deutscher
|
||||
* Vertragssicherheit …“ durch „Mit einem zentralen deutschen Vertragspartner …“ ersetzt.
|
||||
* Der Seeder tauscht nur genau diesen Satz, egal wo er im Sektions-JSON steht; alles andere
|
||||
* bleibt. Steht der Satz nicht mehr drin, passiert nichts. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=AudienceSwitchProjectTextSeeder
|
||||
*/
|
||||
class AudienceSwitchProjectTextSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, string> alter Satz → neuer Satz */
|
||||
public const REPLACEMENTS = [
|
||||
'Mit deutscher Vertragssicherheit und einem eingespielten Herstellernetzwerk.' => 'Mit einem zentralen deutschen Vertragspartner und einem eingespielten Herstellernetzwerk.',
|
||||
'With German contractual reliability and an established manufacturer network.' => 'With a single German contract partner and an established manufacturer network.',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'audience_switch')->first();
|
||||
|
||||
if (! $row) {
|
||||
$this->command?->info('Projekttext: Sektion home/audience_switch nicht im CMS – die Lang-Datei gilt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$count = 0;
|
||||
$value = $this->replaceDeep($value, $count);
|
||||
|
||||
if ($count === 0) {
|
||||
$this->command?->info('Projekttext: bereits aktuell, nichts geändert.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
|
||||
$this->command?->info("Projekttext ersetzt ({$count} Stelle(n)).");
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
64
database/seeders/BrandGalleryCaptionNormalizerSeeder.php
Normal file
64
database/seeders/BrandGalleryCaptionNormalizerSeeder.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Entfernt den Vermerk „BEISPIEL – “ aus vorhandenen Galerie-Bildunterschriften.
|
||||
*
|
||||
* Der Vermerk stammt aus {@see DemoBrandGallerySeeder} und ist auf Kundenwunsch entfallen. Weil
|
||||
* die Bilder auf Vorschau- und Livesystem bereits in der Datenbank stehen, reicht die Änderung am
|
||||
* Demo-Seeder nicht aus – der wuerde beim erneuten Ausfuehren die gesamte Galerie ueberschreiben
|
||||
* und damit gepflegte Bilder verlieren.
|
||||
*
|
||||
* Dieser Seeder fasst deshalb ausschliesslich den Text der Bildunterschrift an. Bilder, Kategorien
|
||||
* und Reihenfolge bleiben unveraendert. Mehrfaches Ausfuehren ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=BrandGalleryCaptionNormalizerSeeder
|
||||
*/
|
||||
class BrandGalleryCaptionNormalizerSeeder extends Seeder
|
||||
{
|
||||
/** Praefixe, die vorne aus der Bildunterschrift entfernt werden. */
|
||||
private const PREFIXES = ['BEISPIEL – ', 'BEISPIEL - ', 'BEISPIEL: ', 'BEISPIEL '];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$cleaned = 0;
|
||||
|
||||
foreach (CmsBrand::query()->whereNotNull('gallery')->get() as $brand) {
|
||||
$gallery = $brand->gallery ?? [];
|
||||
$changed = false;
|
||||
|
||||
foreach ($gallery as $index => $item) {
|
||||
$caption = (string) ($item['caption'] ?? '');
|
||||
$stripped = $this->stripPrefix($caption);
|
||||
|
||||
if ($stripped !== $caption) {
|
||||
$gallery[$index]['caption'] = $stripped;
|
||||
$changed = true;
|
||||
$cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$brand->gallery = CmsBrand::normalizeGallery($gallery);
|
||||
$brand->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->command?->info("Bildunterschriften bereinigt: {$cleaned}.");
|
||||
}
|
||||
|
||||
private function stripPrefix(string $caption): string
|
||||
{
|
||||
foreach (self::PREFIXES as $prefix) {
|
||||
if (str_starts_with($caption, $prefix)) {
|
||||
return trim(substr($caption, strlen($prefix)));
|
||||
}
|
||||
}
|
||||
|
||||
return $caption;
|
||||
}
|
||||
}
|
||||
160
database/seeders/BrandImageSeeder.php
Normal file
160
database/seeders/BrandImageSeeder.php
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Trägt Titelbild und Galerie für CABINET, Rotpunkt, Febrü (Nachtrag 16) und Otterpohl (Nachtrag 19) nach.
|
||||
*
|
||||
* Grundlage: dev/2026-08-28/images/ (Zulieferungen vom 22.–24.09.2026). Die Originale sind 1–8 MB
|
||||
* große JPEGs; unter public/img/assets/marken/{cabinet,rotpunkt,februe,otterpohl}/ liegen sie als
|
||||
* WebP mit maximal 1920 px Kantenlänge (Imagick, Qualität 82, ohne Metadaten). Die mit `_title_`
|
||||
* markierte Datei ist das Titelbild (Seitenkopf der Markenseite, Kachel auf der Startseite,
|
||||
* Markenblock auf /marken). Die Galeriebilder der drei Hersteller stehen als **Visualisierung**:
|
||||
* es sind Herstellerdarstellungen, keine von uns ausgeführten Projekte – unter „Referenzen“
|
||||
* erscheinen sie damit nicht. Bei Otterpohl sind die Bilder 2–5 Einblicke in Werkstatt und
|
||||
* Montage (**Aus der Werkstatt**), alle übrigen ausgeführte Aufträge (**Realisiertes Projekt**);
|
||||
* letztere erscheinen unter „Referenzen“, solange dort keine eigenen Einträge gepflegt sind.
|
||||
*
|
||||
* Verhalten: Das Titelbild wird nur gesetzt, wenn keins gepflegt ist. Galeriebilder werden
|
||||
* nur ergänzt, wenn die Datei noch nicht in der Galerie steht; vorhandene Einträge bleiben
|
||||
* samt Kategorie und Bildunterschrift, wie Marcel sie gepflegt hat. Mehrfaches Ausführen ist
|
||||
* unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=BrandImageSeeder
|
||||
*/
|
||||
class BrandImageSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Galerie: Dateiname → Bildunterschrift (Kategorie Visualisierung) oder
|
||||
* Dateiname → ['caption' => …, 'category' => …] für eine andere Kategorie.
|
||||
*
|
||||
* @var array<string, array{image: string, gallery: array<string, string|array{caption: string, category: string}>}>
|
||||
*/
|
||||
private const IMAGES = [
|
||||
'cabinet' => [
|
||||
'image' => 'marken/cabinet/ankleide-solitaer-eiche-tano.webp',
|
||||
'gallery' => [
|
||||
'marken/cabinet/dachschraegenschrank-filo-cashmere.webp' => 'Dachschrägenschrank in Filo Cashmere',
|
||||
'marken/cabinet/schrank-gegen-schraege-marmor.webp' => 'Schrank gegen die Schräge in Marmoroptik',
|
||||
'marken/cabinet/schlafzimmer-eiche-tano.webp' => 'Schlafzimmerschrank in Eiche Tano',
|
||||
'marken/cabinet/schlafzimmer-velluto-fango.webp' => 'Schlafzimmerschrank in Velluto Fango',
|
||||
'marken/cabinet/schlafzimmer-wandschrank-filo-cashmere.webp' => 'Wandschrank im Schlafzimmer in Filo Cashmere',
|
||||
'marken/cabinet/garderobe-seite-offen.webp' => 'Garderobe mit offener Seite',
|
||||
'marken/cabinet/garderobe-sitznische-filo-cashmere.webp' => 'Garderobe mit Sitznische in Filo Cashmere',
|
||||
'marken/cabinet/drehtuerenschrank-marmor.webp' => 'Drehtürenschrank in Marmoroptik',
|
||||
'marken/cabinet/flur-schrank-eiche-tano.webp' => 'Flurschrank in Eiche Tano',
|
||||
'marken/cabinet/dachschraege-velluto-fango.webp' => 'Schrank unter der Dachschräge in Velluto Fango',
|
||||
'marken/cabinet/ankleide-solitaer-eiche-tano-seite.webp' => 'Ankleide Solitär in Eiche Tano, Seitenansicht',
|
||||
],
|
||||
],
|
||||
'rotpunkt' => [
|
||||
'image' => 'marken/rotpunkt/kueche-01.webp',
|
||||
'gallery' => [
|
||||
'marken/rotpunkt/kueche-01-b.webp' => 'Küche 01, zweite Ansicht',
|
||||
'marken/rotpunkt/kueche-02.webp' => 'Küche 02',
|
||||
'marken/rotpunkt/kueche-03.webp' => 'Küche 03',
|
||||
'marken/rotpunkt/kueche-04.webp' => 'Küche 04',
|
||||
'marken/rotpunkt/kueche-05.webp' => 'Küche 05',
|
||||
'marken/rotpunkt/kueche-06.webp' => 'Küche 06',
|
||||
'marken/rotpunkt/kueche-06-b.webp' => 'Küche 06, zweite Ansicht',
|
||||
'marken/rotpunkt/kueche-07.webp' => 'Küche 07',
|
||||
'marken/rotpunkt/kueche-08.webp' => 'Küche 08',
|
||||
'marken/rotpunkt/kueche-09.webp' => 'Küche 09',
|
||||
'marken/rotpunkt/kueche-10.webp' => 'Küche 10',
|
||||
'marken/rotpunkt/kueche-11.webp' => 'Küche 11',
|
||||
'marken/rotpunkt/kueche-12.webp' => 'Küche 12',
|
||||
],
|
||||
],
|
||||
// Bildunterschriften nach den Febrü-Produktlinien aus den Dateinamen.
|
||||
'februe' => [
|
||||
'image' => 'marken/februe/base-thekenloesung.webp',
|
||||
'gallery' => [
|
||||
'marken/februe/active-2-0-bureo.webp' => 'Active 2.0 mit Bureo',
|
||||
'marken/februe/active-2-0-soul-grossraum.webp' => 'Großraum mit Active 2.0 und Soul',
|
||||
'marken/februe/adesso.webp' => 'Adesso',
|
||||
'marken/februe/arena-stage.webp' => 'Arena Stage',
|
||||
'marken/februe/base.webp' => 'Base',
|
||||
'marken/februe/base-sideboard.webp' => 'Base Sideboard',
|
||||
'marken/februe/bobb-bionics-coworker-container-cube.webp' => 'Bobb, Bionics, CoWorker, Container und Cube',
|
||||
'marken/februe/bobb.webp' => 'Bobb',
|
||||
'marken/februe/brandon.webp' => 'Brandon',
|
||||
'marken/februe/coworker.webp' => 'CoWorker',
|
||||
'marken/februe/corporate.webp' => 'Corporate',
|
||||
'marken/februe/curved-ben-basic.webp' => 'Curved, Ben und Basic',
|
||||
'marken/februe/eline-one-silent-garderobenpaneel.webp' => 'E-Line, One, Silent und Garderobenpaneel',
|
||||
'marken/februe/elements-soul-grossraum.webp' => 'Großraum mit Elements und Soul',
|
||||
'marken/februe/vio.webp' => 'Vio',
|
||||
],
|
||||
],
|
||||
// Otterpohl (Zulieferung 24.09.): Titelbild und 2–5 aus der Werkstatt, der Rest sind Referenzen.
|
||||
// Unterschriften ohne Kundennamen – die stehen nur in den Dateinamen der Zulieferung.
|
||||
'tischlerei' => [
|
||||
'image' => 'marken/otterpohl/werkstatt-titel.webp',
|
||||
'gallery' => [
|
||||
'marken/otterpohl/referenz-luecke-01.webp' => ['caption' => 'Treppe mit Lamellenwand, Sitzbank und Sideboard', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-dziuballe-treppe.webp' => ['caption' => 'Treppe mit Holzstufen, Stufenbeleuchtung und Eichenhandlauf', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-luecke-12.webp' => ['caption' => 'Tür und Kommode vor Lamellenwand', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-neugebauer.webp' => ['caption' => 'Gewendelte Treppe mit Eichenstufen', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-luecke-15.webp' => ['caption' => 'Beleuchtete Wandregale über Eck', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-berkemeier-treppe.webp' => ['caption' => 'Treppe mit umlaufender Sockelleiste und Lichtdetail', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-objektbau-luecke-technik.webp' => ['caption' => 'Objektbau: Verkaufsraum mit Theke und Regalwand', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-otterpohl-11.webp' => ['caption' => 'Sideboard mit Eichenplatte im Büro', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/werkstatt-02.webp' => ['caption' => 'CNC-Bearbeitungszentrum in der Fertigung', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
'marken/otterpohl/werkstatt-03.webp' => ['caption' => 'Einbau einer Tür vor Ort', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
'marken/otterpohl/werkstatt-04.webp' => ['caption' => 'Handwerkzeug in der Werkstatt', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
'marken/otterpohl/werkstatt-05-lift.webp' => ['caption' => 'Anlieferung per Möbellift – Montage inklusive', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach (self::IMAGES as $slug => $data) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$titleSet = false;
|
||||
|
||||
if (trim((string) $brand->image) === '') {
|
||||
$brand->image = $data['image'];
|
||||
$titleSet = true;
|
||||
}
|
||||
|
||||
$gallery = CmsBrand::normalizeGallery($brand->gallery);
|
||||
$known = array_column($gallery, 'image');
|
||||
$added = 0;
|
||||
|
||||
foreach ($data['gallery'] as $image => $entry) {
|
||||
if (in_array($image, $known, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gallery[] = [
|
||||
'image' => $image,
|
||||
'category' => is_array($entry) ? $entry['category'] : CmsBrand::GALLERY_INSPIRATION,
|
||||
'caption' => is_array($entry) ? $entry['caption'] : $entry,
|
||||
];
|
||||
$added++;
|
||||
}
|
||||
|
||||
$brand->gallery = $gallery;
|
||||
$brand->save();
|
||||
|
||||
$this->command?->info(sprintf(
|
||||
'Marke „%s“: Titelbild %s, %d Galeriebilder ergänzt, %d vorhanden.',
|
||||
$slug,
|
||||
$titleSet ? 'gesetzt' : 'behalten',
|
||||
$added,
|
||||
count($gallery) - $added,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
72
database/seeders/BrandLogoSeeder.php
Normal file
72
database/seeders/BrandLogoSeeder.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Traegt die Markenlogos nach.
|
||||
*
|
||||
* Grundlage: dev/2026-08-28/markenlogos/ (Zulieferung vom 03.09.2026). Die Dateien liegen unter
|
||||
* public/img/assets/marken/ und sind dort **optisch aufeinander abgestimmt** – das Frontend
|
||||
* rendert sie in einer gemeinsamen Box, in der jedes Logo seine Zeichenflaeche ausfuellt:
|
||||
*
|
||||
* Rotpunkt Zeichenflaeche = Inhalt → fuellt die Box, gilt als Bezugsgroesse
|
||||
* CABINET Zeichenflaeche rechts +18 % → rendert 15 % kleiner als Rotpunkt
|
||||
* Februe Zeichenflaeche oben/unten +20 % → rendert wie vor der Abstimmung
|
||||
* Otterpohl knapp beschnitten, weiss freigestellt (Zulieferung 17.09.2026, Nachtrag 15)
|
||||
*
|
||||
* Alle drei waren in der Zulieferung sehr unterschiedlich stark umrandet (CABINET nutzte nur
|
||||
* 29 Prozent der Hoehe) und wirkten dadurch ungleich gross. Die Abstimmung steckt bewusst in den
|
||||
* Dateien und nicht im Blade: so gilt sie an allen vier Stellen gleichzeitig – Seitenkopf,
|
||||
* Startseiten-Kachel, /marken und „Weitere Marken“.
|
||||
*
|
||||
* Wird ein Logo im Admin ausgetauscht, sollte die neue Datei genauso knapp beschnitten sein.
|
||||
* Ohne Rand rendert sie in Rotpunkt-Groesse, mit viel Rand entsprechend kleiner.
|
||||
*
|
||||
* {@see CmsBrandSeeder} bringt dieselben Werte fuer frische Installationen mit, ueberspringt
|
||||
* aber bereits angelegte Marken. Dieser Seeder fasst nur das Feld `logo` an und auch nur dann,
|
||||
* wenn es leer ist – ein selbst hochgeladenes Logo bleibt also stehen. Mehrfaches Ausfuehren
|
||||
* ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=BrandLogoSeeder
|
||||
*/
|
||||
class BrandLogoSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private const LOGOS = [
|
||||
'cabinet' => 'marken/cabinet.svg',
|
||||
'rotpunkt' => 'marken/rotpunkt.svg',
|
||||
'februe' => 'marken/februe.png',
|
||||
'tischlerei' => 'marken/otterpohl.png',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$set = 0;
|
||||
$kept = 0;
|
||||
|
||||
foreach (self::LOGOS as $slug => $logo) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – uebersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trim((string) $brand->logo) !== '') {
|
||||
$kept++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand->logo = $logo;
|
||||
$brand->save();
|
||||
$set++;
|
||||
}
|
||||
|
||||
$this->command?->info("Markenlogos gesetzt: {$set}, vorhandene behalten: {$kept}.");
|
||||
}
|
||||
}
|
||||
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' => 'Tischlerei und Innenausbau aus Rheda-Wiedenbrück – vom einzelnen Möbel bis zum ganzen Raum, inklusive Montage.',
|
||||
'en' => 'Joinery and interior fit-out from Rheda-Wiedenbrück – from a single piece to the whole room, installation included.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
438
database/seeders/BrandPageTextSeeder.php
Normal file
438
database/seeders/BrandPageTextSeeder.php
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Ausführliche Markenseiten für CABINET, Rotpunkt Küchen und Febrü.
|
||||
*
|
||||
* Grundlage für Rotpunkt und Febrü sind die abgestimmten Fassungen:
|
||||
* dev/2026-08-28/Rotpunkt_Webseitentext_FINAL.md
|
||||
* dev/2026-08-28/Februe_Webseitentext_FINAL.md
|
||||
*
|
||||
* Die dort mit „AUFKLAPPBARER DETAILBEREICH“ gekennzeichneten Vertiefungen stehen hier als
|
||||
* `detail` am jeweiligen Abschnitt und werden im Frontend aufklappbar dargestellt. Die internen
|
||||
* Quellen- und Umsetzungshinweise sind bewusst nicht übernommen – sie sind laut Zulieferung
|
||||
* nicht zur Veröffentlichung bestimmt.
|
||||
*
|
||||
* ⚠️ Für CABINET lag keine Zulieferung vor. Der Text ist nach dem Vorbild der beiden
|
||||
* abgestimmten Seiten von uns verfasst und beschreibt bewusst nur die eigene Arbeitsweise:
|
||||
* keine Zahlen, Zertifizierungen oder Herstelleraussagen, die niemand belegen kann. Er braucht
|
||||
* die Freigabe durch Marcel Scheibe, bevor er live geht.
|
||||
*
|
||||
* Feedback 17.09.2026 (dev/2026-08-28/feedback-17-09-2026.md) eingearbeitet: CABINET Einstieg
|
||||
* „Bild und grobes Maß“ und die Fragen vorab; Febrü „Kunden – keine Schubladen“, „Aus Herford“
|
||||
* und „Wertschätzung“, doppelter Schlusssatz entfernt; Rotpunkt „Mehr als Küchen“. Die
|
||||
* markeneigenen Handwerk-Abschnitte sind entfallen: das Thema steht als gemeinsamer Abschluss
|
||||
* auf jeder Markenseite (Lang-Sektion `brand_craft`, siehe BrandDetail).
|
||||
*
|
||||
* ⚠️ Überschreibt bei bestehenden Marken **nur** die Felder aus {@see FIELDS}. Bilder, Galerie,
|
||||
* Leistungen und Reihenfolge bleiben unangetastet, damit gepflegte Inhalte erhalten bleiben.
|
||||
*
|
||||
* php artisan db:seed --class=BrandPageTextSeeder
|
||||
*/
|
||||
class BrandPageTextSeeder extends Seeder
|
||||
{
|
||||
/** Felder, die dieser Seeder anfasst. Alles andere bleibt, wie Marcel es gepflegt hat. */
|
||||
private const FIELDS = ['tagline', 'description', 'sections', 'widget_label', 'cta_title', 'cta_text'];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$updated = 0;
|
||||
|
||||
foreach ($this->pages() as $slug => $data) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (self::FIELDS as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$brand->{$field} = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$brand->save();
|
||||
$updated++;
|
||||
|
||||
$this->command?->info("Markenseite „{$slug}“: ".count($data['sections'] ?? []).' Abschnitte übernommen.');
|
||||
}
|
||||
|
||||
$this->command?->info("Markenseiten aktualisiert: {$updated}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function pages(): array
|
||||
{
|
||||
return [
|
||||
'cabinet' => [
|
||||
'widget_label' => ['de' => 'Persönliche Schrankplanung vereinbaren', 'en' => 'Arrange a personal wardrobe consultation'],
|
||||
'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>',
|
||||
'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.'],
|
||||
'cta_text' => [
|
||||
'de' => 'Jeder Raum hat seine Ecken, Schrägen und Eigenheiten. Gemeinsam planen wir eine CABINET-Lösung, die genau dort hineinpasst, im Alltag funktioniert und den vorhandenen Platz vollständig nutzt. Persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
|
||||
'en' => 'Every room has its corners, slopes and quirks. Together we plan a CABINET solution that fits exactly, works day to day and uses the available space completely.',
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Millimeter statt Standardmaß',
|
||||
'paragraphs' => [
|
||||
'Schränke von der Stange sind für den durchschnittlichen Raum gemacht. Nur wohnt niemand im durchschnittlichen Raum. Es gibt die Schräge, den Mauervorsprung, das Fenster an der ungünstigen Stelle und den Heizkörper, den niemand versetzen möchte.',
|
||||
'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',
|
||||
'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.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'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.',
|
||||
'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.',
|
||||
],
|
||||
'claim' => 'So läuft’s: Foto + grobe Maße → Beratung → Planung & Angebot → Aufmaß vor Ort → Feinplanung → Bestellung',
|
||||
],
|
||||
[
|
||||
'title' => 'Innen entscheidet sich, ob ein Schrank funktioniert',
|
||||
'paragraphs' => [
|
||||
'Eine schöne Front ist schnell ausgesucht. Ob ein Schrank im Alltag trägt, entscheidet sich dahinter: bei Einteilung, Auszügen, Fachhöhen, Beleuchtung und der Frage, was in welcher Höhe erreichbar sein muss.',
|
||||
'Wir planen die Innenausstattung deshalb nicht nach Katalogseite, sondern nach dem, was tatsächlich untergebracht werden soll.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Die Fragen, die wir vorher stellen',
|
||||
'paragraphs' => [
|
||||
'Wie viel Hängefläche wird wirklich gebraucht und wie viel Fachboden? Wo sollen Schuhe, Koffer, Bettwäsche, Sportsachen oder Weihnachtsdeko hin? Wer nutzt den Schrank – und wie groß ist diese Person?',
|
||||
'Auch die Dinge, die man nicht jeden Tag braucht, gehören in die Planung: Wäschekörbe, Bügelbrett, Staubsauger, Karnevalskostüme oder besondere Sportsachen. Wenn wir vorher wissen, was untergebracht werden soll, können wir den Platz direkt sinnvoll dafür einplanen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Fronten, die zum Raum gehören',
|
||||
'paragraphs' => [
|
||||
'Ein Einbauschrank kann bewusst auffallen oder nahezu verschwinden. Fronten in Farbe, Holzoptik, Glas oder Spiegel, mit Griff oder grifflos, verändern die Wirkung eines Raumes erheblich – gerade weil ein raumhoher Schrank eine große Fläche ist.',
|
||||
'Wir stimmen Fronten, Griffe und Oberflächen auf die vorhandene Architektur ab: auf Bodenbelag, Türen, Licht und angrenzende Möbel. So entsteht kein Fremdkörper, sondern eine ruhige Fläche, die den Raum größer wirken lässt.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Sichtbar oder unsichtbar – beides ist eine Entscheidung',
|
||||
'paragraphs' => [
|
||||
'Eine wandfarbene, grifflose Front tritt zurück und lässt den Raum offener wirken. Eine Spiegelfront bringt Licht in schmale Flure und Ankleiden. Eine dunkle Holzfront setzt dagegen bewusst einen gestalterischen Schwerpunkt.',
|
||||
'Beides ist richtig – nur eben nicht gleichzeitig. Wir klären deshalb früh, welche Rolle der Schrank im Raum übernehmen soll, bevor wir über Dekore sprechen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Mehr als Schlafzimmer',
|
||||
'paragraphs' => [
|
||||
'Maßgefertigter Stauraum ist längst nicht auf die Ankleide beschränkt. Genauso gefragt sind Garderoben im Flur, Hauswirtschaftsräume, Dachgeschosse, Arbeitszimmer, Wohnwände mit Medientechnik und Aktenschränke in Praxen und Kanzleien.',
|
||||
'Materialien, Farben und Gestaltungselemente lassen sich über mehrere Räume hinweg aufgreifen. So entsteht nicht in jedem Zimmer eine neue Idee, sondern ein zusammenhängendes Interior-Konzept mit einer gemeinsamen gestalterischen Sprache.',
|
||||
'Zusammen mit Rotpunkt Küchen, Febrü und weiteren ausgewählten Partnern betrachten wir bei Bedarf den gesamten Innenraum: CABINET übernimmt die millimetergenaue Sonderlösung, Rotpunkt die Küche und Febrü die Arbeitswelt.',
|
||||
],
|
||||
'claim' => 'CABINET für den Millimeter. B2in für das Ganze.',
|
||||
],
|
||||
[
|
||||
'title' => 'Warum CABINET bei uns?',
|
||||
'paragraphs' => [
|
||||
'Bei uns beginnt eine Planung nicht mit einem Prospekt, sondern mit Ihrem Raum und der Frage, wie Sie darin leben möchten. Erst danach sprechen wir über Fronten, Auszüge und Beschläge.',
|
||||
'Wir planen Stauraum, Wege, Licht, Materialien und angrenzende Wohnbereiche als zusammenhängendes Konzept – und begleiten den gesamten Weg von der ersten Idee über das Aufmaß bis zur fertigen Montage.',
|
||||
'Dabei planen wir nicht für den nächsten schnellen Verkauf, sondern für eine möglichst lange und zufriedene Nutzung. Ein Schrank, der zu viel kann, aber nicht gebraucht wird, ist keine gute Lösung – nur eine teure.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Erleben statt nur beschreiben',
|
||||
'paragraphs' => [
|
||||
'Oberflächen, Beschläge und Auszüge lassen sich schlecht beschreiben und gut ausprobieren. In unserem Showroom in Bielefeld können Sie Fronten anfassen, Auszüge öffnen, Innenausstattungen vergleichen und die Unterschiede selbst beurteilen.',
|
||||
'Sie müssen sich also nicht allein auf unser Verkaufsgespräch verlassen, sondern entscheiden auf Grundlage dessen, was Sie tatsächlich gesehen und angefasst haben.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'rotpunkt' => [
|
||||
'tagline' => ['de' => 'Küchen, die Haltung zeigen.', 'en' => 'Kitchens with character.'],
|
||||
'widget_label' => ['de' => 'Persönliche Küchenberatung vereinbaren', 'en' => 'Arrange a personal kitchen consultation'],
|
||||
'description' => [
|
||||
'de' => '<p>Eine Küche ist weit mehr als ein funktionaler Raum. Sie ist Treffpunkt, Arbeitsplatz und Mittelpunkt des täglichen Lebens. Und Hand aufs Herz: Wo finden die besten Gespräche statt – und wo enden die meisten Partys? Meistens in der Küche.</p>'
|
||||
.'<p>Rotpunkt verbindet außergewöhnliche Gestaltungsfreiheit, intelligente Stauraumlösungen und durchdachte Materiallösungen zu Küchen, die nicht nur gut aussehen, sondern dauerhaft zum Leben ihrer Besitzer passen.</p>'
|
||||
.'<p>Unsere Aufgabe ist es, genau herauszuarbeiten, welche Lösung zu Ihnen, Ihrem Alltag und Ihren persönlichen Vorstellungen passt. Bei uns in der Bielefelder Rathausstraße entsteht daraus nicht einfach nur eine Küche. Auf Wunsch denken wir das ganze Zuhause mit und ergänzen es bei Bedarf bis ins Detail handwerklich.</p>',
|
||||
'en' => '<p>A kitchen is far more than a functional room. It is a meeting place, a workspace and the centre of daily life.</p>',
|
||||
],
|
||||
'cta_title' => ['de' => 'Ihre Küche. Persönlich geplant.', 'en' => 'Your kitchen. Personally planned.'],
|
||||
'cta_text' => [
|
||||
'de' => 'Jeder Raum ist anders. Jeder Alltag stellt andere Anforderungen. Gemeinsam entwickeln wir eine Rotpunkt-Küche, die gestalterisch überzeugt, im Alltag funktioniert und dauerhaft zu Ihnen passt. Persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
|
||||
'en' => 'Every room is different. Together we develop a Rotpunkt kitchen that convinces in design, works day to day and suits you for the long run.',
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Aus Ostwestfalen. Für Ihr Zuhause.',
|
||||
'paragraphs' => [
|
||||
'Rotpunkt entwickelt und fertigt seit 1930 hochwertige Küchenmöbel an den ostwestfälischen Standorten Bünde und Preußisch Oldendorf-Getmold.',
|
||||
'Regionale Fertigung, jahrzehntelange Erfahrung und die unmittelbare Nähe zum Hersteller sind für uns keine romantischen Randnotizen, sondern die Grundlage für verlässliche Qualität und persönliche Küchenplanung.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Gestaltungsfreiheit bis ins Detail',
|
||||
'paragraphs' => [
|
||||
'Rotpunkt bietet eine außergewöhnliche Vielfalt an Fronten, Farben, Materialien, Korpusdekoren, Arbeitsplatten, Griffvarianten und grifflosen Lösungen. Mehr als 20 beidseitig ausgeführte Korpusfarben sorgen dafür, dass eine Küche nicht nur außen, sondern auch innen konsequent gestaltet werden kann.',
|
||||
'So planen wir keinen vorgegebenen Küchenblock, sondern eine Lösung, die sich an Raum, Architektur und den tatsächlichen Gewohnheiten ihrer Besitzer orientiert.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'FENIX und authentische Holzoptiken',
|
||||
'paragraphs' => [
|
||||
'FENIX ist eine besonders matte Oberfläche mit geringer Lichtreflexion, angenehm weicher Haptik und ausgeprägten Anti-Fingerprint-Eigenschaften. Oberflächliche Mikrokratzer können thermisch behandelt werden. 27 aufeinander abgestimmte Farben eröffnen eine breite Gestaltungsvielfalt.',
|
||||
'Auch bei ausgewählten Fronten in Holzoptik zeigt sich die Liebe zum Detail. Synchronporen-Dekore stimmen sichtbare Maserung und fühlbare Struktur präzise aufeinander ab. Dadurch entsteht bereits bei pflegeleichten Melamin-Holzreproduktionen eine natürliche Wirkung.',
|
||||
'Durch eine sorgfältige Planung kann die Holzoptik über mehrere Fronten hinweggeführt werden. Das Ergebnis ist ein ruhiges, zusammenhängendes Frontbild – ohne dass dafür zwingend Echtholz eingesetzt werden muss.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Mehr Raum. Mehr Ergonomie. Mehr Küche.',
|
||||
'paragraphs' => [
|
||||
'Besonders hohe Unterschränke, niedrige Sockelhöhen und zusätzliche Schrankbreiten schaffen mehr nutzbaren Stauraum und eröffnen neue planerische Möglichkeiten. Klingt nach Kleinkram? Vielleicht. Im täglichen Gebrauch macht genau dieser Kleinkram einen erstaunlich großen Unterschied.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'NEW DIMENSIONS',
|
||||
'paragraphs' => [
|
||||
'Mit dem Maßkonzept NEW DIMENSIONS sind beispielsweise Unterschrankhöhen von bis zu 910 Millimetern in Verbindung mit einem nur 50 Millimeter hohen Sockel möglich.',
|
||||
'Zusätzliche Schrankbreiten und vielfältige Innenausstattungen helfen uns, den vorhandenen Raum konsequent und ergonomisch zu nutzen. Die Küche wirkt dadurch nicht nur großzügiger – sie ist es tatsächlich.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Nachhaltigkeit mit konkreter Substanz',
|
||||
'paragraphs' => [
|
||||
'Nachhaltigkeit braucht keine großen Versprechen, sondern nachvollziehbare Fakten. Greenline steht bei Rotpunkt für konkrete Materialien, Zertifizierungen und messbare Unterschiede. Deshalb legen wir offen, welche Materialien Rotpunkt verwendet, welche Zertifizierungen dahinterstehen und welchen konkreten Unterschied Greenline für Umwelt und Raumklima macht.',
|
||||
'Die Greenline BioBoard Gen2 besteht aus bis zu 90 Prozent recyceltem Holz. 95 Prozent des Rotpunkt-Küchensortiments sind FSC-zertifiziert. Außerdem sind die Produkte nach JIS F4Star hinsichtlich ihrer Formaldehydemissionen zertifiziert.',
|
||||
'Sie haben bereits eine vergleichbare Küche im Blick, die beim Thema Nachhaltigkeit mehr verspricht? Bringen Sie die Unterlagen gerne mit. Wir schauen gemeinsam genau hin – ehrlich, nachvollziehbar und ohne Verkaufsnebel.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Zahlen, Zertifizierungen und Raumklima',
|
||||
'paragraphs' => [
|
||||
// ⚠️ Waldflächen-Aussage: laut Zulieferung nur mit ausdrücklicher Herstellerbestätigung
|
||||
// veröffentlichen. Steht als Punkt in der Deployment-Checkliste.
|
||||
'Nach Angaben von Rotpunkt bleibt durch den Einsatz der Greenline-Spanplatten jährlich eine Waldfläche von ungefähr 50 Fußballfeldern erhalten.',
|
||||
'Die Greenline BioBoard Gen2 gibt laut Rotpunkt rund 50 Prozent weniger Formaldehyd an die Raumluft ab als herkömmliche Produkte. Das ist insbesondere für ein möglichst gesundes Raumklima relevant.',
|
||||
'Die FSC-Zertifizierung wird unter der Kennung FSC C131864 geführt. Entscheidend ist für uns nicht das grüne Etikett, sondern die konkrete und überprüfbare Aussage dahinter.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Mehr als Küchen',
|
||||
'paragraphs' => [
|
||||
'Die Möglichkeiten von Rotpunkt enden nicht bei der klassischen Küche. Das Möbel- und Planungssystem lässt sich auch für Hauswirtschaftsräume, Badezimmer, Sideboards, Wohnmöbel und weitere Bereiche rund um das Wohnen einsetzen.',
|
||||
'Materialien, Farben und Gestaltungselemente können dabei über mehrere Räume hinweg aufgegriffen werden. So entsteht ein stimmiges Gesamtbild, statt in jedem Raum wieder ganz von vorne anzufangen.',
|
||||
'Bei Bedarf denken wir den Innenraum deshalb auch über die Küche hinaus – von Möbeln und Beleuchtung bis zu Tapeten, Fußböden und, wenn es sein soll, der letzten Blume.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Warum eine Rotpunkt-Küche bei uns?',
|
||||
'paragraphs' => [
|
||||
'Bei uns beginnt eine neue Küche nicht mit einem Prospekt, sondern mit Ihrem Raum und der Frage, wie Sie darin leben möchten.',
|
||||
'Arbeitsabläufe, Ergonomie, Stauraum, Materialien, Beleuchtung, Geräte und angrenzende Wohnbereiche planen wir als zusammenhängendes Konzept. Auch die Partystellplätze wollen wohlüberlegt sein – also jene Orte, an denen Gäste später stehen, sitzen, reden und vielleicht etwas länger bleiben.',
|
||||
'Wir stellen nicht einfach möglichst viele Produkte zusammen. Wir entwickeln eine Küche, die gestalterisch, funktional und wirtschaftlich überzeugt – und begleiten den gesamten Weg von der ersten Idee bis zur professionellen Montage.',
|
||||
'Dabei planen wir nicht für den nächsten schnellen Verkauf, sondern für eine möglichst lange und zufriedene Nutzung. Das ist kurzfristig vielleicht nicht immer die wirtschaftlichste Entscheidung für uns. Für eine ehrliche und dauerhafte Kundenbeziehung ist es die einzig richtige.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Erleben statt nur erzählen',
|
||||
'paragraphs' => [
|
||||
'Eine gute Entscheidung entsteht nicht allein am Planungstisch. Auf Wunsch – und manchmal auch mit etwas freundlicher Überzeugungsarbeit – begleiten wir Sie nach Abstimmung in das nahe Rotpunkt-Werk oder in ausgewählte Ausstellungen der Gerätehersteller.',
|
||||
'Dort können Sie Materialien, Fertigung und Technik selbst erleben, Geräte ausprobieren und Fragen direkt vor Ort stellen. Sie müssen sich also nicht allein auf unser Verkaufsgespräch verlassen, sondern erhalten Informationen aus erster Hand, können vergleichen und anschließend mit einem sicheren Gefühl entscheiden.',
|
||||
'Wir begleiten Sie dabei, ordnen die unterschiedlichen Möglichkeiten ein und führen Ihre Entscheidungen zu einem stimmigen Gesamtkonzept zusammen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'februe' => [
|
||||
'tagline' => ['de' => 'Arbeiten, wo man gerne bleibt.', 'en' => 'Work where people like to stay.'],
|
||||
'widget_label' => ['de' => 'Persönliche Arbeitsplatzberatung vereinbaren', 'en' => 'Arrange a personal workspace consultation'],
|
||||
'description' => [
|
||||
'de' => '<p>Ein Büro ist kein Parkplatz für Schreibtische. Es ist ein Raum für Konzentration, Austausch, Bewegung und neue Ideen. Und manchmal entsteht die beste Idee eben nicht am Schreibtisch, sondern zwischen Kaffeemaschine, Lounge und einem guten Gespräch.</p>'
|
||||
.'<p>Febrü verbindet ergonomische Arbeitsplätze mit wohnlichem Design, intelligentem Stauraum, durchdachter Akustik und flexiblen Raumstrukturen.</p>'
|
||||
.'<p>Wir entwickeln daraus Arbeitswelten, die zu den Menschen, ihren Aufgaben und ihrer Umgebung passen – vom einzelnen Homeoffice bis zur vollständigen Unternehmensfläche.</p>',
|
||||
'en' => '<p>An office is not a car park for desks. It is a space for concentration, exchange, movement and new ideas.</p>',
|
||||
],
|
||||
'cta_title' => ['de' => 'Ihr Arbeitsplatz. Persönlich geplant.', 'en' => 'Your workspace. Personally planned.'],
|
||||
'cta_text' => [
|
||||
'de' => 'Ob Homeoffice, Praxis, Kanzlei, wachsendes Unternehmen oder vollständige Arbeitswelt: Wir planen jeden Arbeitsplatz mit demselben Anspruch. Nur der Umfang verändert sich. Gemeinsam entwickeln wir eine Arbeitsumgebung, die gesundes Arbeiten unterstützt, gestalterisch überzeugt und den Menschen zeigt, dass ihre tägliche Leistung wahrgenommen und wertgeschätzt wird – persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
|
||||
'en' => 'Whether a home office, a practice, a law firm or a complete working environment: we plan every workspace to the same standard. Only the scope changes.',
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Zuhause oder Projekt. Zwei Wege. Derselbe Anspruch.',
|
||||
'paragraphs' => [
|
||||
'Unsere Startseite führt bewusst über zwei Wege: Zuhause und Projekt. Denn ein einzelnes Homeoffice stellt andere Anforderungen als eine Büroetage mit fünfzig Arbeitsplätzen.',
|
||||
'Unterschiedliche Aufgaben brauchen unterschiedliche Planung und natürlich auch unterschiedliche Kalkulationen. Bei uns gibt es trotzdem nur Kunden – mit unterschiedlichen Räumen, Anforderungen und Stückzahlen. Der Anspruch an Produkt, Beratung und Service bleibt derselbe.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Bei uns gibt es Kunden – keine Schubladen',
|
||||
'paragraphs' => [
|
||||
'Ob Privatkunde oder Unternehmen: Für uns gibt es zunächst einmal Kunden mit unterschiedlichen Räumen, Anforderungen und Stückzahlen.',
|
||||
'Natürlich beeinflusst die Menge den Preis. Fünfzig Arbeitsplätze werden anders kalkuliert als ein einzelner Schreibtisch fürs Homeoffice. Der Anspruch an Produkt, Beratung und Service bleibt trotzdem derselbe.',
|
||||
'Febrü bleibt Febrü. B2in bleibt B2in. Und jeder Kunde erhält eine Lösung, die zu seinem tatsächlichen Bedarf passt.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Ob im Kleinen oder im Großen – die Detailtreue entscheidet.',
|
||||
],
|
||||
[
|
||||
'title' => 'Aus Herford. Für moderne Arbeitswelten.',
|
||||
'paragraphs' => [
|
||||
'Febrü entwickelt und produziert hochwertige Büromöbel direkt im ostwestfälischen Herford – nur wenige Kilometer von Bielefeld entfernt. Eine sehr hohe Fertigungstiefe ermöglicht kurze Wege, verlässliche Qualität und hohe Flexibilität bei Materialien, Farben und individuellen Anforderungen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Von einem Schreibtisch bis zum vollständigen Bürotrakt',
|
||||
'paragraphs' => [
|
||||
'Das Sortiment reicht von ergonomischen und höhenverstellbaren Schreibtischen über Konferenz- und Besprechungstische, Bürostühle und Loungeelemente bis zu Empfangslösungen, Stauraum, Akustikelementen, mobilen Medienmöbeln, Schließfachsystemen und flexiblen Raumstrukturen.',
|
||||
'Damit lässt sich ein einzelner Arbeitsplatz genauso überzeugend planen wie eine Praxis, eine Kanzlei, ein Konferenzbereich oder ein vollständiger Bürotrakt.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Made in Herford. Geplant in Bielefeld.',
|
||||
],
|
||||
[
|
||||
'title' => 'Nicht drei Marken. Ein durchgängiger Raum.',
|
||||
'paragraphs' => [
|
||||
'Ein Bürotrakt endet nicht am Schreibtisch. Menschen brauchen Orte zum Arbeiten, für Besprechungen und Rückzug – aber ebenso für Pausen, den gemeinsamen Kaffee, das Mittagessen und den informellen Austausch.',
|
||||
'Genau hier greifen Febrü, Rotpunkt und CABINET bei uns ineinander: Febrü gestaltet die Arbeitswelt, Rotpunkt die Küche im Bürotrakt und CABINET die millimetergenaue Sonderlösung.',
|
||||
'Entscheidend ist nicht, welcher Hersteller welches Möbel liefert. Entscheidend ist, dass am Ende alles zusammengehört. Wir stimmen Arbeitsplätze, Küche, Stauraum, Farben, Materialien, Beleuchtung und Akustik zu einem durchgängigen Konzept aufeinander ab.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Wie die drei Marken zusammenspielen',
|
||||
'paragraphs' => [
|
||||
'Febrü gestaltet ergonomische Arbeitsplätze, Konferenzbereiche, Loungezonen, Akustik, Stauraum und flexible Raumstrukturen.',
|
||||
'Rotpunkt gestaltet Mitarbeiterküchen, Coffee-Points und gemeinsam genutzte Aufenthaltsküchen als selbstverständlichen Bestandteil der Arbeitswelt – und nicht als übrig gebliebene Funktionszeile. Gerade hier entstehen oft Gespräche, Verbindungen und Ideen, für die im offiziellen Meeting kein Platz war.',
|
||||
'CABINET übernimmt dort, wo Nischen vollständig genutzt, Schränke von Wand zu Wand und vom Boden bis zur Decke geplant oder besondere Übergänge zwischen Arbeitsplatz, Stauraum und Architektur geschaffen werden sollen. So entstehen beispielsweise maßgefertigte Garderoben, Aktenschränke, Medienlösungen und Stauraumelemente.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Febrü für die Arbeitswelt. Rotpunkt für die Küche. CABINET für die millimetergenaue Sonderlösung. B2in für das Ganze.',
|
||||
],
|
||||
[
|
||||
'title' => 'Ein Arbeitsplatz. Fünfzig Arbeitsplätze. Ein Anspruch.',
|
||||
'paragraphs' => [
|
||||
'Ob wir über einen einzelnen Arbeitsplatz oder einen vollständigen Bürotrakt sprechen: Das grundsätzliche Problem bleibt dasselbe. Fühlt sich der Mensch, der dort täglich arbeitet, gesehen und wertgeschätzt – oder wurde ihm lediglich irgendwo ein Tisch hingestellt?',
|
||||
'Die Stückzahl verändert den Umfang der Aufgabe, aber nicht ihre Bedeutung. Wir bewerten einen Arbeitsplatz deshalb nicht nach Quadratmetern oder Auftragswert, sondern danach, was er für den Menschen leisten muss.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Wertschätzung ist keine Frage der Stückzahl',
|
||||
'paragraphs' => [
|
||||
'Gerade in Zeiten des Fachkräftemangels können Unternehmen es sich nicht leisten, Arbeitsbedingungen als Nebensache zu behandeln. Wer Top-Leistung erwartet, muss die passenden Voraussetzungen schaffen. Ein Obstkorb und kostenloses Wasser sind freundlich – ersetzen aber keinen guten Arbeitsplatz.',
|
||||
'Besonders deutlich wird das bei Menschen, die Beruf und Familie miteinander organisieren. Teilzeit bedeutet nicht halbe Leistung. Oft wird in einem kürzeren Zeitfenster hoch konzentriert gearbeitet, während gleichzeitig Schule, Termine, Betreuung und der ganz normale Familienalltag organisiert werden müssen.',
|
||||
'Gerade hier darf der Arbeitsplatz kein Provisorium aus Küchentisch, Beistelltisch oder Schlafzimmerecke sein. Er muss sofort funktionieren, Bewegung ermöglichen, technisch durchdacht sein und sich trotzdem vernünftig in das Zuhause einfügen. Für den Menschen, der ihn täglich nutzt, ist ein einzelner Arbeitsplatz keine kleine Lösung. Er ist seine vollständige Arbeitswelt.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Ergonomie, die im Alltag funktioniert',
|
||||
'paragraphs' => [
|
||||
'Ein Arbeitsplatz muss sich dem Menschen anpassen – nicht umgekehrt. Höhenverstellbare Schreibtische und ergonomische Bürostühle schaffen Bewegung und unterstützen eine gesunde Haltung.',
|
||||
'Ergonomie endet aber nicht bei Tisch und Stuhl. Auch Laufwege, Greifräume, Bildschirmposition, Beleuchtung, Stauraum und der Wechsel zwischen Tätigkeiten bestimmen, wie angenehm und produktiv ein Arbeitstag verläuft.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Und nein: Wir meinen nicht nur den Weg zur Kaffeemaschine',
|
||||
'paragraphs' => [
|
||||
'Sinnvolle Laufwege sind Teil einer guten Planung – auch wenn Kaffeemaschine und Kühlschrank zweifellos eine gewisse Bedeutung für den Arbeitsalltag haben.',
|
||||
'Das gilt für vier Stunden ebenso wie für zehn Stunden am Schreibtisch, im Unternehmen genauso wie im privaten Zuhause. Und natürlich müssen die Möbel gestalterisch zum Raum passen. Wer denkt: „Das sieht doch keiner – außer den Mitarbeitern“, hat den ersten entscheidenden Fehler bereits gemacht. Die Mitarbeiter sehen es. Jeden Tag. Und sie arbeiten darin.',
|
||||
],
|
||||
],
|
||||
'quote' => 'Ich bin, sagen wir, alt genug, um beim Aufstehen gelegentlich zu stöhnen. Und ehrlich gesagt fühle ich mich an manchen Tagen noch etwas älter. Mein Arzt sagt, ich solle weniger sitzen. Mein Beruf findet nun einmal zu einem großen Teil am Computer statt. Ergo brauche ich eine Lösung, die mir mehr Bewegung ermöglicht, ohne dass ich dafür meinen Beruf wechseln muss.',
|
||||
'quote_author' => 'Marcel Scheibe, Geschäftsführer von B2in',
|
||||
],
|
||||
[
|
||||
'title' => 'Office meets Interior',
|
||||
'paragraphs' => [
|
||||
'Identische Schreibtischreihen werden einem modernen Arbeitstag nicht mehr gerecht. Menschen benötigen Bereiche für Konzentration, spontane Gespräche, Teamarbeit, Videokonferenzen, vertrauliche Besprechungen und erholsame Pausen.',
|
||||
'Febrü verbindet dafür klassische Arbeitsplatzmöbel mit wohnlichen Loungeelementen, mobilen Multifunktionslösungen, Stauraum, Akustik und flexibler Raumzonierung. So entsteht nicht für jede Person derselbe Platz, sondern für jede Tätigkeit der richtige.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Das Wir und das Ich',
|
||||
'paragraphs' => [
|
||||
'Ja, ja – das könnte man unter dem üblichen Verkaufs-Blabla abheften. Dahinter steht aber eine einfache Wahrheit: Verantwortung lässt sich nicht dauerhaft nach außen delegieren. Was bleibt, sind das Wir und das Ich.',
|
||||
'Ein Unternehmen muss Bedingungen schaffen, unter denen Menschen gemeinsam gut arbeiten, sich austauschen und konzentrieren können. Der Einzelne erhält die Möglichkeit – und trägt auch die Verantwortung –, daraus das Beste zu machen.',
|
||||
'Räume und Möbel schaffen allein noch keine gute Unternehmenskultur. Schlechte Arbeitsbedingungen können eine gute Kultur aber jeden Tag unnötig erschweren.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Das Unternehmen stärkt das Wir. Der Einzelne macht daraus das Beste.',
|
||||
],
|
||||
[
|
||||
'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.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Akustik und flexible Raumstrukturen',
|
||||
'paragraphs' => [
|
||||
'Hallende Räume, Telefongespräche und Videokonferenzen können Konzentration und Sprachverständlichkeit erheblich beeinträchtigen. Akustikpaneele, Trennwände, Screens und entsprechend geplante Möbel helfen, Schall zu reduzieren.',
|
||||
'Mit modularen Möbeln und Raum-in-Raum-Lösungen lassen sich Arbeitsbereiche neu ordnen, ohne bei jeder Veränderung gleich feste Wände bauen zu müssen. Das ist im großen Büro wichtig – und im privaten Zuhause häufig noch wichtiger.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Gestaltung, die zur Identität passt',
|
||||
'paragraphs' => [
|
||||
'Ein Büro ist ein sichtbarer Teil der Unternehmenskultur. Farben, Materialien und Raumwirkung beeinflussen, wie Mitarbeitende, Kunden und Bewerber ein Unternehmen wahrnehmen. Im Homeoffice muss der Arbeitsplatz professionell funktionieren und sich zugleich in die vorhandene Einrichtung einfügen.',
|
||||
'Dank der hohen Fertigungstiefe bietet Febrü eine große Auswahl an Materialien, Dekoren, Stoffen und Gestellfarben. Corporate Design muss nicht am Briefpapier enden. Und ein Homeoffice muss nach Feierabend nicht wie ein kleines Finanzamt aussehen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Und ja: Pflanzen gehören dazu',
|
||||
'paragraphs' => [
|
||||
'Eine Pflanze macht aus einem Unternehmen noch kein nachhaltiges Unternehmen. Sie macht aus einem technisch eingerichteten Raum aber häufig erst einen Ort, an dem Menschen gerne arbeiten. Das hat nichts mit Greenwashing zu tun, sondern schlicht mit Atmosphäre und Stil.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Nachhaltigkeit, die länger als bis zum nächsten Umzug hält',
|
||||
'paragraphs' => [
|
||||
'Nachhaltigkeit beginnt bei der Lebensdauer eines Möbelstücks. Viele Febrü-Möbel sind modular aufgebaut, sodass Komponenten ausgetauscht oder ergänzt werden können, statt bei veränderten Anforderungen das gesamte Möbelstück zu ersetzen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Konkrete Standards statt allgemeiner Versprechen',
|
||||
'paragraphs' => [
|
||||
'Febrü produziert mit hoher Fertigungstiefe direkt in Herford. Das sorgt für kurze Wege und kontrollierte Prozesse. Mit laserTEC entstehen hochwertige, fugenlose Kanten ohne zusätzlichen Klebstoff.',
|
||||
// ⚠️ Blauer Engel gilt laut Zulieferung nur für ausgewählte Tischsysteme und
|
||||
// Stauraummöbel. Produktbezug beim Redigieren zwingend beibehalten.
|
||||
'Febrü arbeitet nach ISO 9001 und ISO 14001 und verfügt über einen PEFC-Produktkettennachweis. Ausgewählte Febrü-Tischsysteme und Stauraummöbel tragen den Blauen Engel.',
|
||||
'Nachhaltig ist nicht das Möbelstück mit dem längsten Werbetext. Nachhaltig ist das Möbelstück, das viele Jahre funktioniert und veränderte Anforderungen mitmacht.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Warum Febrü bei uns?',
|
||||
'paragraphs' => [
|
||||
'Bei uns beginnt eine Planung nicht mit einem Möbelkatalog, sondern mit den Menschen und der Frage, wie tatsächlich gearbeitet wird. Wo ist Konzentration gefragt? Wo entstehen Gespräche? Welche Bereiche benötigen Diskretion? Und wie viel Stauraum wird wirklich gebraucht?',
|
||||
'Wir verbinden Ergonomie, Laufwege, Akustik, Stauraum, Beleuchtung, Kommunikation und Rückzug zu einem Gesamtkonzept. Es geht nicht darum, möglichst viele Möbel in einen Raum zu stellen. Es geht darum, eine bessere Arbeitswelt zu schaffen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Im Homeoffice stellen sich andere Fragen',
|
||||
'paragraphs' => [
|
||||
'Wie sichtbar darf der Arbeitsplatz sein? Wo verschwinden Technik und Unterlagen nach Feierabend? Und wie lässt sich professionelles Arbeiten so in die vorhandene Einrichtung integrieren, dass das Zuhause ein Zuhause bleibt?',
|
||||
'Auch hier gilt: Ein einzelner Arbeitsplatz ist keine kleinere Aufgabe. Er braucht nur eine andere Antwort als ein vollständiger Bürotrakt.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Erleben statt nur aus dem Katalog auswählen',
|
||||
'paragraphs' => [
|
||||
'Durch die Nähe zwischen Bielefeld und Herford können Febrü-Produkte nach Abstimmung persönlich erlebt werden. Wir organisieren und begleiten den Besuch der Ausstellung – für einen Schreibtisch genauso wie für einen vollständigen Unternehmensstandort.',
|
||||
'Dort lassen sich Materialien anfassen, Stühle und Tische ausprobieren, Akustiklösungen erleben und unterschiedliche Arbeitswelten vergleichen. Sie müssen sich nicht allein auf ein Verkaufsgespräch verlassen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -8,19 +8,6 @@ use Illuminate\Support\Facades\Lang;
|
|||
|
||||
class CmsArticleSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Numerische IDs aus resources/lang/{locale}/b2in.php → Slugs für cms_articles.slug
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ARTICLE_ID_TO_SLUG = [
|
||||
1 => 'escrow-system-dubai-investoren',
|
||||
2 => 'spotlight-al-jaddaf-hotspot',
|
||||
3 => 'turnkey-investments-moeblierung-rendite',
|
||||
4 => 'supply-chain-management-vertragssicherheit',
|
||||
5 => 'local-for-local-regionaler-moebelhandel',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach ($this->getArticles() as $i => $data) {
|
||||
|
|
@ -49,19 +36,28 @@ class CmsArticleSeeder extends Seeder
|
|||
$articlesEn = [];
|
||||
}
|
||||
|
||||
ksort($articlesDe);
|
||||
|
||||
// Kein ksort: die Reihenfolge in der Lang-Datei bestimmt die Reihenfolge im Magazin –
|
||||
// im CMS ueber `order`, im Lang-Fallback ueber die Array-Reihenfolge. Beide Wege stimmen
|
||||
// damit ueberein.
|
||||
$out = [];
|
||||
|
||||
foreach ($articlesDe as $id => $rowDe) {
|
||||
$id = (int) $id;
|
||||
$slug = self::ARTICLE_ID_TO_SLUG[$id] ?? null;
|
||||
if (! is_array($rowDe)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = CmsArticle::slugForLangArticle($id, $rowDe);
|
||||
|
||||
if ($slug === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowEn = $articlesEn[$id] ?? $rowDe;
|
||||
|
||||
if (! is_array($rowEn)) {
|
||||
$rowEn = $rowDe;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'slug' => $slug,
|
||||
'title' => [
|
||||
|
|
@ -74,6 +70,7 @@ class CmsArticleSeeder extends Seeder
|
|||
],
|
||||
'image' => (string) ($rowDe['image'] ?? ''),
|
||||
'category' => (string) ($rowDe['category'] ?? ''),
|
||||
'world' => (string) ($rowDe['world'] ?? CmsArticle::WORLD_INTERIOR),
|
||||
'date_label' => (string) ($rowDe['date'] ?? ''),
|
||||
'read_time' => (string) ($rowDe['readTime'] ?? ''),
|
||||
'author' => is_array($rowDe['author'] ?? null) ? $rowDe['author'] : [],
|
||||
|
|
|
|||
172
database/seeders/CmsBrandSeeder.php
Normal file
172
database/seeders/CmsBrandSeeder.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Legt die Kernmarken an. Texte aus dev/2026-08-28/umsetzung-inhalte.md §5.
|
||||
*
|
||||
* Die vierte Marke ist Otterpohl aus Rheda-Wiedenbrück (Arbeitsplan 14.09.2026, Punkt 1.2;
|
||||
* Zulieferung 17.09.2026). Ihre Texte liegen in {@see OtterpohlBrandSeeder}, der sie bei
|
||||
* bestehenden Installationen auch nachträgt. Der Slug „tischlerei“ bleibt stabil.
|
||||
*
|
||||
* Bestehende Marken werden nicht überschrieben – Marcel pflegt sie im Admin. Für bereits
|
||||
* angelegte Marken setzt {@see BrandLogoSeeder} die Logos nach und {@see BrandOriginSeeder}
|
||||
* den Fertigungsort für die Herkunfts-Sektion (Nachtrag 11).
|
||||
*
|
||||
* Stimmungsbilder und Galerien fehlen als Zulieferung und bleiben leer; die Kacheln und
|
||||
* Detailseiten zeigen so lange einen Platzhalter.
|
||||
*/
|
||||
class CmsBrandSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$created = 0;
|
||||
|
||||
foreach ($this->brands() as $index => $data) {
|
||||
if (CmsBrand::query()->where('slug', $data['slug'])->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CmsBrand::query()->create(array_merge($data, ['order' => $index + 1]));
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->command?->info("Kernmarken: {$created} angelegt.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function brands(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'slug' => 'cabinet',
|
||||
'logo' => 'marken/cabinet.svg',
|
||||
'name' => ['de' => 'CABINET', 'en' => 'CABINET'],
|
||||
'tagline' => ['de' => 'Einbauschränke nach Maß.', 'en' => 'Fitted wardrobes made to measure.'],
|
||||
'teaser' => [
|
||||
'de' => 'Maßgefertigte Schranklösungen, geplant bis auf den Millimeter – gefertigt für Räume, in denen Standard nicht genügt.',
|
||||
'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.
|
||||
|
||||
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.
|
||||
|
||||
Experience the possibilities in our Bielefeld showroom.',
|
||||
],
|
||||
'highlights' => [
|
||||
'Millimetergenaue Planung',
|
||||
'Individuelle Fronten und Innenausstattung',
|
||||
'Persönliche Beratung vor Ort',
|
||||
],
|
||||
'description' => [
|
||||
'de' => '<p>CABINET steht für Einbaulösungen, die sich dem Raum anpassen statt umgekehrt. Jedes Element wird für seinen Platz geplant – vom Dachschrägenschrank bis zur raumhohen Garderobe. Gefertigt in Deutschland, montiert von Fachleuten, die den Unterschied zwischen „passt" und „sitzt" kennen.</p>',
|
||||
'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' => '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.'],
|
||||
],
|
||||
'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,
|
||||
],
|
||||
[
|
||||
'slug' => 'rotpunkt',
|
||||
'logo' => 'marken/rotpunkt.svg',
|
||||
'name' => ['de' => 'Rotpunkt Küchen', 'en' => 'Rotpunkt Küchen'],
|
||||
'tagline' => ['de' => 'Küchen, die Haltung zeigen.', 'en' => 'Kitchens with character.'],
|
||||
'teaser' => [
|
||||
'de' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant, nachhaltig gedacht und gebaut für den Mittelpunkt Ihres Zuhauses.',
|
||||
'en' => 'Premium kitchens made in Germany – individually planned, sustainably conceived and built for the heart of your home.',
|
||||
],
|
||||
'summary' => [
|
||||
'de' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant und gebaut für den Raum, in dem am meisten passiert. Rotpunkt steht für durchdachte Funktion, langlebige Materialien und einen Anspruch an Nachhaltigkeit, der über das Marketing hinausgeht.
|
||||
|
||||
Beratung und Planung in unserem Küchenstudio.',
|
||||
'en' => 'Premium kitchens made in Germany – individually planned and built for the room where most of life happens. Rotpunkt stands for considered function, durable materials and a commitment to sustainability that goes beyond marketing.
|
||||
|
||||
Consultation and planning in our kitchen studio.',
|
||||
],
|
||||
'highlights' => [
|
||||
'Fertigung in Deutschland',
|
||||
'Individuelle Planung nach Ihrem Grundriss',
|
||||
'Nachhaltige Materialien',
|
||||
],
|
||||
'description' => [
|
||||
'de' => '<p>Die Küche ist der Raum, in dem sich das Leben sammelt. Rotpunkt fertigt sie in Deutschland, mit Blick auf Materialien, die halten, und auf eine Planung, die zum Alltag passt – nicht zum Prospekt. Wir begleiten von der ersten Skizze bis zur fertigen Küche.</p>',
|
||||
'en' => '<p>The kitchen is where life gathers. Rotpunkt builds them in Germany, with an eye on materials that last and on planning that fits everyday life – not a brochure. We accompany you from the first sketch to the finished kitchen.</p>',
|
||||
],
|
||||
'services' => [
|
||||
['title' => 'Küchenplanung', 'description' => 'Gemeinsam entwickeln wir Grundriss, Stauraum und Arbeitswege.'],
|
||||
['title' => 'Nachhaltige Materialien', 'description' => 'Deutsche Fertigung mit Blick auf Herkunft und Langlebigkeit.'],
|
||||
['title' => 'Geräte und Technik', 'description' => 'Passende Ausstattung, abgestimmt auf Ihre Art zu kochen.'],
|
||||
['title' => 'Lieferung und Aufbau', 'description' => 'Koordiniert aus einer Hand, inklusive Anschluss.'],
|
||||
],
|
||||
'showroom' => 'Küchenstudio',
|
||||
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
|
||||
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
|
||||
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
|
||||
],
|
||||
'is_published' => true,
|
||||
],
|
||||
[
|
||||
'slug' => 'februe',
|
||||
'logo' => 'marken/februe.png',
|
||||
'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.',
|
||||
'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.',
|
||||
'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' => [
|
||||
'Ergonomie, die man merkt',
|
||||
'Vom einzelnen Arbeitsplatz bis zur Bürofläche',
|
||||
'Für Zuhause und fürs Unternehmen',
|
||||
],
|
||||
'description' => [
|
||||
'de' => '<p>Gutes Arbeiten beginnt beim Möbel. Febrü baut Tische, Stühle und Stauraum, die den Rücken entlasten und den Raum ordnen – zuerst für das Zuhause, in dem immer öfter gearbeitet wird, und ebenso für Büros und Unternehmen.</p>',
|
||||
'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' => '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.'],
|
||||
],
|
||||
'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,
|
||||
],
|
||||
// Vierte Kernmarke Otterpohl (Nachtrag 15). Slug bleibt „tischlerei“, Texte aus OtterpohlBrandSeeder.
|
||||
array_merge(OtterpohlBrandSeeder::data(), [
|
||||
'slug' => OtterpohlBrandSeeder::SLUG,
|
||||
'logo' => OtterpohlBrandSeeder::LOGO,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,34 @@
|
|||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
class CmsContentSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Überschreibt bestehende CMS-Einträge mit den Werten aus den Lang-Dateien.
|
||||
*
|
||||
* Standardmäßig aus, damit ein erneuter Seeder-Lauf die im Admin gepflegten Inhalte
|
||||
* nicht zurücksetzt. Nur einschalten, wenn die Lang-Dateien bewusst als Quelle der
|
||||
* Wahrheit gelten sollen – vorher ein DB-Backup ziehen:
|
||||
*
|
||||
* $seeder = new CmsContentSeeder;
|
||||
* $seeder->overwriteExisting = true;
|
||||
* $seeder->run();
|
||||
*/
|
||||
public bool $overwriteExisting = false;
|
||||
|
||||
/**
|
||||
* Nur diese Section-Keys verarbeiten (leer = alle).
|
||||
*
|
||||
* Erlaubt einen gezielten Lauf für einzelne Sektionen, ohne den Rest anzufassen.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $onlySections = [];
|
||||
|
||||
/**
|
||||
* Sections to skip (not migrated, handled elsewhere or irrelevant).
|
||||
*
|
||||
|
|
@ -52,9 +75,14 @@ class CmsContentSeeder extends Seeder
|
|||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($sectionMap as $sectionKey => $groupKeyPair) {
|
||||
if (in_array($sectionKey, $this->skipSections, true)) {
|
||||
if ($this->onlySections !== [] && ! in_array($sectionKey, $this->onlySections, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->onlySections === [] && in_array($sectionKey, $this->skipSections, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +111,12 @@ class CmsContentSeeder extends Seeder
|
|||
];
|
||||
|
||||
if ($content) {
|
||||
if (! $this->overwriteExisting) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$content->update([
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
|
|
@ -100,7 +134,15 @@ class CmsContentSeeder extends Seeder
|
|||
}
|
||||
}
|
||||
|
||||
$this->command?->info("CmsContent: {$created} created, {$updated} updated.");
|
||||
$this->command?->info("CmsContent: {$created} created, {$updated} updated, {$skipped} kept (existing CMS content).");
|
||||
|
||||
if ($skipped > 0 && ! $this->overwriteExisting) {
|
||||
$this->command?->comment('Bestehende CMS-Inhalte wurden nicht angetastet. Zum Überschreiben $overwriteExisting = true setzen.');
|
||||
}
|
||||
|
||||
if ($created > 0 || $updated > 0) {
|
||||
app(CmsContentService::class)->clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
private function detectType(mixed $data): string
|
||||
|
|
|
|||
|
|
@ -14,14 +14,21 @@ class DatabaseSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Kevin Adametz',
|
||||
'email' => 'kevin.adametz@me.com',
|
||||
'password' => Hash::make('xunfew-0Jygjy-minnyt'),
|
||||
'name' => config('seeding.admin_name'),
|
||||
'email' => config('seeding.admin_email'),
|
||||
'password' => Hash::make(config('seeding.admin_password')),
|
||||
]);
|
||||
|
||||
$this->call(TestDisplaySeeder::class);
|
||||
// CMS-Inhalte: legen nur an, was fehlt – bestehende Redaktion bleibt unangetastet.
|
||||
$this->call([
|
||||
CmsContentSeeder::class,
|
||||
CmsLegalSeeder::class,
|
||||
NetzwerkPageCmsSeeder::class,
|
||||
CmsArticleSeeder::class,
|
||||
CmsBrandSeeder::class,
|
||||
CmsProjectSeeder::class,
|
||||
TestDisplaySeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
database/seeders/DemoBrandGalleryCleanupSeeder.php
Normal file
52
database/seeders/DemoBrandGalleryCleanupSeeder.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Entfernt die Demobilder aus den Markengalerien (Nachtrag 17).
|
||||
*
|
||||
* {@see DemoBrandGallerySeeder} hatte Stockmotive und KI-Renderings der alten Dubai-Seite als
|
||||
* Beispiele in die Galerien von CABINET, Rotpunkt und Febrü gelegt. Seit den echten Bildern
|
||||
* (Nachtrag 16) sollen sie weg. Erkannt werden sie am Dateinamen aus dem Demo-Seeder – nicht an
|
||||
* der Bildunterschrift, denn den Vermerk „BEISPIEL“ hat {@see BrandGalleryCaptionNormalizerSeeder}
|
||||
* auf der Vorschau bereits entfernt. Alle anderen Galeriebilder bleiben samt Kategorie,
|
||||
* Unterschrift und Reihenfolge. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=DemoBrandGalleryCleanupSeeder
|
||||
*/
|
||||
class DemoBrandGalleryCleanupSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$demoImages = [];
|
||||
|
||||
foreach (DemoBrandGallerySeeder::GALLERIES as $items) {
|
||||
foreach ($items as $item) {
|
||||
$demoImages[] = $item[0];
|
||||
}
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
|
||||
foreach (CmsBrand::query()->whereNotNull('gallery')->get() as $brand) {
|
||||
$gallery = CmsBrand::normalizeGallery($brand->gallery);
|
||||
$kept = array_values(array_filter(
|
||||
$gallery,
|
||||
fn (array $item) => ! in_array($item['image'], $demoImages, true),
|
||||
));
|
||||
|
||||
if (count($kept) === count($gallery)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$removed += count($gallery) - count($kept);
|
||||
$brand->gallery = $kept;
|
||||
$brand->save();
|
||||
}
|
||||
|
||||
$this->command?->info("Demobilder aus den Galerien entfernt: {$removed}.");
|
||||
}
|
||||
}
|
||||
70
database/seeders/DemoBrandGallerySeeder.php
Normal file
70
database/seeders/DemoBrandGallerySeeder.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Demo-Galerien zum Ausprobieren der Marken-Detailseiten.
|
||||
*
|
||||
* NUR fuer lokale Tests. Bewusst NICHT in {@see DatabaseSeeder} registriert.
|
||||
*
|
||||
* ⚠️ Die verwendeten Bilder stammen aus dem Bestand der alten Dubai-Seite – es sind Stockmotive
|
||||
* und KI-Renderings, kein einziges davon ist ein echtes CABINET-, Rotpunkt- oder Februe-Projekt.
|
||||
* Der frueher vorangestellte Vermerk „BEISPIEL“ in den Bildunterschriften ist auf Kundenwunsch
|
||||
* entfallen. Die Unterscheidung traegt jetzt allein die Kategorie (Badge „Realisiertes Projekt“
|
||||
* bzw. „Visualisierung“) – die Motive der Kategorie `project` muessen deshalb vor dem Livegang
|
||||
* durch echte Referenzen ersetzt werden. Steht als Punkt in der Deployment-Checkliste.
|
||||
*
|
||||
* Wieder entfernen – nur die Demobilder, gepflegte Bilder bleiben:
|
||||
* php artisan db:seed --class=DemoBrandGalleryCleanupSeeder
|
||||
*/
|
||||
class DemoBrandGallerySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Galerie je Marken-Slug: [Dateiname, Kategorie, Bildunterschrift].
|
||||
*
|
||||
* @var array<string, list<array{0: string, 1: string, 2: string}>>
|
||||
*/
|
||||
public const GALLERIES = [
|
||||
'cabinet' => [
|
||||
['b2a.webp', CmsBrand::GALLERY_PROJECT, 'Wohnwand mit Einbauregal, Bielefeld'],
|
||||
['hero-room.webp', CmsBrand::GALLERY_PROJECT, 'Wohnbereich mit Raumteiler'],
|
||||
['stileigentum.webp', CmsBrand::GALLERY_INSPIRATION, 'Entwurf Wohnzimmer mit Sideboard'],
|
||||
['best-of-two-worlds.jpg', CmsBrand::GALLERY_INSPIRATION, 'Visualisierung offener Wohnraum'],
|
||||
],
|
||||
'rotpunkt' => [
|
||||
['best-of-two-world.jpg', CmsBrand::GALLERY_PROJECT, 'Offener Wohn- und Kochbereich'],
|
||||
['style2own.webp', CmsBrand::GALLERY_INSPIRATION, 'Entwurf heller Wohnbereich'],
|
||||
['magazin-3.webp', CmsBrand::GALLERY_INSPIRATION, 'Visualisierung Wohnraum mit Ausblick'],
|
||||
],
|
||||
'februe' => [
|
||||
['accommodation-2.webp', CmsBrand::GALLERY_PROJECT, 'Empfangsbereich eines Buerogebaeudes'],
|
||||
['accommodation-1.webp', CmsBrand::GALLERY_PROJECT, 'Grossraumbuero mit Arbeitsplaetzen'],
|
||||
['partner-hero.webp', CmsBrand::GALLERY_INSPIRATION, 'Entwurf Besprechungsbereich'],
|
||||
['ecosystem-hero.webp', CmsBrand::GALLERY_INSPIRATION, 'Visualisierung Projektraum'],
|
||||
],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach (self::GALLERIES as $slug => $items) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – uebersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand->gallery = CmsBrand::normalizeGallery(array_map(
|
||||
fn (array $item) => ['image' => $item[0], 'category' => $item[1], 'caption' => $item[2]],
|
||||
$items,
|
||||
));
|
||||
$brand->save();
|
||||
|
||||
$this->command?->info("Galerie fuer „{$brand->slug}“ gesetzt: ".count($brand->gallery).' Bilder.');
|
||||
}
|
||||
}
|
||||
}
|
||||
50
database/seeders/DisplayInteriorBrandingSeeder.php
Normal file
50
database/seeders/DisplayInteriorBrandingSeeder.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use App\Models\DisplayVersion;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Vereinheitlicht das Branding aller B2in-Display-Module auf das Interior-Logo.
|
||||
*
|
||||
* Ausgangslage waren vier verschiedene Logo-Quellen nebeneinander: zwei Dateien aus der
|
||||
* Medienbibliothek, ein relativer und ein absoluter Pfad auf die Cabinet-Assets. Drei Module
|
||||
* zeigten dabei auf `b2in.test` – eine lokale Entwicklungsdomain, die auf dem Live-Display
|
||||
* nicht auflösbar ist.
|
||||
*
|
||||
* Gesetzt wird das Logo passend zum Theme des Moduls (dunkel = negativ/weiss, hell = positiv)
|
||||
* sowie der neue Claim. Alle anderen Einstellungen bleiben unangetastet.
|
||||
*
|
||||
* php artisan db:seed --class=DisplayInteriorBrandingSeeder
|
||||
*/
|
||||
class DisplayInteriorBrandingSeeder extends Seeder
|
||||
{
|
||||
private const LOGO_DARK = '/_cabinet/assets/b2in-interior-logo-negative.svg';
|
||||
|
||||
private const LOGO_LIGHT = '/_cabinet/assets/b2in-interior-logo-positive.svg';
|
||||
|
||||
private const CLAIM = 'Premium-Einrichtung. Persönlich gemacht.';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$updated = 0;
|
||||
|
||||
foreach (DisplayVersion::query()->where('type', DisplayVersionType::B2in)->get() as $module) {
|
||||
$settings = $module->settings ?? [];
|
||||
|
||||
$settings['header_logo_url'] = ($settings['theme'] ?? 'dark') === 'light'
|
||||
? self::LOGO_LIGHT
|
||||
: self::LOGO_DARK;
|
||||
|
||||
$settings['header_claim'] = self::CLAIM;
|
||||
|
||||
$module->settings = $settings;
|
||||
$module->save();
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$this->command?->info("Display-Branding vereinheitlicht: {$updated} B2in-Module.");
|
||||
}
|
||||
}
|
||||
77
database/seeders/DisplayMediaUrlNormalizerSeeder.php
Normal file
77
database/seeders/DisplayMediaUrlNormalizerSeeder.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Macht die im Display-CMS gespeicherten Medien-URLs host-unabhängig.
|
||||
*
|
||||
* Problem: Bilder, Videos und QR-Codes lagen als absolute URLs mit fest verdrahtetem Host in
|
||||
* den Moduleinstellungen und Item-Inhalten – teils `b2in.test` (lokale Entwicklungsdomain),
|
||||
* teils `b2in.eu`. Der Player gibt absolute URLs unverändert zurück, deshalb lädt ein auf
|
||||
* `b2in.test` verweisendes Medium auf dem Live-Display nicht (und umgekehrt).
|
||||
*
|
||||
* Relative Pfade löst der Player über `resolveDisplayUrl()` gegen die Portal-Basis auf und
|
||||
* funktionieren damit auf beiden Systemen. Umgeschrieben wird deshalb Host + Schema zu einem
|
||||
* reinen Pfad.
|
||||
*
|
||||
* Bewusst **nicht** angefasst: Ziel-Links wie Footer-URL oder QR-Ziel (z. B.
|
||||
* `cabinet-bielefeld.de`) – die müssen absolut bleiben. Der Filter greift nur bei einem
|
||||
* B2in-Host **und** einem Pfad unterhalb von /storage/.
|
||||
*
|
||||
* php artisan db:seed --class=DisplayMediaUrlNormalizerSeeder
|
||||
*/
|
||||
class DisplayMediaUrlNormalizerSeeder extends Seeder
|
||||
{
|
||||
private const ASSET_URL_PATTERN = '#^https?://(?:[a-z0-9-]+\.)*b2in\.(?:test|eu|de)(/storage/[^\s"]*)$#i';
|
||||
|
||||
private int $rewritten = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach (DisplayVersion::all() as $module) {
|
||||
$settings = $this->normalize($module->settings ?? []);
|
||||
|
||||
if ($settings !== ($module->settings ?? [])) {
|
||||
$module->settings = $settings;
|
||||
$module->save();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (DisplayVersionItem::all() as $item) {
|
||||
$content = $this->normalize($item->content ?? []);
|
||||
|
||||
if ($content !== ($item->content ?? [])) {
|
||||
$item->content = $content;
|
||||
$item->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->command?->info("Display-Medien-URLs host-unabhängig gemacht: {$this->rewritten} Verweise.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $value
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function normalize(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $entry) {
|
||||
if (is_array($entry)) {
|
||||
$value[$key] = $this->normalize($entry);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($entry) && preg_match(self::ASSET_URL_PATTERN, $entry, $matches) === 1) {
|
||||
$value[$key] = $matches[1];
|
||||
$this->rewritten++;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
70
database/seeders/InteriorRelaunchCmsSeeder.php
Normal file
70
database/seeders/InteriorRelaunchCmsSeeder.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Einmaliger Relaunch-Schritt: schiebt die in AP1 neu getexteten Sektionen ins CMS.
|
||||
*
|
||||
* Hintergrund: cms_theme_section() bevorzugt das CMS gegenüber der Lang-Datei. Die hier
|
||||
* gelisteten Sektionen liegen bereits mit den alten Texten im CMS und würden die neuen
|
||||
* Inhalte sonst überdecken. Der reguläre CmsContentSeeder fasst bestehende Einträge
|
||||
* bewusst nicht mehr an (Redaktionsschutz) – deshalb dieser gezielte Lauf.
|
||||
*
|
||||
* ⚠️ Überschreibt genau diese Sektionen mit dem Stand aus resources/lang/{de,en}/b2in.php.
|
||||
* Andere CMS-Inhalte bleiben unangetastet. Vorher ein DB-Backup ziehen.
|
||||
*
|
||||
* php artisan db:seed --class=InteriorRelaunchCmsSeeder
|
||||
*/
|
||||
class InteriorRelaunchCmsSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Section-Keys aus config/cms_section_map.php, die AP1 neu getextet hat.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const RELAUNCH_SECTIONS = [
|
||||
// Global
|
||||
'announcement_bar',
|
||||
'header',
|
||||
'footer',
|
||||
'cta_section',
|
||||
|
||||
// Startseite
|
||||
'hero',
|
||||
'founder_bar',
|
||||
'audience_switch',
|
||||
'core_brands',
|
||||
'vision_section',
|
||||
'sponsoring',
|
||||
'immobilien_teaser',
|
||||
|
||||
// Unterseite /marken
|
||||
'marken_hero',
|
||||
'marken_network',
|
||||
'marken_growth',
|
||||
'marken_cta',
|
||||
|
||||
// Unterseiten – Durchgang auf Alt-Begriffe
|
||||
'about_hero',
|
||||
'our_story',
|
||||
'faq',
|
||||
'contact_form',
|
||||
'netzwerk_hero',
|
||||
'netzwerk_teasers',
|
||||
'netzwerk_image_break',
|
||||
'netzwerk_cabinet_partner',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$seeder = new CmsContentSeeder;
|
||||
$seeder->setCommand($this->command);
|
||||
$seeder->onlySections = self::RELAUNCH_SECTIONS;
|
||||
$seeder->overwriteExisting = true;
|
||||
$seeder->run();
|
||||
|
||||
$this->command?->info('Interior-Relaunch: '.count(self::RELAUNCH_SECTIONS).' Sektionen aus den Lang-Dateien übernommen.');
|
||||
}
|
||||
}
|
||||
69
database/seeders/OriginSectionCmsSeeder.php
Normal file
69
database/seeders/OriginSectionCmsSeeder.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Herkunfts-Sektion „Aus der Region gefertigt“ ins CMS legen (Arbeitsplan 14.09.2026, Punkt 1.3).
|
||||
*
|
||||
* Legt die Sektion nur an, wenn sie fehlt. Ein bestehender Eintrag bleibt inhaltlich unangetastet,
|
||||
* damit Korrekturen aus dem Admin (Ablauf CABINET) nicht verloren gehen.
|
||||
*
|
||||
* Einzige Ausnahme (Nachtrag 11): Der Schlüssel `locations` stammt aus dem ersten Stand, als die
|
||||
* Standorte noch im Sektions-JSON lagen. Seitdem kommen sie aus den Marken
|
||||
* ({@see BrandOriginSeeder}), das Frontend liest den Schlüssel nicht mehr. Er wird aus einem
|
||||
* bestehenden Eintrag entfernt, damit er im Inhalte-Editor nicht als Pflegefeld auftaucht.
|
||||
* Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=OriginSectionCmsSeeder
|
||||
*/
|
||||
class OriginSectionCmsSeeder extends Seeder
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private const SECTIONS = ['origin_section'];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$seeder = new CmsContentSeeder;
|
||||
$seeder->setCommand($this->command);
|
||||
$seeder->onlySections = self::SECTIONS;
|
||||
$seeder->run();
|
||||
|
||||
$this->dropLegacyLocations();
|
||||
|
||||
$this->command?->info('Herkunfts-Sektion: angelegt, falls sie fehlte.');
|
||||
}
|
||||
|
||||
private function dropLegacyLocations(): void
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'origin_section')->first();
|
||||
|
||||
if (! $row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$changed = false;
|
||||
|
||||
foreach ($value as $locale => $section) {
|
||||
if (is_array($section) && array_key_exists('locations', $section)) {
|
||||
unset($section['locations']);
|
||||
$value[$locale] = $section;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
|
||||
$this->command?->info('Herkunfts-Sektion: veralteten Schlüssel „locations“ entfernt – die Standorte kommen aus den Marken.');
|
||||
}
|
||||
}
|
||||
136
database/seeders/OtterpohlBrandSeeder.php
Normal file
136
database/seeders/OtterpohlBrandSeeder.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Otterpohl Möbelservice, Rheda-Wiedenbrück – die vierte Kernmarke (Nachtrag 15, Text v2 in Nachtrag 19).
|
||||
*
|
||||
* Ersetzt den Platzhalter „Tischlerei“ aus Nachtrag 9 durch die Zulieferung:
|
||||
* dev/2026-08-28/otterpohl_Webseitentext_FINAL_v2.md (Text vom 24.09.2026, Überschriften 1:1 übernommen;
|
||||
* löst die lange Fassung vom 17.09. ab)
|
||||
* dev/2026-08-28/markenlogos/otterpohl.jpg (Logo, freigestellt nach public/img/assets/marken/)
|
||||
*
|
||||
* Der Slug `tischlerei` bleibt stabil (Links, Widget-Thema, Kartenposition der Herkunfts-Sektion).
|
||||
* Überschrieben werden Name, Claim, Texte, Leistungen, Abschnitte, Abschlussblock und
|
||||
* Fertigungsort. Bild, Galerie und Reihenfolge bleiben, das Logo wird nur gesetzt, wenn es leer ist.
|
||||
* {@see CmsBrandSeeder} nutzt dieselben Daten für frische Installationen.
|
||||
*
|
||||
* php artisan db:seed --class=OtterpohlBrandSeeder
|
||||
*/
|
||||
class OtterpohlBrandSeeder extends Seeder
|
||||
{
|
||||
public const SLUG = 'tischlerei';
|
||||
|
||||
public const LOGO = 'marken/otterpohl.png';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$brand = CmsBrand::query()->where('slug', self::SLUG)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn('Marke „'.self::SLUG.'“ nicht gefunden – erst CmsBrandSeeder ausführen.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$brand->fill(self::data());
|
||||
|
||||
if (trim((string) $brand->logo) === '') {
|
||||
$brand->logo = self::LOGO;
|
||||
}
|
||||
|
||||
$brand->save();
|
||||
|
||||
$this->command?->info('Otterpohl: Marke „'.self::SLUG.'“ mit den Texten vom 24.09.2026 (v2) aktualisiert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Textfelder der Marke. Ohne slug, logo, image, gallery, order.
|
||||
*
|
||||
* Reihenfolge wie in der Zulieferung v2: Einleitung „Wollmilchsau“, zwei Abschnitte, die acht
|
||||
* Leistungen, zum Schluss „Das Ganze statt nur das Einzelne“ – der steht als Abschlussblock
|
||||
* (cta_title/cta_text), damit er auf der Seite hinter Leistungen und Galerie bleibt.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function data(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['de' => 'Otterpohl Möbelservice', 'en' => 'Otterpohl Möbelservice'],
|
||||
'tagline' => ['de' => 'Nicht easy click & buy. Aber dennoch easy to get.', 'en' => 'Not easy click & buy. But still easy to get.'],
|
||||
'teaser' => [
|
||||
'de' => 'Individuelle Möbel, Türen, Treppen, Küchen, Wandverkleidungen und Objektbau – bis hin zum kompletten Innenausbau. Tischlerhandwerk aus Rheda-Wiedenbrück.',
|
||||
'en' => 'Bespoke furniture, doors, stairs, kitchens, wall cladding and contract work – up to complete interior fit-outs. Joinery from Rheda-Wiedenbrück.',
|
||||
],
|
||||
'summary' => [
|
||||
'de' => 'Individuelle Möbel. Türen. Treppen. Küchen. Wandverkleidungen. Objektbau. Sonderanfertigungen. Lackierungen und Echtholzfurniere. Bis hin zum kompletten Innenausbau. Mit Otterpohl Möbelservice haben wir einen Partner an unserer Seite, bei dem die Möglichkeiten nicht am einzelnen Möbelstück aufhören.
|
||||
|
||||
Denn manchmal braucht ein Raum mehr als einen Schrank oder ein Sideboard. Vielleicht soll eine Treppe Teil des Gestaltungskonzepts werden. Eine Wand erhält eine individuelle Holzverkleidung. Oder Möbel, Materialien und bauliche Elemente müssen so zusammenspielen, dass am Ende alles wie aus einem Guss wirkt. Genau dort beginnt der individuelle Innenausbau.',
|
||||
'en' => 'Bespoke furniture. Doors. Stairs. Kitchens. Wall cladding. Contract work. Special constructions. Lacquer and real-wood veneer. Up to complete interior fit-outs. With Otterpohl Möbelservice we have a partner whose possibilities do not end at the single piece of furniture.
|
||||
|
||||
Sometimes a room needs more than a wardrobe or a sideboard. A staircase becomes part of the design. A wall gets individual wood cladding. Or furniture, materials and built elements have to work together so that everything looks as if cast from one mould. That is where individual interior fit-out begins.',
|
||||
],
|
||||
'highlights' => [
|
||||
'Individuelle Möbel, Türen, Treppen und Küchen',
|
||||
'Innenausbau, Objektbau, Lack und Echtholzfurnier',
|
||||
'Planung und Handwerk aus einer Hand – inklusive Montage',
|
||||
],
|
||||
'description' => ['de' => '', 'en' => ''],
|
||||
'services' => [
|
||||
['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' => '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.'],
|
||||
['title' => 'Montage', 'description' => 'Die Umsetzung endet nicht bei der Fertigung. Entscheidend ist, dass Möbel, Ausbau und Raum am Ende auch vor Ort sauber zusammenkommen.'],
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Ziemlich nah an der eierlegenden Wollmilchsau',
|
||||
'paragraphs' => [
|
||||
'Der Begriff ist vielleicht nicht besonders elegant. Aber ganz falsch ist er nicht.',
|
||||
'Individuelle Möbel. Türen. Treppen. Küchen. Wandverkleidungen. Objektbau. Sonderanfertigungen. Lackierungen und Echtholzfurniere. Bis hin zum kompletten Innenausbau.',
|
||||
'Mit Otterpohl Möbelservice haben wir einen Partner an unserer Seite, bei dem die Möglichkeiten nicht am einzelnen Möbelstück aufhören.',
|
||||
'Denn manchmal braucht ein Raum mehr als einen Schrank oder ein Sideboard. Vielleicht soll eine Treppe Teil des Gestaltungskonzepts werden. Eine Wand erhält eine individuelle Holzverkleidung. Eine Tür soll sich selbstverständlich in den Raum einfügen. Oder Möbel, Materialien und bauliche Elemente müssen so zusammenspielen, dass am Ende alles wie aus einem Guss wirkt.',
|
||||
],
|
||||
'claim' => 'Genau dort beginnt der individuelle Innenausbau.',
|
||||
],
|
||||
[
|
||||
'title' => 'Vom einzelnen Möbel bis zum ganzen Raum',
|
||||
'paragraphs' => [
|
||||
'Wir arbeiten mit Herstellern, die schon sehr viele individuelle Möglichkeiten bieten. CABINET, Rotpunkt und Febrü geben uns dafür eine starke Basis. Aber es gibt Projekte, bei denen die Lösung darüber hinausgeht.',
|
||||
'Mit Otterpohl Möbelservice können wir deshalb nicht nur individuelle Möbel und Sonderanfertigungen realisieren, sondern auch Türen, Treppen, Küchen, Wand- und Deckenverkleidungen sowie komplette Innenausbauten und Lösungen für den Objektbereich einbeziehen.',
|
||||
],
|
||||
'claim' => 'So können wir Räume ganzheitlicher denken – und Details nicht isoliert betrachten.',
|
||||
],
|
||||
[
|
||||
'title' => 'Planung trifft Handwerk',
|
||||
'paragraphs' => [
|
||||
'Manchmal entsteht die beste Lösung aus einem Möbelprogramm. Manchmal braucht es eine individuelle Ergänzung. Und manchmal muss etwas komplett neu gedacht und gebaut werden. Genau diese Kombination macht für uns den Unterschied.',
|
||||
'Wir planen den Raum und verbinden bei Bedarf unterschiedliche Hersteller, Materialien und handwerkliche Lösungen miteinander. Otterpohl Möbelservice ergänzt diese Planung um die Möglichkeiten einer klassischen Tischlerei und eines erfahrenen Innenausbauers.',
|
||||
],
|
||||
'claim' => 'Das Ergebnis kann ein einzelnes Möbel sein. Oder ein kompletter Raum.',
|
||||
],
|
||||
],
|
||||
'widget_label' => ['de' => 'Persönliche Beratung zur Maßarbeit vereinbaren', 'en' => 'Arrange a personal consultation on bespoke work'],
|
||||
'cta_title' => ['de' => 'Das Ganze statt nur das Einzelne', 'en' => 'The whole, not just the single piece'],
|
||||
'cta_text' => [
|
||||
'de' => 'Genau deshalb passt Otterpohl Möbelservice zu unserem Ansatz: Wir möchten Räume nicht in einzelne Gewerke zerlegen, sondern Lösungen schaffen, bei denen Möbel, Materialien, Architektur und handwerkliche Details zusammenpassen. Mal braucht es dafür einen Hersteller. Mal einen Tischler. Und manchmal beides. Nicht easy click & buy. Aber dennoch easy to get.',
|
||||
'en' => 'That is why Otterpohl Möbelservice fits our approach: we do not want to split rooms into single trades, but create solutions where furniture, materials, architecture and craft details belong together. Sometimes that takes a manufacturer. Sometimes a joiner. And sometimes both. Not easy click & buy. But still easy to get.',
|
||||
],
|
||||
'origin_city' => ['de' => 'Rheda-Wiedenbrück', 'en' => 'Rheda-Wiedenbrück'],
|
||||
'origin_distance' => ['de' => 'rund 35 km', 'en' => 'about 35 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Tischlerei und Innenausbau aus Rheda-Wiedenbrück – vom einzelnen Möbel bis zum ganzen Raum, inklusive Montage.',
|
||||
'en' => 'Joinery and interior fit-out from Rheda-Wiedenbrück – from a single piece to the whole room, installation included.',
|
||||
],
|
||||
'showroom' => '',
|
||||
'is_published' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
83
database/seeders/WhatsappNumberSeeder.php
Normal file
83
database/seeders/WhatsappNumberSeeder.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Hinterlegt die WhatsApp-Business-Nummer des Stores (Abstimmung 23.09.2026, Nachtrag 18).
|
||||
*
|
||||
* Der WhatsApp-Button im Terminanfrage-Widget liest `global.contact.whatsapp`. Auf der Vorschau
|
||||
* stand dort der Platzhalter „+49 151 00000000“ aus der ersten Einrichtung, danach kurz die
|
||||
* falsche Nummer vom 23.09. Der Seeder trägt die echte Nummer ein, wenn das Feld leer ist oder
|
||||
* einen dieser beiden Werte enthält; eine im Admin gepflegte andere Nummer bleibt stehen. Fehlt der Eintrag `global.contact` ganz, wird er
|
||||
* angelegt. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=WhatsappNumberSeeder
|
||||
*/
|
||||
class WhatsappNumberSeeder extends Seeder
|
||||
{
|
||||
/** Business-Nummer laut Marcel (0521-98620100, korrigiert am 24.09.), international geschrieben. */
|
||||
public const NUMBER = '+49 521 98620100';
|
||||
|
||||
/**
|
||||
* Werte, die der Seeder ersetzt: der Platzhalter aus der ersten Einrichtung und die am 23.09.
|
||||
* versehentlich eingetragene falsche Nummer (Zahlendreher in der Zulieferung).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const PLACEHOLDERS = ['+49 151 00000000', '+49 521 98610020'];
|
||||
|
||||
/** @var array<int, string> */
|
||||
private const LOCALES = ['de', 'en'];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'global')->where('key', 'contact')->first();
|
||||
|
||||
if (! $row) {
|
||||
CmsContent::create([
|
||||
'group' => 'global',
|
||||
'key' => 'contact',
|
||||
'type' => 'json',
|
||||
'value' => array_fill_keys(self::LOCALES, ['whatsapp' => self::NUMBER]),
|
||||
'order' => 0,
|
||||
]);
|
||||
app(CmsContentService::class)->clearCache('global');
|
||||
|
||||
$this->command?->info('WhatsApp-Nummer: Eintrag global.contact angelegt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$changed = 0;
|
||||
|
||||
foreach (self::LOCALES as $locale) {
|
||||
$section = is_array($value[$locale] ?? null) ? $value[$locale] : [];
|
||||
$current = trim((string) ($section['whatsapp'] ?? ''));
|
||||
|
||||
if ($current !== '' && ! in_array($current, self::PLACEHOLDERS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$section['whatsapp'] = self::NUMBER;
|
||||
$value[$locale] = $section;
|
||||
$changed++;
|
||||
}
|
||||
|
||||
if ($changed === 0) {
|
||||
$this->command?->info('WhatsApp-Nummer: bereits gepflegt, nichts geändert.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('global');
|
||||
|
||||
$this->command?->info("WhatsApp-Nummer gesetzt ({$changed} Sprache(n)).");
|
||||
}
|
||||
}
|
||||
63
database/seeders/WorldSplitCmsSeeder.php
Normal file
63
database/seeders/WorldSplitCmsSeeder.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Trennung der Welten Interior und Immobilien im CMS (Arbeitsplan 14.09.2026, Folge von 1.1).
|
||||
*
|
||||
* Zwei Schritte:
|
||||
*
|
||||
* 1. **Neue Sektionen anlegen**, falls sie fehlen: Navigation, Ankündigungsleiste, Magazinliste,
|
||||
* Über-uns-Seite und FAQ der Immobilien-Welt. Bestehende Einträge bleiben unangetastet.
|
||||
* 2. **Sektionen überschreiben**: die Hauptnavigation (ohne „Immobilien“ – der Welten-Wechsler
|
||||
* übernimmt), die Einleitung der Magazinliste (ohne Dubai-Bezug) und das Kontaktformular
|
||||
* beider Welten (Betreffe in Gruppen).
|
||||
*
|
||||
* ⚠️ Schritt 2 setzt genau diese beiden Sektionen auf den Stand der Lang-Dateien zurück.
|
||||
* Vorher ein DB-Backup ziehen, falls die Navigation im Admin geändert wurde.
|
||||
*
|
||||
* php artisan db:seed --class=WorldSplitCmsSeeder
|
||||
*/
|
||||
class WorldSplitCmsSeeder extends Seeder
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private const NEW_SECTIONS = [
|
||||
'header_immobilien',
|
||||
'announcement_bar_immobilien',
|
||||
'magazin_list_immobilien',
|
||||
'faq_immobilien',
|
||||
'contact_form_immobilien',
|
||||
'cta_section_immobilien',
|
||||
'immobilien_about_hero',
|
||||
'immobilien_our_story',
|
||||
'immobilien_about_image_break',
|
||||
'immobilien_our_values',
|
||||
];
|
||||
|
||||
/** @var array<int, string> */
|
||||
private const OVERWRITE_SECTIONS = [
|
||||
'header',
|
||||
'magazin_list',
|
||||
// Betreffe in Gruppen (Interior / Immobilien / B2in) statt flacher Liste, 15.09.
|
||||
'contact_form',
|
||||
'contact_form_immobilien',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$create = new CmsContentSeeder;
|
||||
$create->setCommand($this->command);
|
||||
$create->onlySections = self::NEW_SECTIONS;
|
||||
$create->run();
|
||||
|
||||
$overwrite = new CmsContentSeeder;
|
||||
$overwrite->setCommand($this->command);
|
||||
$overwrite->onlySections = self::OVERWRITE_SECTIONS;
|
||||
$overwrite->overwriteExisting = true;
|
||||
$overwrite->run();
|
||||
|
||||
$this->command?->info('Welten-Trennung: '.count(self::NEW_SECTIONS).' Sektionen angelegt (falls fehlend), '.count(self::OVERWRITE_SECTIONS).' Interior-Sektionen aktualisiert.');
|
||||
}
|
||||
}
|
||||
102
dev/2026-08-28/B2in_Interior-Konzept.md
Normal file
102
dev/2026-08-28/B2in_Interior-Konzept.md
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# B2in Interieur
|
||||
## Konzept zur Neuausrichtung der Website – Richtung, Struktur und Textauszüge
|
||||
|
||||
*Adametz.Media | Kevin Adametz | Juli 2026 | Zur Abstimmung*
|
||||
|
||||
---
|
||||
|
||||
Hallo Marcel, dieses Dokument fasst zusammen, wie ich die Website auf die neue Ausrichtung umbaue – Struktur, Gewichtung und ausgewählte Textauszüge, damit Du die Richtung beurteilen kannst. Es sind bewusst nicht alle Texte enthalten, sondern die Stellen, die den Ton setzen. Nimm Dir in Ruhe die Zeit, es durchzugehen; Dein gesammeltes Feedback baue ich nach meinem Urlaub direkt ein.
|
||||
|
||||
---
|
||||
|
||||
## 1. Die neue Ausrichtung in drei Sätzen
|
||||
|
||||
B2in stellt künftig das Interieur und die Kernmarken nach vorne – CABINET, Rotpunkt Küchen, Febrü und weitere. Die Immobilien bleiben Teil des Angebots, rücken aber bewusst nach hinten: Ergänzung statt Aufmacher. Deine Idee der Doppelbedeutung nehme ich dabei auf:
|
||||
|
||||
> **B2in – Bridges to International. Bridges to Interior.**
|
||||
>
|
||||
> *Die internationale Herkunft bleibt Teil der Geschichte, das Interieur wird der Kern. Im Logo wird das sichtbar: B2in mit dem Zusatz „Interieur".*
|
||||
|
||||
Wichtiges Prinzip dabei: **B2in Interieur ist das Dach – die Kernmarken sind die Gesichter.** Jede Marke behält ihren eigenen Auftritt und ihre eigene Stärke; B2in verbindet sie, ohne sie zu vermischen. Genau das unterscheidet eine Premium-Gruppe von einem Gemischtwarenladen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Der neue Seitenaufbau
|
||||
|
||||
Die Seite wird nicht neu gebaut – Struktur und Technik bleiben. Es ändern sich Reihenfolge, Gewichtung und Inhalte. So sieht der Aufbau der Startseite künftig aus:
|
||||
|
||||
| # | Bereich | Was passiert |
|
||||
|---|---------|--------------|
|
||||
| 1 | **Ankündigungsleiste** | Bleibt als Instrument, trägt künftig Interieur-Themen (z.B. Neuheiten aus den Showrooms) statt dauerhaft Immobilien-Launches. |
|
||||
| 2 | **Hero** | Neue Headline und neue Bildwelt: Interieur und Marken zuerst. Zwei Buttons: „Unsere Marken entdecken" und „Termin anfragen". |
|
||||
| 3 | **Weiche Privat / Geschäft** | Neu: Zwei klare Wege direkt unter dem Einstieg – Privatkunden (Einrichtung fürs Zuhause) und Geschäftskunden (Ausstattung für Projekte, „bis zur letzten Blume"). |
|
||||
| 4 | **Kernmarken** | Neu: Das Herzstück. Kacheln für CABINET, Rotpunkt, Febrü – über das CMS von Dir selbst erweiterbar, jede Marke mit eigener Detailseite. |
|
||||
| 5 | **Über Dich / Vertrauen** | Bleibt – Du als Gesicht und persönlicher Ansprechpartner. Text dreht sich: Einrichtungskompetenz zuerst, Immobilien als ergänzende Expertise am Ende. |
|
||||
| 6 | **Aktuelles (Display)** | Neu: Die Inhalte aus dem Display-CMS erscheinen auf der Website (9:16). Dazu kommt Deine neue, selbst pflegbare Rubrik-Seite als eigenes Web-Display. |
|
||||
| 7 | **Immobilien-Teaser** | Bleibt, aber klein und dezent am Ende der Seite – wer gezielt sucht, findet den Weg; wer wegen Einrichtung kommt, wird nicht abgelenkt. |
|
||||
| 8 | **Abschluss & Kontakt** | Terminanfrage-Widget (Name, E-Mail, Wunschtermin, Notiz, Markenauswahl) plus WhatsApp-Button, der direkt den Chat mit Dir öffnet. |
|
||||
|
||||
In der Navigation rückt **„Marken" nach vorne, „Immobilien" nach hinten**. Auch Titel und Beschreibung für Google werden auf die neue Ausrichtung umgestellt, damit die Seite auch in der Suche die richtige Geschichte erzählt.
|
||||
|
||||
---
|
||||
|
||||
## 3. Textauszüge – so klingt die neue Richtung
|
||||
|
||||
Drei Auszüge, die den Ton setzen. Alle weiteren Texte folgen dieser Linie.
|
||||
|
||||
### 3.1 Hero (Einstieg)
|
||||
|
||||
> **B2in Interieur – Premium-Einrichtung von starken Marken. Für Ihr Zuhause. Für Ihr Projekt.**
|
||||
>
|
||||
> *Von der einzelnen Maßanfertigung bis zur vollständigen Ausstattung – mit CABINET, Rotpunkt Küchen, Febrü und einem Netzwerk regionaler Fachexperten.*
|
||||
|
||||
### 3.2 Die Weiche: Privat / Geschäft
|
||||
|
||||
> **Einrichtung für Ihr Zuhause**
|
||||
>
|
||||
> Einzelne Möbelstücke, maßgefertigte Einbaulösungen oder die komplette Einrichtung: Unsere Kernmarken und unser Fachhandels-Netzwerk begleiten Sie von der ersten Idee bis zur Montage.
|
||||
|
||||
> **Ausstattung für Ihr Projekt**
|
||||
>
|
||||
> Vollständige Einrichtung für Objekte, Büros und Projektentwicklungen – koordiniert aus einer Hand, von der Küche bis zur letzten Blume. Mit deutscher Vertragssicherheit und einem eingespielten Herstellernetzwerk.
|
||||
|
||||
### 3.3 Beispiel Kernmarken-Kachel (CABINET)
|
||||
|
||||
> **CABINET – Einbauschränke nach Maß.**
|
||||
>
|
||||
> 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.
|
||||
|
||||
Rotpunkt und Febrü bekommen Kacheln im selben Stil. Bei Febrü setze ich den Schwerpunkt auf das Zuhause-Arbeiten (Home-Office) und nenne die Büro- und Unternehmensausstattung als zweiten Aspekt – so passt die Marke in beide Welten, mit der richtigen Gewichtung.
|
||||
|
||||
---
|
||||
|
||||
## 4. Was bewusst so bleibt
|
||||
|
||||
- **Die Immobilien** bleiben statisch wie bisher und rücken nur in der Gewichtung nach hinten. Der Bereich selbst bleibt vollständig erreichbar.
|
||||
- **Das Magazin** bleibt bestehen – die neue Rubrik-Seite ergänzt es, ersetzt es aber nicht.
|
||||
- **Videos** laufen über YouTube-Einbindung – unkompliziert und ohne eigene Videotechnik. Das Influencer-Video bekommt darüber einen gut sichtbaren Platz.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dein Feedback – diese Punkte entscheide bitte Du
|
||||
|
||||
Damit ich nach dem Urlaub direkt umsetzen kann, brauche ich zu diesen Punkten Deine Rückmeldung:
|
||||
|
||||
| Punkt | Frage an Dich |
|
||||
|-------|---------------|
|
||||
| **Logo-Zusatz „Interieur"** | Freigabe der Ergänzung im Logo. Kläre bitte auch, ob Du eine Logo-Datei anpassen lässt oder ich das übernehmen soll. |
|
||||
| **Claim** | Vorschlag: „Premium-Einrichtung. Persönlich gemacht." (ersetzt „Connecting Design and Property"). Passt das – oder soll „Bridges to Interior" selbst als Claim-Zeile erscheinen? |
|
||||
| **Hero-Headline** | Passt Dir die Richtung aus Auszug 3.1? |
|
||||
| **Febrü-Tonalität** | Home-Office zuerst, Büro als zweiter Aspekt – einverstanden? |
|
||||
| **Name der neuen Rubrik** | „Aktuelles", „Referenzen", „Projekte" – oder ein anderer Name? |
|
||||
| **Bildmaterial** | Für Hero und Kernmarken brauche ich hochwertige Motive (Logos und Basis-Infos zu Rotpunkt und Febrü ebenfalls). Was kannst Du liefern, was soll ich auswählen? |
|
||||
|
||||
---
|
||||
|
||||
## 6. Zeitplan
|
||||
|
||||
- **Bis Anfang August:** Du gehst dieses Konzept in Ruhe durch und sammelst Dein Feedback zu den Punkten aus Abschnitt 5.
|
||||
- **Ab meiner Rückkehr:** Ich baue Dein Feedback ein und setze Struktur, Texte und Funktionen um. Zwischenstände zeige ich Dir vor dem Livegang.
|
||||
- **Ende August:** Livegang der neuen Ausrichtung – vorausgesetzt, Feedback und Zulieferungen (Bilder, Logos, Marken-Infos) liegen rechtzeitig vor.
|
||||
|
||||
*Bei Fragen erreichst Du mich nach dem Urlaub wie gewohnt – dringende Punkte gerne per Mail, ich arbeite sie der Reihe nach ab.*
|
||||
BIN
dev/2026-08-28/Bildschirmfoto 2026-09-24 um 17.30.16.png
Normal file
BIN
dev/2026-08-28/Bildschirmfoto 2026-09-24 um 17.30.16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
BIN
dev/2026-08-28/Farbwelt und Stil.png
Normal file
BIN
dev/2026-08-28/Farbwelt und Stil.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
dev/2026-08-28/Februe_Webseitentext_B2in_FINAL.pdf
Normal file
BIN
dev/2026-08-28/Februe_Webseitentext_B2in_FINAL.pdf
Normal file
Binary file not shown.
236
dev/2026-08-28/Februe_Webseitentext_FINAL.md
Normal file
236
dev/2026-08-28/Februe_Webseitentext_FINAL.md
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# B2in Interior · Febrü
|
||||
|
||||
**Arbeiten, wo man gerne bleibt.**
|
||||
|
||||
Webseitentext und Umsetzungsstruktur für die Febrü-Seite
|
||||
Stand: 31. August 2026 · Korrigierte Fassung zur Umsetzung
|
||||
|
||||
---
|
||||
|
||||
## Umsetzungshinweise (nicht veröffentlichen)
|
||||
|
||||
**Webstruktur:** Der normale Seitentext bleibt direkt sichtbar. Alle als `AUFKLAPPBARER DETAILBEREICH` gekennzeichneten Vertiefungen werden als Akkordeon umgesetzt. So bleibt die Seite schnell erfassbar, ohne dass Inhalt verloren geht.
|
||||
|
||||
**CTA:** Kein zweiter Button. Das bereits vorhandene schwebende Terminanfrage-Widget erhält auf dieser Markenseite die Beschriftung **„Persönliche Arbeitsplatzberatung vereinbaren"**. Es bleibt beim Scrollen sichtbar und darf auf dem Smartphone keine Inhalte verdecken.
|
||||
|
||||
---
|
||||
|
||||
## Hero
|
||||
|
||||
Ein Büro ist kein Parkplatz für Schreibtische. Es ist ein Raum für Konzentration, Austausch, Bewegung und neue Ideen. Und manchmal entsteht die beste Idee eben nicht am Schreibtisch, sondern zwischen Kaffeemaschine, Lounge und einem guten Gespräch.
|
||||
|
||||
Febrü verbindet ergonomische Arbeitsplätze mit wohnlichem Design, intelligentem Stauraum, durchdachter Akustik und flexiblen Raumstrukturen.
|
||||
|
||||
Wir entwickeln daraus Arbeitswelten, die zu den Menschen, ihren Aufgaben und ihrer Umgebung passen – vom einzelnen Homeoffice bis zur vollständigen Unternehmensfläche.
|
||||
|
||||
**Ihr Arbeitsplatz. Persönlich geplant.**
|
||||
|
||||
---
|
||||
|
||||
## Zuhause oder Projekt. Zwei Wege. Derselbe Anspruch.
|
||||
|
||||
Unsere Startseite führt bewusst über zwei Wege: Zuhause und Projekt. Denn ein einzelnes Homeoffice stellt andere Anforderungen als eine Büroetage mit fünfzig Arbeitsplätzen.
|
||||
|
||||
Unterschiedliche Aufgaben brauchen unterschiedliche Planung und natürlich auch unterschiedliche Kalkulationen. Bei uns gibt es trotzdem nur Kunden – mit unterschiedlichen Räumen, Anforderungen und Stückzahlen. Der Anspruch an Produkt, Beratung und Service bleibt derselbe.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Bei uns gibt es Kunden – keine Schubladen
|
||||
>
|
||||
> B2B und B2C sind Begriffe aus Präsentationen und Vertriebsschulungen. Früher hieß es Privatkunde oder Firmenkunde. Oder, noch schlimmer: A-, B- oder C-Kunde. Bei diesem Schubladendenken machen wir nicht mit.
|
||||
>
|
||||
> Natürlich beeinflusst die Menge den Preis. Wer fünfzig Arbeitsplätze einrichtet, erhält eine andere Kalkulation als jemand, der einen einzelnen Schreibtisch für sein Homeoffice sucht. Alles andere wäre weder ehrlich noch kaufmännisch sinnvoll.
|
||||
>
|
||||
> Die Menge verändert aber weder die Qualität des Produktes noch die Sorgfalt der Planung, die persönliche Beratung oder den Service. Ein einzelner Arbeitsplatz im privaten Zuhause verdient dieselbe Aufmerksamkeit wie eine vollständige Büroetage.
|
||||
>
|
||||
> Febrü bleibt Febrü. B2in bleibt B2in. Und jeder Kunde erhält eine Lösung, die zu seinem tatsächlichen Bedarf passt.
|
||||
|
||||
**Ob im Kleinen oder im Großen – die Detailtreue entscheidet.**
|
||||
|
||||
---
|
||||
|
||||
## Aus Herford. Für moderne Arbeitswelten.
|
||||
|
||||
Febrü entwickelt und produziert hochwertige Büromöbel direkt im ostwestfälischen Herford – nur wenige Kilometer von Bielefeld entfernt. Eine Fertigungstiefe von 95 Prozent ermöglicht kurze Wege, verlässliche Qualität und hohe Flexibilität bei Materialien, Farben und individuellen Anforderungen.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Von einem Schreibtisch bis zum vollständigen Bürotrakt
|
||||
>
|
||||
> Das Sortiment reicht von ergonomischen und höhenverstellbaren Schreibtischen über Konferenz- und Besprechungstische, Bürostühle und Loungeelemente bis zu Empfangslösungen, Stauraum, Akustikelementen, mobilen Medienmöbeln, Schließfachsystemen und flexiblen Raumstrukturen.
|
||||
>
|
||||
> Damit lässt sich ein einzelner Arbeitsplatz genauso überzeugend planen wie eine Praxis, eine Kanzlei, ein Konferenzbereich oder ein vollständiger Bürotrakt.
|
||||
|
||||
**Made in Herford. Geplant in Bielefeld.**
|
||||
|
||||
---
|
||||
|
||||
## Nicht drei Marken. Ein durchgängiger Raum.
|
||||
|
||||
Ein Bürotrakt endet nicht am Schreibtisch. Menschen brauchen Orte zum Arbeiten, für Besprechungen und Rückzug – aber ebenso für Pausen, den gemeinsamen Kaffee, das Mittagessen und den informellen Austausch.
|
||||
|
||||
Genau hier greifen Febrü, Rotpunkt und CABINET bei uns ineinander: Febrü gestaltet die Arbeitswelt, Rotpunkt die Küche im Bürotrakt und CABINET die millimetergenaue Sonderlösung.
|
||||
|
||||
Entscheidend ist nicht, welcher Hersteller welches Möbel liefert. Entscheidend ist, dass am Ende alles zusammengehört. Wir stimmen Arbeitsplätze, Küche, Stauraum, Farben, Materialien, Beleuchtung und Akustik zu einem durchgängigen Konzept aufeinander ab.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Wie die drei Marken zusammenspielen
|
||||
>
|
||||
> Febrü gestaltet ergonomische Arbeitsplätze, Konferenzbereiche, Loungezonen, Akustik, Stauraum und flexible Raumstrukturen.
|
||||
>
|
||||
> Rotpunkt gestaltet Mitarbeiterküchen, Coffee-Points und gemeinsam genutzte Aufenthaltsküchen als selbstverständlichen Bestandteil der Arbeitswelt – und nicht als übrig gebliebene Funktionszeile. Gerade hier entstehen oft Gespräche, Verbindungen und Ideen, für die im offiziellen Meeting kein Platz war.
|
||||
>
|
||||
> CABINET übernimmt dort, wo Nischen vollständig genutzt, Schränke von Wand zu Wand und vom Boden bis zur Decke geplant oder besondere Übergänge zwischen Arbeitsplatz, Stauraum und Architektur geschaffen werden sollen. So entstehen beispielsweise maßgefertigte Garderoben, Aktenschränke, Medienlösungen und Stauraumelemente.
|
||||
|
||||
**Febrü für die Arbeitswelt. Rotpunkt für die Küche. CABINET für die millimetergenaue Sonderlösung. B2in für das Ganze.**
|
||||
|
||||
---
|
||||
|
||||
## Ein Arbeitsplatz. Fünfzig Arbeitsplätze. Ein Anspruch.
|
||||
|
||||
Ob wir über einen einzelnen Arbeitsplatz oder einen vollständigen Bürotrakt sprechen: Das grundsätzliche Problem bleibt dasselbe. Fühlt sich der Mensch, der dort täglich arbeitet, gesehen und wertgeschätzt – oder wurde ihm lediglich irgendwo ein Tisch hingestellt?
|
||||
|
||||
Die Stückzahl verändert den Umfang der Aufgabe, aber nicht ihre Bedeutung. Wir bewerten einen Arbeitsplatz deshalb nicht nach Quadratmetern oder Auftragswert, sondern danach, was er für den Menschen leisten muss.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Wertschätzung ist keine Frage der Stückzahl
|
||||
>
|
||||
> Gerade in Zeiten des Fachkräftemangels können Unternehmen es sich nicht leisten, Arbeitsbedingungen als Nebensache zu behandeln. Wer Top-Leistung erwartet, muss die passenden Voraussetzungen schaffen. Ein Obstkorb und kostenloses Wasser sind freundlich – ersetzen aber keinen guten Arbeitsplatz.
|
||||
>
|
||||
> Besonders deutlich wird das bei teilzeitbeschäftigten Müttern – selbstverständlich genauso bei Vätern und allen anderen Menschen mit familiärer Sorgeverantwortung. Teilzeit bedeutet nicht halbe Leistung. Oft wird in einem kürzeren Zeitfenster hoch konzentriert gearbeitet, während neben dem nächsten Onlinemeeting gleichzeitig Schule, Termine, Betreuung und damit unsere wichtigste gemeinsame Ressource organisiert werden: unser Nachwuchs.
|
||||
>
|
||||
> Gerade hier darf der Arbeitsplatz kein Provisorium aus Küchentisch, Beistelltisch oder Schlafzimmerecke sein. Er muss sofort funktionieren, Bewegung ermöglichen, technisch durchdacht sein und sich trotzdem vernünftig in das Zuhause einfügen. Für den Menschen, der ihn täglich nutzt, ist ein einzelner Arbeitsplatz keine kleine Lösung. Er ist seine vollständige Arbeitswelt.
|
||||
|
||||
---
|
||||
|
||||
## Ergonomie, die im Alltag funktioniert
|
||||
|
||||
Ein Arbeitsplatz muss sich dem Menschen anpassen – nicht umgekehrt. Höhenverstellbare Schreibtische und ergonomische Bürostühle schaffen Bewegung und unterstützen eine gesunde Haltung.
|
||||
|
||||
Ergonomie endet aber nicht bei Tisch und Stuhl. Auch Laufwege, Greifräume, Bildschirmposition, Beleuchtung, Stauraum und der Wechsel zwischen Tätigkeiten bestimmen, wie angenehm und produktiv ein Arbeitstag verläuft.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Und nein: Wir meinen nicht nur den Weg zur Kaffeemaschine
|
||||
>
|
||||
> Sinnvolle Laufwege sind Teil einer guten Planung – auch wenn Kaffeemaschine und Kühlschrank zweifellos eine gewisse Bedeutung für den Arbeitsalltag haben.
|
||||
>
|
||||
> Das gilt für vier Stunden ebenso wie für zehn Stunden am Schreibtisch, im Unternehmen genauso wie im privaten Zuhause. Und natürlich müssen die Möbel gestalterisch zum Raum passen. Wer denkt: „Das sieht doch keiner – außer den Mitarbeitern", hat den ersten entscheidenden Fehler bereits gemacht. Die Mitarbeiter sehen es. Jeden Tag. Und sie arbeiten darin.
|
||||
|
||||
Marcel Scheibe, Geschäftsführer von B2in, bringt es persönlich auf den Punkt:
|
||||
|
||||
> „Ich bin, sagen wir, alt genug, um beim Aufstehen gelegentlich zu stöhnen. Und ehrlich gesagt fühle ich mich an manchen Tagen noch etwas älter. Mein Arzt sagt, ich solle weniger sitzen. Mein Beruf findet nun einmal zu einem großen Teil am Computer statt. Ergo brauche ich eine Lösung, die mir mehr Bewegung ermöglicht, ohne dass ich dafür meinen Beruf wechseln muss."
|
||||
|
||||
---
|
||||
|
||||
## Office meets Interior
|
||||
|
||||
Identische Schreibtischreihen werden einem modernen Arbeitstag nicht mehr gerecht. Menschen benötigen Bereiche für Konzentration, spontane Gespräche, Teamarbeit, Videokonferenzen, vertrauliche Besprechungen und erholsame Pausen.
|
||||
|
||||
Febrü verbindet dafür klassische Arbeitsplatzmöbel mit wohnlichen Loungeelementen, mobilen Multifunktionslösungen, Stauraum, Akustik und flexibler Raumzonierung. So entsteht nicht für jede Person derselbe Platz, sondern für jede Tätigkeit der richtige.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Das Wir und das Ich
|
||||
>
|
||||
> Ja, ja – das könnte man unter dem üblichen Verkaufs-Blabla abheften. Dahinter steht aber eine einfache Wahrheit: Verantwortung lässt sich nicht dauerhaft nach außen delegieren. Was bleibt, sind das Wir und das Ich.
|
||||
>
|
||||
> Ein Unternehmen muss Bedingungen schaffen, unter denen Menschen gemeinsam gut arbeiten, sich austauschen und konzentrieren können. Der Einzelne erhält die Möglichkeit – und trägt auch die Verantwortung –, daraus das Beste zu machen.
|
||||
>
|
||||
> Räume und Möbel schaffen allein noch keine gute Unternehmenskultur. Schlechte Arbeitsbedingungen können eine gute Kultur aber jeden Tag unnötig erschweren.
|
||||
>
|
||||
> Das Unternehmen stärkt das Wir. Der Einzelne macht daraus das Beste.
|
||||
|
||||
---
|
||||
|
||||
## Ruhe und Flexibilität sind planbar.
|
||||
|
||||
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.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Akustik und flexible Raumstrukturen
|
||||
>
|
||||
> Hallende Räume, Telefongespräche und Videokonferenzen können Konzentration und Sprachverständlichkeit erheblich beeinträchtigen. Akustikpaneele, Trennwände, Screens und entsprechend geplante Möbel helfen, Schall zu reduzieren.
|
||||
>
|
||||
> Mit modularen Möbeln und Raum-in-Raum-Lösungen lassen sich Arbeitsbereiche neu ordnen, ohne bei jeder Veränderung gleich feste Wände bauen zu müssen. Das ist im großen Büro wichtig – und im privaten Zuhause häufig noch wichtiger.
|
||||
|
||||
---
|
||||
|
||||
## Gestaltung, die zur Identität passt
|
||||
|
||||
Ein Büro ist ein sichtbarer Teil der Unternehmenskultur. Farben, Materialien und Raumwirkung beeinflussen, wie Mitarbeitende, Kunden und Bewerber ein Unternehmen wahrnehmen. Im Homeoffice muss der Arbeitsplatz professionell funktionieren und sich zugleich in die vorhandene Einrichtung einfügen.
|
||||
|
||||
Dank der hohen Fertigungstiefe bietet Febrü eine große Auswahl an Materialien, Dekoren, Stoffen und Gestellfarben. Corporate Design muss nicht am Briefpapier enden. Und ein Homeoffice muss nach Feierabend nicht wie ein kleines Finanzamt aussehen.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Und ja: Pflanzen gehören dazu
|
||||
>
|
||||
> Eine Pflanze macht aus einem Unternehmen noch kein nachhaltiges Unternehmen. Sie macht aus einem technisch eingerichteten Raum aber häufig erst einen Ort, an dem Menschen gerne arbeiten. Das hat nichts mit Greenwashing zu tun, sondern schlicht mit Atmosphäre und Stil.
|
||||
|
||||
---
|
||||
|
||||
## Nachhaltigkeit, die länger als bis zum nächsten Umzug hält
|
||||
|
||||
Nachhaltigkeit beginnt bei der Lebensdauer eines Möbelstücks. Viele Febrü-Möbel sind modular aufgebaut, sodass Komponenten ausgetauscht oder ergänzt werden können, statt bei veränderten Anforderungen das gesamte Möbelstück zu ersetzen.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Konkrete Standards statt allgemeiner Versprechen
|
||||
>
|
||||
> Febrü produziert mit hoher Fertigungstiefe direkt in Herford. Das sorgt für kurze Wege und kontrollierte Prozesse. Mit laserTEC entstehen hochwertige, fugenlose Kanten ohne zusätzlichen Klebstoff.
|
||||
>
|
||||
> Febrü arbeitet nach ISO 9001 und ISO 14001 und verfügt über einen PEFC-Produktkettennachweis. Ausgewählte Febrü-Tischsysteme und Stauraummöbel tragen den Blauen Engel.
|
||||
>
|
||||
> Nachhaltig ist nicht das Möbelstück mit dem längsten Werbetext. Nachhaltig ist das Möbelstück, das viele Jahre funktioniert und veränderte Anforderungen mitmacht.
|
||||
|
||||
---
|
||||
|
||||
## Warum Febrü bei uns?
|
||||
|
||||
Bei uns beginnt eine Planung nicht mit einem Möbelkatalog, sondern mit den Menschen und der Frage, wie tatsächlich gearbeitet wird. Wo ist Konzentration gefragt? Wo entstehen Gespräche? Welche Bereiche benötigen Diskretion? Und wie viel Stauraum wird wirklich gebraucht?
|
||||
|
||||
Wir verbinden Ergonomie, Laufwege, Akustik, Stauraum, Beleuchtung, Kommunikation und Rückzug zu einem Gesamtkonzept. Es geht nicht darum, möglichst viele Möbel in einen Raum zu stellen. Es geht darum, eine bessere Arbeitswelt zu schaffen.
|
||||
|
||||
> **AUFKLAPPBARER DETAILBEREICH**
|
||||
> ### Im Homeoffice stellen sich andere Fragen
|
||||
>
|
||||
> Wie sichtbar darf der Arbeitsplatz sein? Wo verschwinden Technik und Unterlagen nach Feierabend? Und wie lässt sich professionelles Arbeiten so in die vorhandene Einrichtung integrieren, dass das Zuhause ein Zuhause bleibt?
|
||||
>
|
||||
> Auch hier gilt: Ein einzelner Arbeitsplatz ist keine kleinere Aufgabe. Er braucht nur eine andere Antwort als ein vollständiger Bürotrakt.
|
||||
|
||||
---
|
||||
|
||||
## Erleben statt nur aus dem Katalog auswählen
|
||||
|
||||
Durch die Nähe zwischen Bielefeld und Herford können Febrü-Produkte nach Abstimmung persönlich erlebt werden. Wir organisieren und begleiten den Besuch der Ausstellung – für einen Schreibtisch genauso wie für einen vollständigen Unternehmensstandort.
|
||||
|
||||
Dort lassen sich Materialien anfassen, Stühle und Tische ausprobieren, Akustiklösungen erleben und unterschiedliche Arbeitswelten vergleichen. Sie müssen sich nicht allein auf ein Verkaufsgespräch verlassen.
|
||||
|
||||
---
|
||||
|
||||
## Abschluss
|
||||
|
||||
**Ihr Arbeitsplatz. Persönlich geplant.**
|
||||
|
||||
Ob Homeoffice, Praxis, Kanzlei, wachsendes Unternehmen oder vollständige Arbeitswelt: Wir planen jeden Arbeitsplatz mit demselben Anspruch. Nur der Umfang verändert sich.
|
||||
|
||||
Gemeinsam entwickeln wir eine Arbeitsumgebung, die gesundes Arbeiten unterstützt, gestalterisch überzeugt und den Menschen zeigt, dass ihre tägliche Leistung wahrgenommen und wertgeschätzt wird – persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.
|
||||
|
||||
**Ob im Kleinen oder im Großen – die Detailtreue entscheidet.**
|
||||
|
||||
---
|
||||
|
||||
## Interne Quellenhinweise (nicht veröffentlichen)
|
||||
|
||||
Die Quellen dienen der Umsetzung, den Herstellern und einer abschließenden rechtlichen Prüfung. Aussagen nur im belegten Umfang veröffentlichen.
|
||||
|
||||
- Febrü, Nachhaltigkeit und Zertifizierungen: https://www.februe.de/unternehmen/nachhaltigkeit
|
||||
- Blauer Engel, Febrü: https://www.blauer-engel.de/de/unternehmen/februe-bueromoebel-gmbh
|
||||
- Blauer Engel listet für Febrü zwei Produktgruppen: ausgewählte Tischsysteme sowie ausgewählte Stauraummöbel. **Produktbezug bei Veröffentlichung zwingend beibehalten** – keine pauschale Aussage über das Gesamtsortiment.
|
||||
- Die pauschale Aussage zur nahezu vollständigen Wiederverwertbarkeit von Aluminium und Stahl wurde entfernt.
|
||||
|
||||
**Vor Veröffentlichung:**
|
||||
- Herstellerfreigabe einholen
|
||||
- Umweltaussagen mit Blick auf die EmpCo-Regeln (ab 27.09.2026) prüfen lassen
|
||||
|
||||
---
|
||||
|
||||
## Änderungen gegenüber der Fassung vom 31.08.
|
||||
|
||||
- Der erklärende Satz „Die Aussage bezieht sich ausdrücklich auf die offiziell gelisteten Produktgruppen und nicht pauschal auf das gesamte Sortiment" wurde aus dem Kundentext entfernt. Die Aussage davor ist bereits präzise; der Hinweis steht jetzt in den internen Quellenhinweisen.
|
||||
- Die Formulierung zu teilzeitbeschäftigten Müttern bleibt unverändert.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue