B2in Interior: Relaunch der Website, Kernmarken-CMS und Terminanfrage

Umsetzung von AP0-AP6 aus dev/2026-08-28/entwicklungsplan.md sowie der
vorab gefundenen Befunde T1 und B1-B13.

Befunde
- CmsContentSeeder überschrieb bei jedem Lauf alle redaktionellen Inhalte;
  jetzt Redaktionsschutz plus gezielter Relaunch-Seeder (B1)
- Magazin-CMS war nicht ans Frontend angebunden, Artikel jetzt über Slugs
  mit 301 von den alten numerischen URLs (B2)
- Vier veröffentlichte Display-Playlists zeigten auf b2in.test und wären
  live nicht ladbar gewesen (B13)
- Info-Tablet speicherte geleerte Öffnungszeiten nicht (T1)
- /service und /portfolio lieferten seit Februar 500 (B12)

Website
- Startseite neu strukturiert und umgetextet, eigene Seite /marken mit
  Detailseite je Kernmarke, /netzwerk und Altrouten leiten dorthin
- Galerie je Marke, getrennt nach realisierten Projekten und
  Visualisierungen; ohne gesetzte Kategorie gilt ein Bild als Entwurf
- Seite /referenzen, gespeist aus den Markengalerien
- Showroom-Display im 9:16-Rahmen über eine schreibgeschützte Embed-Route
  ohne Preview-Token
- Terminanfrage-Widget auf allen Seiten, Markenauswahl aus dem CMS,
  WhatsApp per Klick-zum-Chat-Link mit vorbereitetem Text
- FAQ, Über-uns, Kontakt und Magazin inhaltlich auf Interior umgestellt

Sonstiges
- Vorschau-Instanzen liefern noindex und verlinken sich bei unbekanntem
  Host auf sich selbst statt auf eine fremde Domain
- Seeder-Zugangsdaten aus dem Code in die Konfiguration (B8)
- Ablauf für die Vorschau: dev/2026-08-28/preview-deployment.md

833 Tests grün (3084 Assertions), Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-08-28 15:31:19 +00:00
parent 193d37b1ce
commit 6d53243ea6
132 changed files with 8672 additions and 335 deletions

View file

@ -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=

4
.gitignore vendored
View file

@ -57,4 +57,6 @@ 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

View file

@ -10,6 +10,7 @@ class ThemeHelper
public static function getLogoPath(string $type = 'positive'): string
{
$theme = config('app.theme', 'b2in');
return self::getLogoPathForBrand($theme, $type);
}
@ -22,21 +23,21 @@ class ThemeHelper
$logoMap = [
'b2in' => [
'positive' => 'img/logos/b2in-logo-positive.svg',
'negative' => 'img/logos/b2in-logo-negative.svg'
'positive' => 'img/logos/b2in-interior-logo-positive.svg',
'negative' => 'img/logos/b2in-interior-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 +49,7 @@ class ThemeHelper
public static function getFaviconPath(): string
{
$theme = config('app.theme', 'b2in');
return "img/favicons/{$theme}-favicon.ico";
}
@ -57,6 +59,7 @@ class ThemeHelper
public static function getThemeCssPath(): string
{
$theme = config('app.theme', 'b2in');
return "resources/css/web/theme-{$theme}.css";
}
@ -66,6 +69,7 @@ class ThemeHelper
public static function getDomainConfig(): array
{
$theme = config('app.theme', 'b2in');
return config("domains.domains.{$theme}", []);
}
@ -75,6 +79,7 @@ class ThemeHelper
public static function getPrimaryColor(): string
{
$config = self::getDomainConfig();
return $config['color_scheme']['primary'] ?? '#2b3f51';
}
@ -84,6 +89,7 @@ class ThemeHelper
public static function getSecondaryColor(): string
{
$config = self::getDomainConfig();
return $config['color_scheme']['secondary'] ?? '#20a0da';
}
@ -93,6 +99,7 @@ class ThemeHelper
public static function getPrimaryFont(): string
{
$config = self::getDomainConfig();
return $config['font_family']['primary'] ?? 'Inter';
}
@ -102,6 +109,7 @@ class ThemeHelper
public static function getSecondaryFont(): string
{
$config = self::getDomainConfig();
return $config['font_family']['secondary'] ?? 'IBM Plex Sans';
}
@ -111,12 +119,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 +137,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 +151,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 +168,7 @@ class ThemeHelper
public static function getBrandConfig(?string $brand = null): array
{
$brand = $brand ?? config('app.theme', 'b2in');
return config("domains.domains.{$brand}", []);
}
}

View 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',
]);
}
}

View file

@ -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] : '';
}
}

View 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');
}
}

View file

@ -0,0 +1,44 @@
<?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();
}
public function render()
{
return view('livewire.web.components.sections.brand-detail');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View file

@ -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,9 +15,9 @@ class MagazinDetail extends Component
public $content;
public function mount($id = 1)
public function mount($slug)
{
$this->articleId = (int) $id;
$this->slug = (string) $slug;
$this->loadArticle();
$this->loadRelatedArticles();
$this->loadThemeContent();
@ -25,14 +26,18 @@ class MagazinDetail extends Component
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())
->reject(fn (array $article, string $slug) => $slug === $this->slug)
->take(2)
->values()
->toArray();
@ -43,9 +48,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()

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsArticle;
use Livewire\Component;
class MagazinList extends Component
@ -18,17 +19,16 @@ class MagazinList extends Component
private function loadPosts()
{
$articles = trans('b2in.articles');
$this->posts = collect($articles)->map(function ($article) {
$this->posts = collect(CmsArticle::forFrontend())->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()
@ -38,6 +38,8 @@ class MagazinList extends Component
public function render()
{
return view('livewire.web.components.sections.magazin-list');
return view('livewire.web.components.sections.magazin-list', [
'posts' => $this->posts,
]);
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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');
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use Livewire\Component;
/**
* Realisierte Projekte aus den Markengalerien, über alle Marken hinweg gesammelt.
*
* Keine zweite Pflegestelle: Marcel pflegt die Bilder weiterhin je Marke im Kernmarken-Modul.
* Hier erscheinen ausschließlich Einträge der Rubrik „Realisiertes Projekt" – Visualisierungen
* und Entwürfe bleiben bewusst draußen, damit „Referenzen" hält, was das Wort verspricht.
*/
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;
$this->projects = $this->collectProjects();
}
/**
* @return array<int, array<string, mixed>>
*/
private function collectProjects(): 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'],
'brand' => (string) ($brand['name'] ?? ''),
'brand_slug' => $slug,
'has_detail_page' => (bool) ($brand['has_detail_page'] ?? false),
];
}
}
return $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
}
public function render()
{
return view('livewire.web.components.sections.reference-projects');
}
}

View 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');
}
}

View file

@ -13,6 +13,25 @@ class AnnouncementBar extends Component
$this->content = cms_theme_section('announcement_bar');
}
/**
* 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()
{
return view('livewire.web.components.ui.announcement-bar');

View file

@ -0,0 +1,129 @@
<?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',
'sonstiges' => 'Sonstiges',
];
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();
}
/**
* 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 = '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->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');
}
}

View file

@ -6,6 +6,15 @@ use Livewire\Component;
class Footer extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public function mount(): void
{
$section = cms_theme_section('footer');
$this->content = is_array($section) ? $section : [];
}
public function render()
{
return view('livewire.web.components.ui.footer');

View file

@ -13,6 +13,22 @@ 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',
];
protected $fillable = [
'slug',
'title',
@ -53,6 +69,86 @@ class CmsArticle extends Model
return $query->orderBy('order')->orderByDesc('created_at');
}
/**
* 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.
*
* @return array<string, array<string, mixed>>
*/
public static function forFrontend(): array
{
$articles = static::query()->published()->ordered()->get();
if ($articles->isNotEmpty()) {
return $articles
->mapWithKeys(fn (self $article) => [$article->slug => $article->toFrontendArray()])
->all();
}
return static::fallbackFromLang();
}
/**
* 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];
}
return $out;
}
/**
* @return array<string, mixed>
*/

225
app/Models/CmsBrand.php Normal file
View file

@ -0,0 +1,225 @@
<?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';
/** @var list<string> */
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION];
protected $table = 'cms_brands';
protected $fillable = [
'slug',
'name',
'tagline',
'teaser',
'summary',
'highlights',
'description',
'logo',
'image',
'services',
'gallery',
'website_url',
'showroom',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = [
'name',
'tagline',
'teaser',
'summary',
'description',
];
protected function casts(): array
{
return [
'highlights' => 'array',
'services' => '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' => [],
'gallery' => [],
'website_url' => '',
'showroom' => '',
'has_detail_page' => false,
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
$out[$slug] = $entry;
}
return $out;
}
/**
* 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 und Inspirationen getrennt.
*
* @param mixed $gallery
* @return array{projects: 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,
)),
'inspirations' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_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 ?? [],
'gallery' => self::normalizeGallery($this->gallery),
'website_url' => $this->website_url,
'showroom' => $this->showroom,
'has_detail_page' => true,
];
}
}

View file

@ -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'];

View file

@ -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,

View file

@ -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),

View file

@ -41,6 +41,21 @@ 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'),
/*
|--------------------------------------------------------------------------
| Application URL

View file

@ -15,10 +15,25 @@ return [
'sections' => [
'announcement_bar' => ['global', 'announcement_bar'],
'header' => ['global', 'header'],
'footer' => ['global', 'footer'],
'cta_section' => ['global', 'cta_section'],
'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'],
'marken_hero' => ['marken', 'hero'],
'marken_network' => ['marken', 'network'],
'marken_growth' => ['marken', 'growth'],
'marken_cta' => ['marken', 'cta'],
'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'],

21
config/seeding.php Normal file
View 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'),
];

View 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]);
}
}

View file

@ -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');
}
};

View file

@ -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']);
});
}
};

View file

@ -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' => [

View file

@ -0,0 +1,139 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Legt die drei Kernmarken an. Texte aus dev/2026-08-28/umsetzung-inhalte.md §5.
*
* Bestehende Marken werden nicht überschrieben – Marcel pflegt sie im Admin.
* Logos, 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',
'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',
'is_published' => true,
],
[
'slug' => 'rotpunkt',
'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',
'is_published' => true,
],
[
'slug' => 'februe',
'name' => ['de' => 'Febrü', 'en' => 'Febrü'],
'tagline' => ['de' => 'Arbeiten, wo man gerne ist.', 'en' => 'Work where you like to be.'],
'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' => '',
'is_published' => true,
],
];
}
}

View file

@ -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

View file

@ -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,
]);
}
}

View file

@ -0,0 +1,67 @@
<?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. Damit
* das nicht versehentlich live geht, traegt jede Bildunterschrift den Vermerk „BEISPIEL“.
*
* Wieder entfernen:
* php artisan tinker --execute="App\Models\CmsBrand::query()->update(['gallery' => null]);"
*/
class DemoBrandGallerySeeder extends Seeder
{
/**
* Galerie je Marken-Slug: [Dateiname, Kategorie, Bildunterschrift].
*
* @var array<string, list<array{0: string, 1: string, 2: string}>>
*/
private const GALLERIES = [
'cabinet' => [
['b2a.webp', CmsBrand::GALLERY_PROJECT, 'BEISPIEL – Wohnwand mit Einbauregal, Bielefeld'],
['hero-room.webp', CmsBrand::GALLERY_PROJECT, 'BEISPIEL – Wohnbereich mit Raumteiler'],
['stileigentum.webp', CmsBrand::GALLERY_INSPIRATION, 'BEISPIEL – Entwurf Wohnzimmer mit Sideboard'],
['best-of-two-worlds.jpg', CmsBrand::GALLERY_INSPIRATION, 'BEISPIEL – Visualisierung offener Wohnraum'],
],
'rotpunkt' => [
['best-of-two-world.jpg', CmsBrand::GALLERY_PROJECT, 'BEISPIEL – Offener Wohn- und Kochbereich'],
['style2own.webp', CmsBrand::GALLERY_INSPIRATION, 'BEISPIEL – Entwurf heller Wohnbereich'],
['magazin-3.webp', CmsBrand::GALLERY_INSPIRATION, 'BEISPIEL – Visualisierung Wohnraum mit Ausblick'],
],
'februe' => [
['accommodation-2.webp', CmsBrand::GALLERY_PROJECT, 'BEISPIEL – Empfangsbereich eines Buerogebaeudes'],
['accommodation-1.webp', CmsBrand::GALLERY_PROJECT, 'BEISPIEL – Grossraumbuero mit Arbeitsplaetzen'],
['partner-hero.webp', CmsBrand::GALLERY_INSPIRATION, 'BEISPIEL – Entwurf Besprechungsbereich'],
['ecosystem-hero.webp', CmsBrand::GALLERY_INSPIRATION, 'BEISPIEL – 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.');
}
}
}

View 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.");
}
}

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

View 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.');
}
}

View 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.*

View file

@ -0,0 +1,113 @@
.env.example
app/Helpers/ThemeHelper.php
app/Livewire/Admin/Cms/CabinetInfoTablet.php
app/Livewire/Web/Components/Sections/MagazinDetail.php
app/Livewire/Web/Components/Sections/MagazinList.php
app/Livewire/Web/Components/UI/AnnouncementBar.php
app/Livewire/Web/Components/UI/Footer.php
app/Models/CmsArticle.php
app/Providers/ThemeServiceProvider.php
app/Support/DisplayModuleSettings.php
app/helpers.php
config/app.php
config/cms_section_map.php
database/seeders/CmsArticleSeeder.php
database/seeders/CmsContentSeeder.php
database/seeders/DatabaseSeeder.php
public/_cabinet/display/index.html
resources/lang/de/b2in.php
resources/lang/de/ui.php
resources/lang/en/b2in.php
resources/lang/en/ui.php
resources/views/livewire/admin/cms/cabinet-info-tablet.blade.php
resources/views/livewire/admin/cms/content-index.blade.php
resources/views/livewire/admin/cms/dashboard.blade.php
resources/views/livewire/admin/cms/projects-index.blade.php
resources/views/livewire/web/components/sections/magazin-list.blade.php
resources/views/livewire/web/components/ui/announcement-bar.blade.php
resources/views/livewire/web/components/ui/footer.blade.php
resources/views/livewire/web/components/ui/header.blade.php
resources/views/vendor/mail/html/header.blade.php
resources/views/web/about.blade.php
resources/views/web/contact.blade.php
resources/views/web/cookie-policy.blade.php
resources/views/web/faq.blade.php
resources/views/web/home.blade.php
resources/views/web/impressum.blade.php
resources/views/web/layouts/web-master.blade.php
resources/views/web/magazin-detail.blade.php
resources/views/web/magazin.blade.php
resources/views/web/netzwerk.blade.php
resources/views/web/privacy.blade.php
resources/views/web/terms.blade.php
routes/admin.php
routes/web.php
tests/Feature/AnnouncementBarTest.php
tests/Feature/CabinetInfoTabletTest.php
tests/Feature/Cms/CmsArticleTest.php
tests/Feature/Cms/CmsContentSeederTest.php
tests/Feature/DisplayVersionApiTest.php
tests/Feature/DisplayVersionTest.php
tests/Feature/InteriorPageTest.php
tests/Feature/LiveSiteReviewTest.php
tests/Feature/MagazinPageTest.php
app/Http/Controllers/DisplayEmbedController.php
app/Livewire/Web/Components/Sections/AudienceSwitch.php
app/Livewire/Web/Components/Sections/BrandDetail.php
app/Livewire/Web/Components/Sections/BrandOverview.php
app/Livewire/Web/Components/Sections/CoreBrands.php
app/Livewire/Web/Components/Sections/DisplayFeed.php
app/Livewire/Web/Components/Sections/ImmobilienTeaser.php
app/Livewire/Web/Components/Sections/MarkenCta.php
app/Livewire/Web/Components/Sections/MarkenGrowth.php
app/Livewire/Web/Components/Sections/MarkenNetwork.php
app/Livewire/Web/Components/Sections/PageHero.php
app/Livewire/Web/Components/Sections/ReferenceProjects.php
app/Livewire/Web/Components/Sections/Sponsoring.php
app/Livewire/Web/Components/Ui/AppointmentWidget.php
app/Models/CmsBrand.php
config/seeding.php
database/factories/CmsBrandFactory.php
database/migrations/2026_08_28_110407_create_cms_brands_table.php
database/migrations/2026_08_28_112244_add_summary_and_highlights_to_cms_brands_table.php
database/seeders/CmsBrandSeeder.php
database/seeders/DemoBrandGallerySeeder.php
database/seeders/DisplayInteriorBrandingSeeder.php
database/seeders/DisplayMediaUrlNormalizerSeeder.php
database/seeders/InteriorRelaunchCmsSeeder.php
public/_cabinet/assets/b2in-interior-logo-negative.svg
public/_cabinet/assets/b2in-interior-logo-positive.svg
public/img/assets/sponsoring/
public/img/logos/b2in-interior-logo-negative.png
public/img/logos/b2in-interior-logo-negative.svg
public/img/logos/b2in-interior-logo-positive.png
public/img/logos/b2in-interior-logo-positive.svg
resources/views/livewire/admin/cms/brands-index.blade.php
resources/views/livewire/web/components/sections/audience-switch.blade.php
resources/views/livewire/web/components/sections/brand-detail.blade.php
resources/views/livewire/web/components/sections/brand-overview.blade.php
resources/views/livewire/web/components/sections/core-brands.blade.php
resources/views/livewire/web/components/sections/display-feed.blade.php
resources/views/livewire/web/components/sections/immobilien-teaser.blade.php
resources/views/livewire/web/components/sections/marken-cta.blade.php
resources/views/livewire/web/components/sections/marken-growth.blade.php
resources/views/livewire/web/components/sections/marken-network.blade.php
resources/views/livewire/web/components/sections/page-hero.blade.php
resources/views/livewire/web/components/sections/reference-projects.blade.php
resources/views/livewire/web/components/sections/sponsoring.blade.php
resources/views/livewire/web/components/ui/appointment-widget.blade.php
resources/views/web/marken-show.blade.php
resources/views/web/marken.blade.php
resources/views/web/referenzen.blade.php
tests/Feature/Admin/Cms/CmsBrandsIndexTest.php
tests/Feature/AppointmentWidgetTest.php
tests/Feature/BrandPageTest.php
tests/Feature/Cms/CmsBrandTest.php
tests/Feature/DisplayInteriorBrandingTest.php
tests/Feature/DisplayMediaUrlNormalizerTest.php
tests/Feature/DisplayWebFeedTest.php
tests/Feature/FooterTest.php
tests/Feature/HomePageStructureTest.php
tests/Feature/MarkenPageTest.php
tests/Feature/PreviewHostTest.php
tests/Feature/ReferenzenPageTest.php

View file

@ -0,0 +1,982 @@
# Entwicklungsplan: B2in Interior – Neuausrichtung Website & CMS
**Erstellt:** 28.08.2026 · zuletzt aktualisiert 28.08.2026
**Basis:** `B2in_Interior-Konzept.md`, `umsetzungliste.md`, `umsetzung-inhalte.md`
**Vorgänger:** `dev/2026-02-27/b2in-website-umsetzungsplan.md`
**Rahmen:** 37–39 Std. freigegeben (18 + 8 + 4 + 3 + 4–6)
**Status:** Revision 12 – Befunde abgearbeitet, **AP0–AP6 umgesetzt**. Testbereit; offen sind nur
noch Zulieferungen und die Rechtstexte.
**Umgebung:** Dev Container, `php artisan …` (nicht `vendor/bin/sail …`)
> **Verbindliche Schreibweisen:** **Interior** (nicht „Interieur"), **Febrü**, **Rotpunkt Küchen**, **CABINET**.
> Das Konzeptpapier schreibt durchgehend „Interieur" – die Umsetzungsliste überschreibt das. Gilt für Code,
> Texte, Commit-Messages und Datenbank-Inhalte.
---
## 0. Stand & nächste Schritte
**Stand 28.08.2026:** T1 und B1–B13 abgearbeitet, **alle Arbeitspakete AP0–AP6 umgesetzt**, die
Bestandsinhalte auf den Interior-Auftritt umgestellt. `php artisan test --compact`: **826 grün**
(3072 Assertions), Pint sauber, `npm run build:b2in` gebaut.
**Die Seite ist testbereit** – die ausstehenden Zulieferungen trägt Marcel selbst im CMS nach.
| Paket | Stand |
|---|---|
| AP0 Vorbereitung | ✅ |
| AP1 Umstrukturierung & Umtextung | ✅ |
| AP2 Kernmarken-Modul + `/marken` + Galerie | ✅ |
| AP3 Terminanfrage-Widget + WhatsApp-Link | ✅ (Nummer fehlt noch) |
| AP6 Sponsoring | ✅ |
| Bestandsinhalte FAQ / About / Kontakt / Magazin | ✅ |
| AP4 Display-Spiegelung | ✅ |
| AP5 Rubrik-Seite „Referenzen" | ✅ |
### Nächste Schritte
**Vor dem Versand des Testlinks — nichts.** Alle Seiten laden, die Suite ist grün, die Assets sind
gebaut. Was fehlt, ist Material, kein Code.
**Was Marcel selbst nachträgt (alles im CMS, ohne Codeänderung):**
| Was | Wo im Admin |
|---|---|
| Logos und Stimmungsbilder der drei Marken | Kernmarken → Marke bearbeiten |
| Echte Projektfotos statt der BEISPIEL-Bilder | Kernmarken → Bildergalerie |
| Herstellerseiten-URLs | Kernmarken → Herstellerseite |
| Motiv für den Kopf von `/marken` | Inhalte → `marken` → `hero` |
| WhatsApp-Nummer | Inhalte → `global` → `contact` → `whatsapp` |
| Welches Display auf der Website läuft | Inhalte → `referenzen` → `display_feed` → `display_id` |
**Beim Deployment mit erledigen (Abschnitt 8):**
1. **Rechtstexte** – AGB und Datenschutzerklärung beschreiben weiter das alte Geschäft
(„Connecting Design and Property", Dubai/Lissabon, Supply-Chain-Management,
`resources/lang/de/b2in_legal.php` Zeilen 117 und 221). Marcel lässt die Formulierung prüfen,
danach eintragen. Der WhatsApp-Absatz kommt im selben Zug dazu.
2. **OG-Bild** – das Layout verweist auf `img/assets/b2in-og-default.jpg`, die Datei existiert
nicht. Bis dahin zeigt jede geteilte Seite eine Vorschau ohne Bild.
3. **Seeder-Passwort rotieren** (B8) – steht weiter in der Git-Historie.
4. Die zwei Magazin-Beiträge und die Sponsoren-Kurztexte gegenlesen lassen.
**Danach offen, aber nicht Teil dieses Auftrags:** YouTube-Einbindung mit Consent-Gate auf der
Referenzen-Seite (Abschnitt 4). Sinnvoll, sobald es Videos gibt – vorher wäre es ein Consent-Gate
ohne Inhalt.
---
## 1. Ist-Architektur (relevanter Ausschnitt)
### 1.1 Content-Pipeline
```
resources/lang/de/b2in.php (themes.b2in.<section>) ← Quelltexte / Fallback
├─ config/cms_section_map.php section → [group, key]
├─ database/seeders/CmsContentSeeder.php
▼
flux_cms_contents (group, key, type, value:JSON, translatable de/en)
├─ FluxCms\Core\Services\CmsContentService (Cache: flux_cms.content.__all__)
▼
cms_theme_section('hero') → CMS gewinnt, sonst trans('b2in.themes.b2in.hero')
```
Redaktion unter `admin/flux-cms/content` → [`content-index.blade.php`](../../resources/views/livewire/admin/cms/content-index.blade.php)
(Volt, 782 Z.): Group/Key-Editor mit JSON-Repeater-Modal, Medien-Picker, Flux-Editor für HTML.
**Kann Werte ändern, aber keine Sektion neu anlegen.**
### 1.2 Seiten & Sektionen
- Startseite: `routes/web.php` → `resources/views/web/home.blade.php`
- Sektionen = Livewire-Komponenten unter `app/Livewire/Web/Components/Sections/`,
Muster `mount() { $this->content = cms_theme_section('<key>'); }`
- Ist-Reihenfolge: `header → hero → founder-bar → content-section(synergie) → vision-section → ecosystem-core → cta-section → footer`
- Navigation und Ankündigungsleiste liegen **bereits im CMS** (`global.header.navigation`, `global.announcement_bar`)
→ Umbau ist Redaktion, kein Code.
- SEO: `@section('title')` / `@section('meta_description')` je View; OG-Tags leiten sich
in `web-master.blade.php` automatisch daraus ab → **OG kostet keinen Extra-Aufwand** (aber siehe B6).
- Immobilien rendern statisch aus `resources/lang/de/immobilien-azizi.json` – wie gefordert.
### 1.3 Display-CMS
`Display` (Gerät) → `DisplayPlaylist` (draft/published) → `DisplayVersion` („Modul", Enum
`DisplayVersionType`: `video-display` | `b2in` | `offers`) → `DisplayVersionItem`.
[`DisplayPlaylistConfigBuilder`](../../app/Services/DisplayPlaylistConfigBuilder.php) erzeugt das
Player-JSON. Player: `public/_cabinet/display/index.html`, festes 1080×1920-Raster (9:16), per Scale eingepasst.
API vorhanden: `/api/display/{display}/config`, `/api/display/preview/{token}`,
`/api/display/module/{module}/preview` – registriert für Portal-Domain **und** als Fallback für alle Domains.
### 1.4 Sonstiges
- Kontaktformular: `packages/acme/contact-form` – **unterstützt bereits `customFields` + Presets,
Honeypot, Rate-Limit und `SpamDetector`.** Das Terminanfrage-Widget kann darauf aufsetzen.
- Tests: Pest, SQLite in-memory, `php artisan test --compact`.
---
## 2. Gefundene Bugs & Optimierungen
Beim Abgleich der Umsetzungsliste mit dem Code aufgefallen.
**Stand 28.08.2026: alle Befunde behoben, Suite grün (699 Tests, 2715 Assertions).**
| # | Befund | Status |
|---|---|---|
| T1 | Info-Tablet: Leeren per X wird nicht gespeichert | ✅ behoben |
| B1 | CMS-Seeder überschreibt redaktionelle Inhalte | ✅ behoben |
| B2 | Magazin-CMS nicht ans Frontend angeschlossen | ✅ behoben (Variante A: Slug-Routen + 301) |
| B3 | Ankündigungsleiste bleibt nach Umtexten unsichtbar | ✅ behoben |
| B4 | Footer hartkodiert, nicht pflegbar | ✅ behoben |
| B5 | Englische Footer-Labels auf deutscher Seite | ✅ behoben |
| B6 | 19 Seiten erben alte Dubai-Beschreibung | ✅ behoben (Fallback + canonical) |
| B7 | Content-Seeder nicht registriert | ✅ behoben |
| B8 | Klartext-Passwort im Seeder | ✅ behoben |
| B9 | Medien-Picker greift bei leeren Logo-Feldern nicht | ✅ behoben |
| B10 | Alt-Begriffe im Seiteninhalt | ✅ behoben in AP1 |
| B12 | `/service` und `/portfolio` lieferten 500 | ✅ behoben (301) |
| B11 | `/magazin/{unbekannt}` liefert 200 statt 404 | ✅ behoben |
---
### T1 · Info-Tablet: Leeren der Öffnungszeiten wird nicht gespeichert ✅
Gemeldet am 28.08.2026: „Wenn ich den Inhalt weg-X, kommt nicht Geschlossen. Der Wert kommt
wieder, wenn ich in ein anderes Feld klicke. Setze ich Öffnung und Schluss auf X und speichere,
ist Öffnung wieder der alte Wert."
**Drei Ursachen, alle in `cabinet-info-tablet.blade.php` bzw. `CabinetInfoTablet.php`:**
1. **Zwei Submit-Handler auf demselben Formular.** `wire:submit.prevent="save"` **und**
`x-on:submit.prevent="… $wire.save(hours)"` waren gleichzeitig gebunden – `save()` lief pro
Klick zweimal, einmal mit den DOM-Werten und einmal ohne. Welcher Aufruf zuletzt schrieb,
war nicht deterministisch. Das erklärt „speichert nur den letzten".
2. **`wire:model.live` auf den Zeit-Pickern.** Jede Änderung löste einen Roundtrip aus; beim
erneuten Rendern hat Livewire ein zuvor geleertes Feld mit dem unveränderten Serverwert
überschrieben. Das erklärt „der Wert kommt wieder, wenn ich in ein anderes Feld klicke".
3. **Geöffnet/Geschlossen wurde serverseitig gerechnet** und konnte deshalb erst nach einem
Roundtrip umspringen. Das erklärt „es kommt nicht Geschlossen".
**Behoben:** nur noch ein Submit-Pfad (das DOM ist die Quelle der Wahrheit), `.live` entfernt,
die Geöffnet/Geschlossen-Anzeige rechnet clientseitig per Alpine und reagiert damit sofort auf das X.
**Zusätzlich:** `save(array $hours)` hat über `property_exists()` **jede** öffentliche Property
aus dem Client-Payload beschreibbar gemacht – auch `storeStatus` und `contactEmail`. Jetzt greift
eine Allowlist auf die 14 Zeitfelder.
**Tests:** 5 neue Fälle in `tests/Feature/CabinetInfoTabletTest.php` (15 grün) – u. a. mehrere Tage
in einem Submit leeren, nur die Öffnung leeren, Fallback ohne DOM-Payload, Allowlist.
**Noch am Gerät zu prüfen:** die drei Ursachen liegen im Browser-Verhalten; die Tests sichern den
Serverpfad ab. Bitte im Portal einmal gegenprüfen: mehrere Tage leeren → speichern → neu laden.
### B1 · Der CMS-Seeder überschreibt alles, was Marcel pflegt ✅
`database/seeders/CmsContentSeeder.php:76-90` – bestehende Einträge werden bedingungslos
per `$content->update()` mit den Werten aus der Lang-Datei überschrieben. Geschützt sind nur
die zwei Sektionen in `$skipSections`.
**Wirkung:** Jeder erneute Seeder-Lauf – auch der geplante nach der Umtextung – setzt sämtliche
redaktionellen Änderungen zurück. Die Zusage „von Marcel selbst pflegbar" hält so nicht.
**Behoben:** Default ist jetzt „nur anlegen, wenn nicht vorhanden". Überschreiben nur noch über
`$seeder->overwriteExisting = true`. Der Lauf meldet, wie viele Einträge unangetastet blieben, und
leert den CMS-Cache, wenn er etwas geändert hat.
**Tests:** 3 neue Fälle in `tests/Feature/Cms/CmsContentSeederTest.php` – gepflegter Inhalt bleibt,
fehlende Sektionen werden trotzdem angelegt, bewusstes Überschreiben funktioniert.
### B2 · Das Magazin-CMS ist nicht mit dem Frontend verbunden ✅
- Admin `admin/flux-cms/articles` schreibt nach `cms_articles` (Model `CmsArticle`).
- Frontend liest `trans('b2in.articles')`:
`app/Livewire/Web/Components/Sections/MagazinList.php:20` und `MagazinDetail.php:48`.
- `CmsArticle` wird **außerhalb des Admin-Bereichs nirgends verwendet** (verifiziert per Grep).
**Wirkung 1:** Was Marcel im Magazin-CMS bearbeitet, erscheint nicht auf der Website.
**Wirkung 2 (für dieses Projekt entscheidend):** Die Umsetzungsliste sagt für die Kernmarken
„adaptiert vom **Magazin-Muster**". Das Admin-CRUD ist als Vorlage brauchbar, die
Frontend-Anbindung fehlt dort aber – **dieser Fehler darf nicht mitkopiert werden.**
**Bei der Umsetzung aufgefallen – das ist kein reiner Verdrahtungsfehler:**
Die Artikel liegen in beiden Quellen (5 in `cms_articles`, 5 in der Lang-Datei, inhaltsgleich),
**aber unter verschiedenen Schlüsseln**: die Lang-Datei nutzt 1–5, die Datenbank die IDs 8–12.
Die öffentlichen URLs lauten heute `/magazin/1` … `/magazin/5`. Eine Umstellung auf das Model
**ändert damit die öffentlichen URLs** und braucht eine Slug-Route plus Weiterleitungen von den
alten numerischen IDs – sonst brechen bestehende Links und die Google-Indexierung.
**Deshalb bewusst nicht einfach umgestellt.** Entscheidung nötig:
| Variante | Aufwand | Wirkung |
|---|---|---|
| A · Slug-Routen `/magazin/{slug}` + 301 von den alten IDs | ≈3–4 Std. | sauber, CMS wird wirksam, URLs werden sprechend |
| B · Numerische Route beibehalten, Auflösung über die DB-ID | ≈2 Std. | CMS wird wirksam, aber `/magazin/1` … `/magazin/5` werden zu 404 |
| C · Vorerst so lassen | 0 | Magazin funktioniert, das Magazin-CMS bleibt aber wirkungslos |
**Umgesetzt: Variante A.**
- `CmsArticle::forFrontend()` liefert die veröffentlichten Artikel 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.
- Die ID→Slug-Zuordnung lag als private Konstante im `CmsArticleSeeder` und ist jetzt
`CmsArticle::LEGACY_ID_TO_SLUG` – eine Quelle für Seeder, Fallback und Weiterleitung.
- `MagazinList` und `MagazinDetail` lesen aus dem Model statt aus `trans('b2in.articles')`;
die Übersicht verlinkt über `route('magazin.detail')` statt über die numerische ID.
- Neue Route `/magazin/{slug}`. Die alten `/magazin/1` … `/magazin/5` leiten dauerhaft (301)
auf den Slug um; sie stehen per `whereNumber()` **vor** der Slug-Route, sonst würde diese
die Ziffern selbst schlucken. Numerische IDs ohne Zuordnung liefern 404.
- Die Detailseite fragte die Artikel zweimal ab (Artikel + verwandte Artikel) – jetzt gemerkt,
eine Abfrage pro Seitenaufruf.
**Tests:** `tests/Feature/MagazinPageTest.php` auf 13 Fälle erweitert – Slug-Auslieferung,
301 von jeder alten ID, 404 bei unbekanntem Slug, CMS schlägt Lang-Datei, unveröffentlichte
Artikel sind unsichtbar, Fallback bei leerer Tabelle. `LiveSiteReviewTest` prüft für jede alte
ID die Weiterleitung **und** dass das Ziel lädt.
⚠️ **Beim Deployment:** `/magazin/1` … `/magazin/5` sind indexiert. Die 301 fängt sie ab, aber
nach dem Livegang bitte einmal in der Search Console gegenprüfen. Externe Links, die noch auf
die alten URLs zeigen, bleiben funktionsfähig.
**Nebenbefund, nicht angefasst:** `MagazinDetail::$relatedArticles` wird berechnet, aber von
keiner View gerendert – entweder die Sektion „verwandte Artikel" nachziehen oder die Property
entfernen. Deine Entscheidung.
### B3 · Ankündigungsleiste: alte Wegklicker sehen die neue Leiste nie ✅
`announcement-bar.blade.php:2` merkt sich das Wegklicken in
`localStorage['announcement_dismissed_<id>']`. Bleibt die `id` beim Umtexten gleich, bleibt die
Leiste für alle Wiederkehrer unsichtbar.
**Behoben:** Der Dismiss-Schlüssel wird jetzt aus Inhalt **und** `id` abgeleitet
(`AnnouncementBar::getDismissKeyProperty()`). Wird die Leiste im CMS neu belegt, sehen auch
Wiederkehrer die neue Meldung – ohne dass jemand daran denken muss, die `id` mitzuändern.
**Tests:** 2 neue Fälle in `tests/Feature/AnnouncementBarTest.php`.
### B4 · Der Footer ist vollständig hartkodiert ✅
`footer.blade.php` – Claim „Connecting Design and Property" steht in Zeile 15 im Markup,
`Footer.php` lädt überhaupt keinen Content. Es gibt keine `footer`-Sektion in `cms_section_map.php`.
**Wirkung:** Sowohl „Footer-Claim ersetzen" als auch „FAQ in den Footer" aus der Umsetzungsliste
erfordern einen kleinen Umbau; Marcel kann den Claim danach ohne CMS-Anbindung weiterhin nicht ändern.
**Behoben:** Neue CMS-Sektion `global.footer` (Claim, Byline, Linkliste) in `cms_section_map.php`
und in beiden Lang-Dateien. `Footer.php` lädt sie über `cms_theme_section('footer')`, die View
rendert Claim und Links daraus und fällt ohne CMS-Eintrag auf sinnvolle Defaults zurück.
Der Claim lautet jetzt „Premium-Einrichtung. Persönlich gemacht.", FAQ ist als erster Link drin.
**Tests:** `tests/Feature/FooterTest.php` (4 Fälle).
### B5 · Englische Footer-Labels auf der deutschen Seite ✅
„Privacy Policy", „Terms of Service", „Cookie Policy" waren hartkodiert englisch, während
„Impressum" korrekt über `__('ui.legal_notice')` lief.
**Behoben:** neue Keys `privacy_policy`, `terms_of_service`, `cookie_policy`, `faq` in
`resources/lang/{de,en}/ui.php`; die deutsche Seite zeigt jetzt Datenschutz / AGB /
Cookie-Richtlinie / Häufige Fragen / Impressum.
### B6 · 19 Seiten tragen die alte Dubai-Beschreibung ✅
`web-master.blade.php:11` und `:18` haben als Fallback fest verdrahtet:
„B2in – Connecting Design and Property. Exklusive Immobilien in Dubai & europäisches Einrichtungsnetzwerk."
19 Views unter `resources/views/web/` setzen **kein** `@section('meta_description')` und erben damit
diesen Text – darunter die Live-Seite `magazin-detail.blade.php`.
**Behoben:** Der Fallback in `web-master.blade.php` (Description **und** OG-Description) trägt
jetzt den Interior-Text aus `umsetzung-inhalte.md` §0. Zusätzlich `<link rel="canonical">` ergänzt,
das fehlte komplett.
**Offen als B10:** Der Alt-Begriff-Bestand im Seiteninhalt („Supply-Chain", „Local-for-Local",
„Ökosystem", „Investment", „Connecting Design") – **90 Treffer in 14 Dateien**, u. a. der
Hero-`card_text`. Das ist Content-Arbeit mit den freigegebenen Texten und gehört in AP1, nicht
in einen Bugfix.
### B7 · Content-Seeder sind nirgends registriert ✅
`DatabaseSeeder::run()` ruft nur `TestDisplaySeeder`. `CmsContentSeeder`, `CmsArticleSeeder`,
`CmsProjectSeeder`, `CmsLegalSeeder`, `NetzwerkPageCmsSeeder` müssen von Hand aufgerufen werden;
ein frisch aufgesetztes System hat keine CMS-Inhalte.
**Behoben:** `CmsContentSeeder`, `CmsLegalSeeder`, `NetzwerkPageCmsSeeder`, `CmsArticleSeeder`
und `CmsProjectSeeder` sind in `DatabaseSeeder::run()` registriert – nach dem B1-Fix gefahrlos,
weil bestehende Inhalte unangetastet bleiben.
### B8 · Klartext-Passwort im Seeder ✅
`database/seeders/DatabaseSeeder.php` enthielt ein echtes Passwort im Repo.
**Behoben:** Zugangsdaten kommen jetzt aus `config/seeding.php` bzw. `SEED_ADMIN_*` in der `.env`;
`.env.example` ist ergänzt.
⚠️ **Das alte Passwort steht weiterhin in der Git-Historie – bitte rotieren, falls es irgendwo
sonst benutzt wird.**
### B9 · Medien-Picker greift bei leeren Logo-Feldern nicht ✅
`content-index.blade.php` → `_cmsFieldLooksLikeImage()` erkennt Bildfelder an einer festen
Feldnamen-Liste **oder** an der Dateiendung **im Wert**. `logo` steht nicht in der Liste, und ein
noch leeres Feld hat keinen Wert → wird als reines Textfeld gerendert.
**Wirkung:** Marcel kann bei einer neu angelegten Marke bzw. bei den Sponsoring-Logos das Bild
nicht per Picker setzen, sondern müsste den Dateinamen abtippen. Betrifft AP2 und AP6 direkt.
**Behoben:** `logo` in die Feldnamen-Liste und `_logo$` in die Suffix-Regex aufgenommen. Zusätzlich
prüft `_cmsFieldLooksLikeImage()` den Feldnamen jetzt **vor** dem Wert – ein noch leeres Bildfeld
wird dadurch als Bild erkannt und bekommt den Picker. (`logo_width` bleibt korrekt ein Textfeld.)
### B11 · `/magazin/{unbekannte-id}` liefert 200 statt 404 ✅
`MagazinDetail::loadArticle()` fiel bei unbekannten IDs still auf Artikel 1 zurück
(`$articles[$this->articleId] ?? $articles[1]`). `/magazin/999` lieferte damit Status 200 mit dem
Inhalt von Artikel 1 – beliebig viele URLs mit identischem Inhalt, schlecht für die Indexierung,
und bei fehlendem Artikel 1 ein Fehler statt einer 404.
**Behoben:** `abort_unless(isset($articles[$this->articleId]), 404)`.
**Tests:** 2 neue Fälle in `tests/Feature/MagazinPageTest.php`.
---
## 3. Arbeitspakete
Gliederung folgt der Umsetzungsliste. Reihenfolge nach dem Vorschlag aus `umsetzung-inhalte.md`.
### AP0 · Vorbereitung (im Rahmen von AP1)
Branch `feature/b2in-interior`, DB-Backup, Backup der Startseite (Muster
`dev/2026-02-27/backup-before-relaunch/`), `php artisan test --compact` als Referenzstand.
### AP1 · Website-Umstrukturierung & Umtextung — ✅ umgesetzt
**Global**
- Meta-Title und Description auf Interior umgestellt; fünf Live-Seiten hatten gar keine eigene
Description und erbten den Fallback – jetzt gesetzt (`magazin-detail`, `impressum`, `privacy`,
`terms`, `cookie-policy`). OG-Tags leiten sich automatisch ab.
- Navigation: `Home · Marken · Magazin · Immobilien · Über B2in`. „Netzwerk" ist raus (Seite bleibt
erreichbar), FAQ steht im Footer. „Referenzen" kommt mit AP5 dazu.
- Footer-Claim „Premium-Einrichtung. Persönlich gemacht." (siehe B4).
**Startseite** – neue Reihenfolge in `resources/views/web/home.blade.php`:
`header → hero → marcel-badge → weiche → kernmarken → über-marcel → sponsoring → immobilien-teaser → abschluss-cta → footer`
- Ankündigungsleiste neu belegt (Rotpunkt Küchen), inkl. neuer `id`.
- Hero: neue Headline, Subline, zwei Buttons, drei Checkmarks, Bild-Badge „Einrichtung, die bleibt."
- Marcel-Badge umgetextet.
- **Neu:** `Sections/AudienceSwitch` (Weiche Privat/Geschäft) – ersetzt „Zwei Welten. Ein Netzwerk."
- **Neu:** `Sections/CoreBrands` mit CABINET, Rotpunkt Küchen, Febrü (Anker `#marken`).
Febrü bewusst mit Home-Office zuerst. **Datenquelle ist vorerst das CMS-Sektionsfeld –
in AP2 wird sie durch das Model `CmsBrand` samt Detailseiten ersetzt.** Das Markup bleibt.
- **Neu:** `Sections/Sponsoring` (AP6) und `Sections/ImmobilienTeaser`.
- „Ein Ökosystem, drei Säulen" ist von der Startseite genommen, die Komponente bleibt als Archiv.
**Sponsoring-Logos:** je Verein eine Datei gewählt, auf max. 400 px Breite herunterskaliert
(326 KB → 130 KB) und nach `public/img/assets/sponsoring/` übernommen. Die drei Seitenverhältnisse
sind sehr unterschiedlich (500×262, 689×689, 2443×741), deshalb eine feste Box je Logo.
`tc-bad-waldliesborn-horn` liegt nur als JPG vor und hat damit keinen transparenten Hintergrund.
**Durchgang Alt-Begriffe:** auf allen Live-Seiten bereinigt – Startseite, `/about`, `/contact`,
`/immobilien` sind sauber; `/netzwerk` und `/faq` ebenfalls. Nicht angefasst: die
**Magazin-Artikel**, die inhaltlich über Supply-Chain und Investments handeln – das sind
redaktionelle Beiträge, keine Seitentexte.
**Tests:** `tests/Feature/HomePageStructureTest.php` (11 Fälle: Reihenfolge, Hero-Buttons und
-Checkmarks, Kernmarken samt Anker, Febrü-Gewichtung, Navigation, SEO, Sponsoring-Logos);
`AnnouncementBarTest` und `LiveSiteReviewTest` auf die neuen Inhalte gezogen.
---
#### ⚠️ Wichtig fürs Deployment: der Relaunch-Seeder
`cms_theme_section()` bevorzugt das CMS gegenüber der Lang-Datei. 14 der neu getexteten Sektionen
lagen bereits mit den **alten** Texten im CMS und hätten die neuen Inhalte überdeckt – der reguläre
`CmsContentSeeder` fasst bestehende Einträge seit B1 bewusst nicht mehr an.
Deshalb gibt es `InteriorRelaunchCmsSeeder`: er überschreibt **genau die 19 in AP1 geänderten
Sektionen** aus den Lang-Dateien und lässt alles andere in Ruhe.
```
php artisan db:seed --class=InteriorRelaunchCmsSeeder
```
Auf der lokalen Datenbank bereits gelaufen (5 angelegt, 14 aktualisiert). **Auf dem Live-System
vor dem Lauf ein DB-Backup ziehen** – er überschreibt in diesen Sektionen auch redaktionelle
Änderungen. Danach `npm run build:web`.
---
#### Nebenbefund B12 · `/service` und `/portfolio` lieferten 500 ✅
Beide Seiten sind öffentlich erreichbar, aber ihre Content-Sektionen (`hero_image`,
`content_section_left/right`, `portfolio`) existieren in keiner Lang-Datei mehr – vermutlich seit
dem Februar-Relaunch. Sie warfen seitdem einen 500er; da sie in keinem Test vorkamen, ist es
niemandem aufgefallen. **Verifiziert: älter als AP1, keine Regression.**
**Behoben** wie bei den anderen stillgelegten Seiten per 301 (`/service` → `/netzwerk`,
`/portfolio` → `/immobilien`); die Views bleiben als Archiv liegen. Beide Ziele sind eine
Zeile in `routes/web.php`, falls Du woandershin umleiten möchtest.
---
#### Was in AP1 offen bleibt
| Punkt | Grund |
|---|---|
| Hero-Bildmotiv, Kachelbilder, OG-Image | Zulieferung offen, Bestandsbild bleibt |
| Navigationspunkt „Referenzen" | kommt mit der Seite in AP5 |
| Teaser „Aktuelles aus unseren Showrooms" | kommt mit AP5, sonst zeigt er ins Leere |
---
#### Logo mit Zusatz „Interior" ✅ (Zulieferung eingetroffen)
Marcel hat die Dateien geliefert (`dev/2026-08-28/logos/`) – die typografische Eigenlösung
entfällt damit. Beide Varianten sind identisch aufgebaut und unterscheiden sich nur im Füllwert:
positiv `#314052`, negativ `#FFFFFF`; der blaue Akzent `#2C9FDA` bleibt in beiden. Trotz „CMYK"
im Dateinamen enthalten die SVGs RGB-Hexwerte, die Schrift liegt in Pfaden.
**Zuschnitt war nötig:** Die Lieferung lag auf einem A4-Artboard (595,3 × 419,5) – gemessen
**74 % leerer Weißraum**. Ungeschnitten hätte das Logo bei gleicher CSS-Höhe rund halb so groß
gerendert wie das bisherige. Der Inhaltsrahmen wurde durch Rendern ermittelt und das viewBox auf
`153 96 289.3 227.5` gezogen (98 % Füllgrad, gleichmäßige Randluft, Verhältnis 1,27:1).
Abgelegt als `public/img/logos/b2in-interior-logo-{positive,negative}.{svg,png}`; die alten
`b2in-logo-*`-Dateien bleiben als Archiv liegen.
**Angepasst:** `ThemeHelper` (b2in → neue Dateien) · Header `h-10` → `h-12` und Footer `h-14` →
`h-16`, weil das neue Lockup gestapelt ist und die Wortmarke bei gleicher Höhe kleiner wirkt ·
Footer- und Mail-`width`/`height`-Attribute auf das neue Verhältnis korrigiert (standen auf
2,14:1 bzw. 3,5:1 und passten schon vorher nicht) · Mail-Header nutzt das neue PNG.
**Display ebenfalls umgestellt** (Marcel möchte es laut Mail einheitlich):
- Beide Varianten liegen jetzt auch unter `public/_cabinet/assets/`.
- `DisplayModuleSettings`: Default-Logo und Claim getauscht. Der Claim stand dort noch auf
„Connecting Design & Property".
- **Variante nach Theme:** Das Standard-Theme ist `dark`, dort liegt das Logo auf einem dunklen
Scrim – bislang stand da die **dunkle** Variante (deshalb der `drop-shadow` im Player). Jetzt
dunkles Theme → negativ/weiß, helles Theme → positiv.
- Logo-Höhe im Player von `3.5vh` auf `4.6vh`, weil das neue Lockup gestapelt ist.
- `DisplayInteriorBrandingSeeder` vereinheitlicht die bestehenden Module. Vorher liefen dort
**vier verschiedene Logo-Quellen** nebeneinander (zwei Dateien aus der Medienbibliothek, ein
relativer und ein absoluter Asset-Pfad) und zwei Module trugen den Claim „Hello".
---
#### B13 · Live-Displays zeigten auf eine lokale Entwicklungsdomain ⛔→✅
Beim Vereinheitlichen aufgefallen: Medien-URLs lagen als **absolute URLs mit fest verdrahtetem
Host** in den Moduleinstellungen und Item-Inhalten – teils `b2in.eu`, teils **`b2in.test`**.
Der Player gibt absolute URLs unverändert zurück, ein auf `b2in.test` verweisendes Medium kann auf
dem Live-Display also nicht laden.
**Betroffen waren vier veröffentlichte Playlists:** „Display 4 – Innen", „Kundenstopper",
„Display 2" und „Test-Display" – unter anderem über das Modul „Cabinet-Influencerin", das auf
drei der vier Displays läuft.
**Behoben:** `DisplayMediaUrlNormalizerSeeder` schreibt Schema + Host auf einen reinen Pfad um.
Relative Pfade löst der Player über `resolveDisplayUrl()` gegen die Portal-Basis auf und
funktionieren damit auf Test- **und** Live-System. 23 Verweise umgeschrieben, 0 verblieben.
Ziel-Links (Footer-URL, QR-Ziel wie `cabinet-bielefeld.de`) bleiben bewusst absolut – der Filter
greift nur bei einem B2in-Host **und** einem Pfad unterhalb von `/storage/`. Nachgeprüft: die
fünf Ziel-Links stehen unverändert.
**Tests:** `DisplayInteriorBrandingTest` (6) und `DisplayMediaUrlNormalizerTest` (6).
⚠️ **Wichtig:** Ob die betroffenen Mediendateien auf dem Live-Server überhaupt vorhanden sind,
kann ich von hier nicht prüfen. Wurden sie nur lokal hochgeladen, fehlen sie dort weiterhin –
der relative Pfad ist dann trotzdem richtig, aber die Datei muss nachgezogen werden.
Nach dem Deployment bitte die vier Displays einmal sichten.
**Deployment-Reihenfolge:**
```
php artisan db:seed --class=InteriorRelaunchCmsSeeder
php artisan db:seed --class=DisplayInteriorBrandingSeeder
php artisan db:seed --class=DisplayMediaUrlNormalizerSeeder
npm run build:web
```
### AP2 · Kernmarken-Modul im CMS — ✅ umgesetzt
**Model + Migration:** `CmsBrand` / Tabelle `cms_brands`.
⚠️ Bewusst **nicht** `Brand` – das Model existiert bereits im Produkt-/Partner-Kontext
(`Partner::brand()`, `Product::brand()`) und bleibt unangetastet. Ein Test hält das fest.
Felder: `slug`, `name`, `tagline`, `teaser`, `description` (HTML), `logo`, `image`, `services`,
`gallery`, `website_url`, `showroom`, `is_published`, `order`.
Übersetzt werden `name`, `tagline`, `teaser`, `description`.
**Die Lehre aus B2 ist eingebaut:** Das Frontend liest direkt aus dem Model, nicht aus einer
Lang-Datei. `CmsBrand::forFrontend()` liefert die veröffentlichten Marken nach Slug indiziert;
solange keine Marke gepflegt ist, greift die Lang-Sektion `core_brands` als Fallback. Die
Fallback-Einträge tragen `has_detail_page = false`, damit die Kachel dann nicht auf eine
Detailseite verlinkt, die es noch nicht gibt.
**Admin:** `admin/flux-cms/brands` → `brands-index.blade.php` (Volt), aufgebaut nach dem
Magazin-Muster. Medien über den bestehenden `MediaPicker` (Logo, Stimmungsbild, Galerie), die
Einleitung über den Flux-Editor mit `CmsFluxEditorHtmlTransformer`. Slug wird auf Format und
Eindeutigkeit geprüft. Eigene Karte im CMS-Dashboard.
**Frontend:**
- `Sections/CoreBrands` rendert die Kacheln aus dem Model (Anker `#marken`).
- Neue Route `/marken/{slug}` mit `Sections/BrandDetail` und der Vorlage-Struktur aus
`umsetzung-inhalte.md` §5: Header (Logo + Claim + Stimmungsbild) → Einleitung → Leistungen →
Galerie → CTA, dazu eine Leiste mit den übrigen Marken.
- Eigener Title und eigene Description je Marke.
- Unveröffentlichte Marken liefern 404 und verschwinden von der Startseite.
**Seeder:** `CmsBrandSeeder` legt CABINET, Rotpunkt Küchen und Febrü mit den Texten aus
`umsetzung-inhalte.md` §5 sowie je vier Leistungen an – Febrü mit Home-Office zuerst.
Bestehende Marken werden **nicht** überschrieben, Marcel pflegt sie im Admin.
**Tests:** `Cms/CmsBrandTest` (8), `BrandPageTest` (11), `Admin/Cms/CmsBrandsIndexTest` (11) –
darunter die Gegenprobe zu B2: eine im CMS neu angelegte Marke erscheint ohne Codeänderung auf
der Startseite **und** bekommt ihre Detailseite.
#### Nachtrag: eigene Unterseite `/marken`
Auf Wunsch nach der Sichtung – Grundlage `dev/2026-08-28/unterseite_marke.md`. Die Startseite
bleibt wie sie ist, die Kernmarken-Sektion dort ist jetzt ein **Teaser**.
- **Neue Seite `/marken`** mit dem Aufbau aus der Spec: Seitenkopf mit Breadcrumb (Home → Marken)
→ Kernmarken → Partnernetzwerk → „Markenwelt wächst" → Abschluss-CTA. Eigener Title und eigene
Description.
- **Zwei Textebenen je Marke.** Dafür kamen zwei Felder dazu (`summary`, `highlights`):
| Feld | Wo |
|---|---|
| `teaser` | Kachel auf der Startseite (gekürzt) |
| `summary` | Text auf `/marken` (zwei Absätze) |
| `highlights` | die drei Merkmale auf `/marken` |
| `description` | Einleitung der Detailseite |
| `services` | Leistungen der Detailseite |
- **Startseite:** Kacheln verlinken direkt auf die Detailseite, darunter der Button
„Alle Marken ansehen" → `/marken`.
- **Navigation:** „Marken" zeigt auf `/marken` statt auf den Anker `/#marken`; der Hero-Button
ebenfalls. Der Anker `#marken` bleibt für Direktsprünge erhalten.
- **Slug korrigiert:** Die Spec nennt `/marken/rotpunkt`, geseedet war `rotpunkt-kuechen`.
Angepasst, solange nichts live ist – später wäre das ein Redirect gewesen.
- **`/netzwerk` stillgelegt.** Der Inhalt geht im Abschnitt „Mehr als Marken: unser Netzwerk" auf
`/marken` auf. `/netzwerk`, `/interior`, `/partner` und `/service` leiten jetzt **direkt** auf
`/marken` – vorher liefen `/interior` und `/partner` über `/netzwerk`, also eine Redirect-Kette.
Die View bleibt als Archiv liegen.
- **WhatsApp-Button:** erscheint nur, wenn im CMS unter `global.contact.whatsapp` eine Nummer
hinterlegt ist (`whatsapp_link()`). Solange die Nummer nicht bestätigt ist, zeigt der CTA nur
„Termin anfragen" – kein Button auf eine erfundene Nummer.
**Tests:** `MarkenPageTest` (9), `InteriorPageTest` neu geschnitten (4), dazu zwei Fälle in
`Admin/Cms/CmsBrandsIndexTest` – darunter die Gegenprobe, dass eine im Admin angelegte Marke
mitsamt Text und Merkmalen auf `/marken` erscheint.
> **Notiz für spätere Reihenfolge-Tests:** `wire:snapshot` serialisiert die Komponentendaten als
> JSON ins Attribut und escaped Umlaute zu `\uXXXX`. Ein `strpos`-Vergleich auf dem rohen HTML
> misst dann den Snapshot statt des sichtbaren Textes. In `MarkenPageTest` werden die Snapshots
> vorher entfernt.
**Offen:** Logos, Stimmungsbilder und Galerien fehlen als Zulieferung – Kacheln und Detailseiten
zeigen so lange einen Platzhalter. Der WhatsApp-Button im CTA-Block kommt mit AP3, sobald die
Nummer bestätigt ist.
---
#### Nachtrag 2: Galerie je Marke – realisierte Projekte und Inspirationen getrennt
Aus Marcels Mail: „Der Bereich Referenzen ist mir wichtig … Entscheidend ist für mich, dass dieser
Bereich langfristig mitwachsen und von mir selbst gepflegt werden kann. Für den Start können wir
ergänzend mit hochwertigen Visualisierungen und KI-unterstützten Darstellungen arbeiten. Dabei
sollte allerdings klar zwischen tatsächlich realisierten Referenzen und Entwürfen beziehungsweise
Inspirationen unterschieden werden."
Umgesetzt als Galerie **auf der Detailseite jeder Kernmarke**, mit zwei Rubriken.
- **Datenform.** Das bestehende JSON-Feld `cms_brands.gallery` bekommt je Eintrag
`image`, `category` (`project` | `inspiration`) und `caption`. **Keine Migration nötig.**
- **Sicherheitsnetz gegen die schädlichere Verwechslung:** `CmsBrand::normalizeGallery()` stuft
jeden Eintrag ohne oder mit unbekannter Kategorie als **Inspiration** ein – nie als Projekt.
Ein Entwurf, der versehentlich als Referenz durchgeht, wäre der teurere Fehler. Die
Normalisierung greift beim Laden, beim Speichern und beim Ausspielen.
- **Frontend** (`brand-detail.blade.php`): Abschnitt „Referenzen und Inspirationen" mit Umschalter,
sobald beide Rubriken gefüllt sind; ist nur eine gefüllt, steht sie mit eigener Überschrift da.
Jede Kachel trägt ein sichtbares Label („Realisiertes Projekt" / „Visualisierung"), über den
Inspirationen zusätzlich der Satz „Diese Darstellungen sind Entwürfe und Visualisierungen –
keine ausgeführten Projekte." Lightbox mit Pfeiltasten und Esc, Muster übernommen aus
`immobilien-show.blade.php`.
- **Admin** (`brands-index.blade.php`): zwei getrennte Buttons „+ Projekt" und „+ Inspiration", je
Eintrag ein Auswahlfeld und eine Bildunterschrift, dazu ein Hinweis, was in welche Rubrik gehört.
Die Kategorie wird beim Anlegen entschieden, nicht per stillem Vorgabewert.
- **Abgrenzung zu AP5:** AP5 bleibt die Rubrik-Seite „Referenzen" mit dem zweiten Display. Die
markenbezogene Galerie hier ist die pflegbare Grundlage dafür und ersetzt AP5 nicht.
**Tests:** 5 Fälle in `BrandPageTest` (keine Galerie → kein Abschnitt; beide Rubriken beschriftet;
kein Umschalter bei nur einer Rubrik; Eintrag ohne Kategorie erscheint als Visualisierung; Hinweis
sichtbar), 5 in `Cms/CmsBrandTest` (Normalisierung, Gruppierung), 4 in
`Admin/Cms/CmsBrandsIndexTest` (Anlegen je Rubrik, Speichern, unbekannte Kategorie, Löschen).
**Offen:** Bildmaterial. Marcel setzt Projektfotos und Visualisierungen selbst ein.
---
### AP3 · Terminanfrage-Widget + WhatsApp — ✅ umgesetzt
**Korrektur gegenüber Revision 1:** kein Inline-Abschnitt, sondern ein **schwebendes Element
unten rechts auf allen Seiten** → gehört ins Layout `web-master.blade.php`, nicht in `home.blade.php`.
- Aufsetzen auf `Acme\ContactForm\Livewire\ContactForm` per `customFields` – Honeypot,
Rate-Limit und `SpamDetector` kommen damit geschenkt.
- Felder: Name*, E-Mail*, Telefon, Anliegen/Marke (Auswahl), Wunschtermin, Nachricht.
- **Markenauswahl speist sich aus `CmsBrand`** → Abhängigkeit zu AP2 (Reihenfolge einhalten),
plus feste Einträge „Projektausstattung" und „Sonstiges".
- WhatsApp-Button daneben, Nummer als CMS-Wert (`global.contact.whatsapp`), Link `https://wa.me/<nummer>`.
- Cookie-Consent beachten (`packages/acme/CookieConsent`).
**Tests:** `AppointmentWidgetTest` (17 Fälle: Einbindung auf allen Seiten, Markenauswahl aus
`CmsBrand`, Validierung inkl. Wunschtermin in der Vergangenheit, Übergabe an den
`ContactFormService`, Honeypot, WhatsApp sichtbar/unsichtbar); `ContactFormTest` bleibt grün.
#### Umsetzungsnotizen
- **Abweichung vom Plan:** nicht auf `Acme\ContactForm\Livewire\ContactForm` per `customFields`
aufgesetzt. Dessen mitgelieferte View bringt ein eigenes, blaues Layout mit, das nicht zum
Auftritt passt. Stattdessen eine eigene Komponente wie `ContactForm` und `ImmobilienContactForm`
der Seite, die `ContactFormService` und `SpamDetector` aus dem Paket nutzt – Honeypot,
Rate-Limit und Spam-Erkennung sind damit trotzdem drin.
- **Platzierung:** `web-master.blade.php`, unten rechts, `z-40`. Der Cookie-Button des
Consent-Managers sitzt unten links (ebenfalls `z-40`), sein Dialog auf `z-50` – die Einwilligung
bleibt also immer erreichbar und wird nie verdeckt.
- **Kein eigener Consent nötig:** Das Widget setzt nichts im Browser, der WhatsApp-Link ist ein
ausgehender Link ohne Einbettung.
- Die `[x-cloak]`-Regel liegt jetzt einmal zentral im Layout – die Seiten brachten sie bisher nur
teilweise selbst mit, das Widget braucht sie überall.
- Felder: Name*, E-Mail*, Telefon, Anliegen (Auswahl), Wunschtermin, Nachricht, Einwilligung*.
Der Wunschtermin ist auf heute oder später begrenzt, im Browser über `min` und serverseitig
über `after_or_equal:today`.
- Betreff der Mail: `Terminanfrage B2in Interior: <Anliegen>`, Log-Kontext `appointment-widget` –
so lassen sich Widget-Anfragen von den übrigen Formularen unterscheiden. Die Seite, von der die
Anfrage kam, steht als `source` in der Mail.
- **WhatsApp** erscheint weiterhin nur, wenn unter `global.contact.whatsapp` eine Nummer im CMS
steht. Ohne Nummer zeigt das Widget allein den Termin-Button.
#### WhatsApp: was technisch geht und was nicht
Eine echte WhatsApp-Anbindung (Nachrichten empfangen, im CMS beantworten, Verlauf speichern)
setzt die WhatsApp Business Platform voraus: Meta-Verifizierung des Unternehmens, ein Anbieter
für den API-Zugang, laufende Kosten je Konversation. Das ist nicht Teil dieses Auftrags.
Umgesetzt ist die dafür übliche Alternative, ein **Klick-zum-Chat-Link** (`wa.me`):
- Der Button sitzt im Terminanfrage-Panel unter dem Absenden-Button.
- Auf dem Handy öffnet die installierte WhatsApp-App, am Rechner WhatsApp Web bzw. die
Desktop-App. Ist WhatsApp nirgends vorhanden, landet man auf der Hinweisseite von `wa.me`.
- Der Nachrichtentext ist **vorbereitet** und wird im Browser aus dem zusammengesetzt, was im
Formular schon steht (Anliegen, Wunschtermin, Name). Der Einstiegssatz ist im CMS pflegbar
(`global.appointment_widget.whatsapp_text`).
- **Abgeschickt wird die Nachricht vom Besucher selbst** – das steht auch so unter dem Button.
Was das bedeutet, bewusst festgehalten:
| | |
|---|---|
| Wo landet die Nachricht | in Marcels WhatsApp, nicht im CMS und nicht per Mail |
| Was sehen wir davon | nichts – kein Eintrag, keine Statistik, kein Spamschutz |
| Voraussetzung | eine Mobilnummer, die WhatsApp empfangen kann (sinnvoll: WhatsApp Business) |
| Datenschutz | ausgehender Link zu Meta. In die Datenschutzerklärung gehört ein Absatz dazu; ein Consent-Banner braucht es nicht, weil nichts eingebettet wird und nichts gesetzt wird |
Wer eine Anfrage im System dokumentiert haben will, nutzt weiterhin das Formular – der
WhatsApp-Weg ist die schnelle, aber unprotokollierte Abkürzung.
### Nachtrag 3 · Bestandsinhalte auf den Interior-Auftritt umgestellt ✅
Nach der Sichtung: Drei verlinkte Seiten erzählten noch die Dubai-/Plattform-Geschichte.
`/immobilien` bleibt bewusst unverändert – die Seite ist der „Außerdem"-Bereich.
**`/faq`** – die beiden Rubriken „Fokus: Immobilien-Investoren (B2C)" und „Fokus: B2B-Partner"
sind ersetzt durch **„Für Ihr Zuhause"** und **„Für Projekte und Objekte"** (je vier Fragen:
Einstieg mit einem einzelnen Möbelstück, Kosten der Maßanfertigung, Montage, Showroom –
bzw. Projektgröße, Termintreue, Architekten, Netzwerk). Von den allgemeinen Fragen waren zwei
noch Dubai-lastig, die sind jetzt Beratungsablauf und Verlässlichkeit. Die englische Fassung war
noch komplett auf dem alten Stand und ist mitgezogen.
**`/about`** – das Mission-Zitat sprach von „digitalen Werkzeugen" und „Brücken zwischen Märkten".
Neu: wenige ausgewählte Hersteller, Fachbetriebe aus der Region, ein Ansprechpartner. Die sechs
Werte waren Plattform-Vokabular (Innovation, Konnektivität, Design-Exzellenz) und heißen jetzt
Beratung statt Verkauf, Maßarbeit, Ausgewählte Marken, Regional verwurzelt, Ein Ansprechpartner,
Verlässlichkeit. In der Zeitleiste sind „Die Idee", „Das Fundament" und „Die Vision" umgeschrieben.
**`/contact`** – die Betreffliste beginnt jetzt mit Beratungstermin, Maßanfertigung und
Küchenplanung; „Internationale Immobilien" rutscht nach hinten, bleibt aber erhalten.
**`/magazin`** – zwei neue Beiträge, die **an erster Stelle** stehen:
`massanfertigung-warum-der-millimeter-zaehlt` und `kueche-planen-vom-wunsch-zum-grundriss`.
Die vier Dubai-Beiträge bleiben, sie gehören zum Immobilienbereich.
> ⚠️ Die beiden Beiträge sind **Platzhalter**. Bewusst ohne Referenzen, Zahlen oder Namen
> geschrieben, damit nichts behauptet wird, was nicht belegt ist – vor dem Livegang von Marcel
> gegenlesen lassen. Als Bilder dienen vorhandene Motive aus dem Altbestand.
**Zwei technische Punkte dabei:**
- Neue Lang-Artikel bringen ihren Slug jetzt selbst mit (`'slug' => …`, aufgelöst über
`CmsArticle::slugForLangArticle()`). `LEGACY_ID_TO_SLUG` bleibt damit das, was der Name sagt:
die Karte für die alten `/magazin/{id}`-URLs. `/magazin/6` ist korrekt ein 404, weil es diese
URL nie gab.
- `CmsArticleSeeder` sortiert nicht mehr per `ksort`. Die Reihenfolge in der Lang-Datei bestimmt
jetzt die Reihenfolge im Magazin – im CMS über `order`, im Lang-Fallback über die
Array-Reihenfolge. Vorher konnten beide Wege auseinanderlaufen.
**Tests:** vier Fälle in `MagazinPageTest` (Reihenfolge, beide Slugs, kein erfundener Legacy-Link,
Slug-Auflösung), drei in `LiveSiteReviewTest` (FAQ ohne Dubai mit neuen Rubriken, About mit den
neuen Werten, Kontakt mit den neuen Betreffen).
**Nachkontrolle:** Gegen die Live-Seiten geprüft, nicht nur gegen die Sprachdatei – dabei fiel in
der FAQ eine dritte Frage auf, die noch vom „Local for Local"-Netzwerk sprach. Ebenfalls korrigiert.
**Noch offen auf diesen Seiten:** eigene Beitragsbilder für die zwei neuen Artikel; die vier
Dubai-Beiträge bleiben inhaltlich unverändert stehen.
---
### AP4 · Display-Spiegelung auf der Website — ✅ umgesetzt
**Korrektur gegenüber Revision 1:** Revision 1 empfahl, die Slides nativ zu rendern. Bei 3 Std.
ist das nicht zu halten, und die Umsetzungsliste schreibt „nutzt die vorhandene Display-/Player-API
als Datenquelle". → **Einbettung des vorhandenen Players im 9:16-Container.**
- Sektion `Sections/DisplayFeed` mit `aspect-ratio: 9/16`, darin der Player.
- **Vorher zu klären:** Die `/preview/…`-Routen sind token- bzw. modulbasiert und für den internen
Gebrauch gedacht. Für die öffentliche Domain braucht es eine schmale, read-only Route
(z. B. `/embed/display/{display}`), die keine Preview-Tokens nach außen gibt.
- Keine doppelte Pflege: Marcel pflegt weiterhin nur im Display-CMS.
#### Umsetzung
- **Route `GET /embed/display/{display}`** (`DisplayEmbedController`), bewusst getrennt von den
Preview-Routen: Die arbeiten mit `preview_token` und zeigen den **Entwurf**. Nach außen darf
weder ein Token noch ein unveröffentlichter Stand gelangen. Die Embed-Route kennt nur die
numerische ID, liefert 404 bei inaktivem Display, bei reinem Entwurf und bei leerer Playlist,
und setzt `X-Robots-Tag: noindex`.
- **Kein Proxy, keine CORS-Frage:** Der Player liest die ID aus dem Pfad (`/display/{id}` steckt in
`/embed/display/{id}`) und holt seine Konfiguration über die bereits öffentliche Route
`/api/display/{display}/config` – dieselbe Quelle wie das physische Display. Da die API-Routen
für alle Domains registriert sind, läuft der Abruf gleichursprünglich über `b2in.test`.
Möglich wurde das durch **B13**: Vorher standen absolute `b2in.test`-URLs in den Playlists.
- **Sektion `Sections/DisplayFeed`** im 9:16-Rahmen. Welches Display gezeigt wird, steht im CMS
(`referenzen.display_feed.display_id`); ohne Eintrag greift das erste aktive Display mit
veröffentlichter Playlist. Gibt es keins, rendert die Sektion **nichts** – lieber keine Sektion
als ein Rahmen mit ewigem Ladebalken. Auch die Texte bleiben dann leer, sonst stünden sie
im Livewire-Snapshot der Seite.
- Eingebunden auf der Startseite (an der dafür vorgesehenen Stelle) und auf `/referenzen`.
**Tests:** `DisplayWebFeedTest` (10 Fälle: Embed liefert die Datei, kein Token im Ausgabe-Inhalt,
404 bei inaktiv/Entwurf/leer/unbekannt, Einbindung auf der Startseite, CMS-Auswahl und Fallback).
### AP5 · Rubrik-Seite „Referenzen" — ✅ umgesetzt
**Korrektur gegenüber Revision 1:** Revision 1 schlug einen neuen `DisplayVersionType`-Case vor.
Die Umsetzungsliste sagt „zweites, separates **Display** im Display-CMS" – das ist ein weiterer
`Display`-Datensatz und braucht **keine Enum-Änderung**. Damit entfallen die Folgeänderungen an
`DisplayPlaylistConfigBuilder::moduleData()`, `DisplayModuleSettings` und den
`version-editor-*`-Partials. Deutlich risikoärmer und billiger.
- Zweites `Display` anlegen, Route `/referenzen`, View + Sektion, Aufnahme in die Navigation.
- Startseiten-Teaser „Aktuelles aus unseren Showrooms" mit Button „Alle Referenzen ansehen →".
- 2–3 Platzhalter-Einträge.
- YouTube-Verlinkungen mit Consent-Gate.
- Inhaltliche Regel dokumentieren: unter „Referenzen" nur realisierte Projekte; Visualisierungen
und Entwürfe gehören in die Kernmarken-Detailseiten.
- **Bereits vorhanden:** die markenbezogene Galerie mit den Rubriken „Realisierte Projekte" und
„Inspirationen und Entwürfe" (siehe Nachtrag 2 zu AP2). AP5 baut darauf auf, statt eine zweite
Pflegestelle für dieselben Bilder zu schaffen.
#### Umsetzung
- Seite `/referenzen`: Seitenkopf → realisierte Projekte → Showroom-Display → Abschluss-CTA.
Navigationspunkt zwischen „Marken" und „Magazin".
- **Kein zweites Display nötig, keine zweite Pflegestelle:** Die Projekte kommen aus den
Markengalerien (AP2) und werden über alle Marken hinweg gesammelt. Es erscheinen ausschließlich
Einträge der Rubrik **„Realisiertes Projekt"** – Visualisierungen bleiben draußen, damit
„Referenzen" hält, was das Wort verspricht. Jede Kachel verlinkt zurück auf ihre Markenseite.
- **Ehrlicher Leerzustand:** Solange keine Projekte gepflegt sind, steht dort ein Satz dazu und ein
Terminlink – keine erfundene Referenz und keine leere Fläche.
- Der Seitenkopf ist jetzt eine wiederverwendbare Sektion (`PageHero` mit `section` und
`breadcrumb`), vorher war er auf `/marken` festgelegt.
**Tests:** `ReferenzenPageTest` (6 Fälle: Seite lädt mit eigenem Titel, Navigationspunkt,
Projekte aus allen Marken, Visualisierungen bleiben draußen, Rückverlinkung, Leerzustand).
### AP6 · Engagement / Sponsoring — ✅ umgesetzt (im Rahmen von AP1)
Schlanke Sektion „Regional verbunden" nach der Marcel-Sektion: ein Einleitungssatz plus drei
Logos in ruhiger Reihe (Graustufen/gedeckt).
Logos liegen bereits unter `dev/2026-08-28/sponsoren-logos/` – **7 Dateien für 3 Vereine**
(Arminia, TCBWH, TCH in mehreren Varianten und Formaten). Vor der Umsetzung je Verein **eine**
Datei auswählen, nach `public/img/assets/` bzw. in die Medienbibliothek übernehmen und
vereinheitlichen (gleiche Höhe, transparenter Hintergrund – die `.jpg`-Varianten haben keinen).
Setzt **B9** voraus, damit die Logos per Picker pflegbar sind.
---
## 4. Nicht Bestandteil dieses Auftrags ⏸
Referenzbereich voll ausgebaut · 16:9-Display-Modul · Immobilien-CMS · Dubai-Map ·
Markenrichtlinie Fahrzeug/Print · weitere frei anlegbare Rubriken · eigene Videolösung.
**Zusätzlich neu zu bewerten:** B2 (Magazin-CMS ans Frontend anschließen, ≈2–3 Std.)
und B8 (Passwort aus dem Seeder).
---
## 5. Abhängigkeiten
```
AP1 (Struktur, Umtextung, B1/B3/B4/B5/B6/B7)
├─ AP2 (Kernmarken, B9) ──┐
├─ AP4 (Display-Spiegelung)│
├─ AP5 (Referenzen) │
├─ AP6 (Sponsoring) ◄──────┤ braucht B9
└─ AP3 (Termin-Widget) ◄───┘ Markenauswahl braucht CmsBrand
```
**B1 zuerst.** Solange der Seeder überschreibt, ist jede redaktionelle Arbeit flüchtig.
---
## 6. Restrisiken & offene Punkte
| Punkt | Stand | Umgang |
|---|---|---|
| **Rechtstexte** nennen weiter „Connecting Design and Property", Dubai/Lissabon, Supply-Chain | ⚠️ offen | Formulierung freigeben lassen, dann eintragen – nicht selbst getextet |
| **OG-Bild fehlt komplett** (`b2in-og-default.jpg` existiert nicht) | ⚠️ offen | Motiv wählen, ablegen |
| WhatsApp-Nummer (+49 151 51002992?) | offen | bestätigen lassen; ohne Nummer bleibt der Button unsichtbar |
| Logos, Stimmungsbilder, Galeriefotos der drei Marken | offen | Platzhalter stehen, Marcel trägt im CMS nach |
| Zwei neue Magazin-Beiträge sind Platzhalter | offen | gegenlesen lassen |
| Sponsoren-Kurztexte sind Entwürfe | offen | gegenlesen lassen |
| Vier Dubai-Beiträge bleiben im Magazin | bewusst | gehören zum Immobilienbereich, der bestehen bleibt |
| `ecosystem_stats` behauptet „1,7K+ Partner", „510+ Projekte", „98 %" | ruhend | Sektion wird auf keiner erreichbaren Seite gerendert – vor einer Wiederverwendung prüfen |
| B1/B2 Entscheidung | ✅ erledigt | Redaktionsschutz im Seeder, Magazin am Frontend |
| Logo mit Zusatz „Interior" | ✅ erledigt | Zulieferung eingetroffen und zugeschnitten |
| Sponsoring-Logos uneinheitlich | ✅ erledigt | Kacheln mit weißer Bühne, JPG ohne Transparenz fällt nicht mehr auf |
**Spielregel aus der Umsetzungsliste:** eine Korrekturschleife je Bereich ist enthalten.
Weitere Runden vorher melden und beziffern.
---
## 7. Definition of Done
- [x] Alle Live-Seiten laden (`LiveSiteReviewTest` grün)
- [x] Marcel kann Marke anlegen, Logo per Picker setzen, Galerie pflegen – und die Änderung
erscheint auf der Website (Gegenprobe zu B2, getestet in `CmsBrandsIndexTest`)
- [x] Erneuter `CmsContentSeeder`-Lauf löscht keine redaktionellen Inhalte (Gegenprobe zu B1)
- [x] `./vendor/bin/pint`
- [x] `npm run build:b2in` (Build-Verzeichnis `build/b2in`)
- [x] `php artisan test --compact` vollständig grün — 810 Tests, 3037 Assertions
- [ ] Keine Treffer mehr für „Supply-Chain", „Local for Local", „Ökosystem", „Connecting Design"
auf den Live-Seiten. **Stand: 8 statt ursprünglich 90.** Davon
5 in `/magazin` (die vier Dubai-Beiträge, bleiben bewusst),
1 in `/privacy` und 2 in `/terms` (Rechtstexte, siehe Abschnitt 0, Schritt 2).
Erst abhakbar, wenn die Rechtstexte freigegeben sind.
- [ ] OG-Bild vorhanden
- [ ] Rechtstexte freigegeben und eingetragen
---
## 7a. Vorschau-Instanz für die Abstimmung
Für die erste Abstimmung läuft der neue Stand als **eigenständige Vorschau** unter
`preview.b2in.eu` – eigenes Verzeichnis, eigene `.env`, eigene Datenbank. Live bleibt unangetastet.
**Ablauf Schritt für Schritt: `dev/2026-08-28/preview-deployment.md`.**
Dateien dafür: `dev/2026-08-28/deploy/b2in-interior-update.tar.gz` (geänderte Dateien plus
gebaute Assets) und `deploy/dateiliste.txt`.
Zwei Punkte, die dabei leicht übersehen werden:
- **Die Vorschau braucht `DOMAIN_B2IN=preview.b2in.eu` in ihrer `.env`.** Der
`ThemeServiceProvider` setzt `URL::forceRootUrl()` auf die konfigurierte Domain; ein unbekannter
Host fällt auf `APP_URL` zurück. Ohne diesen Eintrag rendert die Vorschau zwar, aber jeder
interne Link springt auf die Live-Seite.
- **Eigene Datenbank, keine geteilte.** `InteriorRelaunchCmsSeeder` überschreibt 23 CMS-Sektionen –
auf der Live-DB ausgeführt wäre der alte Stand weg.
Neu dafür im Code: `config('app.indexable')`. Außerhalb von `APP_ENV=production` liefert jede Seite
`noindex, nofollow`, damit eine Vorschau nicht mit dem Original um Suchergebnisse konkurriert.
Über `SITE_INDEXABLE` einzeln steuerbar.
---
## 8. Deployment-Checkliste (Livegang)
> **Diese Liste beim Livegang von oben nach unten abarbeiten.** Sie wird mit jedem Arbeitspaket
> ergänzt – zuletzt aktualisiert nach AP1 und der Display-Umstellung.
### 8.1 Vorher
- [ ] **DB-Backup ziehen.** Die Seeder unter 8.2 schreiben in `flux_cms_contents`,
`display_versions` und `display_version_items`.
- [ ] `.env` prüfen: `SEED_ADMIN_NAME`, `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` gesetzt
(neu seit B8 – vorher stand das Passwort im Repo).
- [ ] Wartungsfenster mit Marcel abstimmen: die Displays holen ihre Konfiguration zyklisch,
ein Neustart ist nicht nötig, aber ein sichtbarer Wechsel mitten im Ladengeschäft schon.
### 8.2 Deployment
```bash
php artisan migrate --force # legt u. a. cms_brands an (AP2)
# 0. Kernmarken anlegen (CABINET, Rotpunkt Küchen, Febrü).
# Überschreibt nichts – bereits gepflegte Marken bleiben unberührt.
php artisan db:seed --class=CmsBrandSeeder --force
# 1. Neue Texte ins CMS. Überschreibt GENAU die 19 in AP1 geänderten Sektionen,
# alles andere bleibt unangetastet.
php artisan db:seed --class=InteriorRelaunchCmsSeeder --force
# 2. Display-Branding auf das Interior-Logo vereinheitlichen (Logo je Theme + neuer Claim).
php artisan db:seed --class=DisplayInteriorBrandingSeeder --force
# 3. Medien-URLs host-unabhängig machen. Ohne diesen Schritt zeigen vier veröffentlichte
# Playlists weiterhin auf b2in.test und laden auf dem Live-Display nicht.
php artisan db:seed --class=DisplayMediaUrlNormalizerSeeder --force
php artisan config:cache
php artisan view:clear
npm ci && npm run build:web
```
### 8.3 Nachher prüfen
- [ ] Startseite: Reihenfolge, Kernmarken-Anker `#marken`, Sponsoring-Logos
- [ ] Markenseite `/marken` – Aufbau, Merkmale, „Alle Marken ansehen" von der Startseite
- [ ] Kernmarken-Detailseiten `/marken/cabinet`, `/marken/rotpunkt`, `/marken/februe`
- [ ] `/netzwerk`, `/interior`, `/partner`, `/service` leiten auf `/marken`
- [ ] Im Admin unter „Kernmarken" eine Marke ändern und prüfen, dass es auf der Seite ankommt
- [ ] Ankündigungsleiste erscheint (Dismiss-Key hängt seit B3 am Inhalt – wer die alte Leiste
weggeklickt hatte, sieht die neue wieder)
- [ ] Logo in Header, Footer **und** in einer Test-Mail
- [ ] Alte Magazin-URLs `/magazin/1` … `/magazin/5` leiten per 301 auf den Slug
- [ ] `/service` und `/portfolio` leiten um statt 500 zu liefern
- [ ] **Die vier Displays sichten:** „Display 4 – Innen", „Kundenstopper", „Display 2",
„Test-Display". Besonders das Modul „Cabinet-Influencerin" – dessen Video lag auf
`b2in.test`. Ob die Datei auf dem Live-Server überhaupt existiert, ließ sich lokal nicht
prüfen; fehlt sie, muss sie nachgeladen werden.
- [ ] Search Console: die alten Magazin-URLs waren indexiert, die 301 fängt sie ab
- [ ] Search Console: `/netzwerk` war indexiert und leitet jetzt auf `/marken`
### 8.4 Zurückrollen
Die Seeder sind nicht umkehrbar. Rückweg ist das Backup aus 8.1 – für die betroffenen Tabellen
genügt ein selektives Zurückspielen von `flux_cms_contents`, `display_versions` und
`display_version_items`.
### 8.5 Nicht vergessen
- [ ] Altes Seeder-Passwort rotieren, falls es anderswo benutzt wird – es steht weiterhin in der
Git-Historie (B8).
- [ ] WhatsApp-Nummer bestätigen lassen – erst dann erscheint der Button im Terminanfrage-Widget
und in den CTA-Blöcken (CMS: `global.contact.whatsapp`).
- [ ] `CONTACT_FORM_RECIPIENT` in der Live-`.env` prüfen: dorthin gehen auch die Terminanfragen
aus dem Widget.
- [ ] Datenschutzerklärung um einen Absatz zum WhatsApp-Link ergänzen, sobald die Nummer steht.
- [ ] **Rechtstexte:** AGB (`b2in_legal.php` Zeile 117) und Datenschutzerklärung (Zeile 221)
nennen weiter „Connecting Design and Property", Dubai/Lissabon und Supply-Chain-Management.
Von Marcel prüfen lassen, dann eintragen.
- [ ] Prüfen, welches Display auf der Website laufen soll (`referenzen.display_feed.display_id`).
Ohne Eintrag zeigt die Seite das erste aktive Display mit veröffentlichter Playlist.
- [ ] Die zwei neuen Magazin-Beiträge von Marcel gegenlesen lassen – sie sind Platzhalter.
- [ ] OG-Bild anlegen: das Layout verweist auf `img/assets/b2in-og-default.jpg`, die Datei
existiert nicht. Bis dahin zeigt jede geteilte Seite eine Vorschau ohne Bild.
- [ ] Sponsoring-Logo `tc-bad-waldliesborn-horn` liegt nur als JPG vor, also ohne transparenten
Hintergrund. Falls Marcel eine PNG-Variante hat, austauschen.
- [ ] Markengalerien vor dem Livegang durchsehen: jedes Bild muss in der richtigen Rubrik stehen.
Ohne gesetzte Kategorie erscheint ein Bild als „Visualisierung" – für ein echtes Projektfoto
also bewusst „Realisiertes Projekt" wählen.

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 30.8.0, SVG Export Plug-In . SVG Version: 9.03 Build 0) -->
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 595.3 419.5" style="enable-background:new 0 0 595.3 419.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#2C9FDA;}
.st2{fill:none;stroke:#2C9FDA;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M156.8,262.4V158.9h40.7c7.5,0,13.8,1.1,18.9,3.4c5,2.3,8.8,5.4,11.3,9.4c2.5,4,3.8,8.6,3.8,13.8
c0,4.1-0.8,7.6-2.4,10.7c-1.6,3-3.8,5.5-6.6,7.4c-2.8,1.9-5.9,3.3-9.5,4.1v1c3.9,0.1,7.5,1.2,10.9,3.3c3.4,2.1,6.2,4.9,8.3,8.6
c2.1,3.7,3.2,8.1,3.2,13.2c0,5.5-1.3,10.3-4,14.7c-2.7,4.3-6.6,7.7-11.8,10.2c-5.2,2.5-11.7,3.7-19.5,3.7H156.8z M177.8,202.4
h16.6c3,0,5.7-0.5,8.1-1.6c2.4-1.1,4.3-2.6,5.6-4.7c1.4-2,2-4.4,2-7.2c0-3.8-1.3-6.8-4-9.2c-2.6-2.4-6.4-3.5-11.3-3.5h-17V202.4
z M177.8,244.9H196c6.2,0,10.7-1.2,13.5-3.6c2.8-2.4,4.3-5.6,4.3-9.5c0-2.9-0.7-5.5-2.1-7.7c-1.4-2.2-3.4-4-6-5.3
c-2.6-1.3-5.6-1.9-9.2-1.9h-18.8V244.9z"/>
<path class="st0" d="M249.4,262.4v-15.3l36.4-34.4c3.1-3.1,5.8-5.8,7.9-8.3c2.1-2.5,3.8-4.9,4.9-7.3c1.1-2.4,1.7-5,1.7-7.7
c0-3.1-0.7-5.8-2.1-8c-1.4-2.2-3.3-3.9-5.7-5.1c-2.4-1.2-5.1-1.8-8.2-1.8c-3.2,0-6,0.6-8.3,1.9c-2.4,1.3-4.2,3.1-5.5,5.6
c-1.3,2.4-1.9,5.3-1.9,8.7h-20.1c0-6.7,1.5-12.6,4.6-17.5c3.1-5,7.3-8.8,12.7-11.5c5.4-2.7,11.7-4.1,18.8-4.1
c7.3,0,13.6,1.3,19,3.9c5.4,2.6,9.6,6.2,12.7,10.8c3,4.6,4.5,9.9,4.5,15.8c0,3.8-0.7,7.6-2.2,11.4c-1.5,3.8-4.2,8-8,12.6
c-3.8,4.7-9.3,10.2-16.3,16.7l-15.5,15.4v0.8h43.4v17.4H249.4z"/>
</g>
</g>
<g>
<path class="st0" d="M345,262.2v-74.1h18.2v74.1H345z"/>
<path class="st0" d="M395.1,262.2h-18.2v-74.1h18.2v12.4h0.7c1.5-4,3.9-7.3,7.2-10c3.3-2.7,7.8-4,13.6-4c7.6,0,13.4,2.5,17.5,7.6
c4.1,5.1,6.2,12.2,6.2,21.4v46.8h-18.2v-45c0-5.3-0.9-9.3-2.8-11.9c-1.9-2.6-5-4-9.4-4c-2.5,0-4.8,0.5-7.1,1.3
c-2.3,0.9-4.1,2.2-5.5,4c-1.4,1.8-2.1,4-2.1,6.6V262.2z"/>
</g>
<path class="st1" d="M354.1,152.3c2.5,0,4.7,0.3,6.6,0.8c-14-32.8-49-56-89.9-56c-41.5,0-76.8,23.9-90.5,57.3h14.3
c8.8-30.8,39.6-53.5,76.1-53.5c36.1,0,66.6,22.2,75.8,52.5C348.6,152.7,351.1,152.3,354.1,152.3z"/>
<path class="st0" d="M354.1,178.1c-3.7,0-6.4-0.9-8.1-2.6c-1.7-1.7-2.6-3.9-2.6-6.5v-2.8c0-2.6,0.9-4.8,2.6-6.5
c1.7-1.7,4.4-2.6,8.1-2.6s6.4,0.9,8.1,2.6c1.7,1.7,2.6,3.9,2.6,6.5v2.8c0,2.6-0.9,4.7-2.6,6.5
C360.5,177.2,357.8,178.1,354.1,178.1z"/>
</g>
<g>
<path class="st0" d="M156.4,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H156.4z"/>
<path class="st0" d="M196.8,307l-3-6.3h-0.1v21.1h-6.4v-31.4h7.5l8.6,14.8l3,6.3h0.1v-21.1h6.4v31.4h-7.5L196.8,307z"/>
<path class="st0" d="M243.1,296.5v25.3h-6.8v-25.3h-8.5v-6.1h23.8v6.1H243.1z"/>
<path class="st0" d="M266.4,321.9v-31.4h21.4v6.1h-14.5v6.4h12.5v6h-12.5v6.8h14.5v6.1H266.4z"/>
<path class="st0" d="M310.7,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M317.9,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H317.9z"/>
<path class="st0" d="M343.2,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H343.2z"/>
<path class="st0" d="M372.8,306.2c0-10.7,5.5-16.2,13.8-16.2c8.3,0,13.8,5.6,13.8,16.2c0,10.7-5.5,16.2-13.8,16.2
C378.3,322.4,372.8,316.8,372.8,306.2z M393.2,308.5v-4.7c0-5-2.5-7.8-6.6-7.8s-6.6,2.8-6.6,7.8v4.7c0,5,2.5,7.8,6.6,7.8
S393.2,313.5,393.2,308.5z"/>
<path class="st0" d="M423.1,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M430.3,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H430.3z"/>
</g>
<line class="st2" x1="154.1" y1="275.5" x2="440.2" y2="275.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 30.8.0, SVG Export Plug-In . SVG Version: 9.03 Build 0) -->
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 595.3 419.5" style="enable-background:new 0 0 595.3 419.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#314052;}
.st1{fill:#2C9FDA;}
.st2{fill:none;stroke:#2C9FDA;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M156.8,262.4V158.9h40.7c7.5,0,13.8,1.1,18.9,3.4c5,2.3,8.8,5.4,11.3,9.4c2.5,4,3.8,8.6,3.8,13.8
c0,4.1-0.8,7.6-2.4,10.7c-1.6,3-3.8,5.5-6.6,7.4c-2.8,1.9-5.9,3.3-9.5,4.1v1c3.9,0.1,7.5,1.2,10.9,3.3c3.4,2.1,6.2,4.9,8.3,8.6
c2.1,3.7,3.2,8.1,3.2,13.2c0,5.5-1.3,10.3-4,14.7c-2.7,4.3-6.6,7.7-11.8,10.2c-5.2,2.5-11.7,3.7-19.5,3.7H156.8z M177.8,202.4
h16.6c3,0,5.7-0.5,8.1-1.6c2.4-1.1,4.3-2.6,5.6-4.7c1.4-2,2-4.4,2-7.2c0-3.8-1.3-6.8-4-9.2c-2.6-2.4-6.4-3.5-11.3-3.5h-17V202.4
z M177.8,244.9H196c6.2,0,10.7-1.2,13.5-3.6c2.8-2.4,4.3-5.6,4.3-9.5c0-2.9-0.7-5.5-2.1-7.7c-1.4-2.2-3.4-4-6-5.3
c-2.6-1.3-5.6-1.9-9.2-1.9h-18.8V244.9z"/>
<path class="st0" d="M249.4,262.4v-15.3l36.4-34.4c3.1-3.1,5.8-5.8,7.9-8.3c2.1-2.5,3.8-4.9,4.9-7.3c1.1-2.4,1.7-5,1.7-7.7
c0-3.1-0.7-5.8-2.1-8c-1.4-2.2-3.3-3.9-5.7-5.1c-2.4-1.2-5.1-1.8-8.2-1.8c-3.2,0-6,0.6-8.3,1.9c-2.4,1.3-4.2,3.1-5.5,5.6
c-1.3,2.4-1.9,5.3-1.9,8.7h-20.1c0-6.7,1.5-12.6,4.6-17.5c3.1-5,7.3-8.8,12.7-11.5c5.4-2.7,11.7-4.1,18.8-4.1
c7.3,0,13.6,1.3,19,3.9c5.4,2.6,9.6,6.2,12.7,10.8c3,4.6,4.5,9.9,4.5,15.8c0,3.8-0.7,7.6-2.2,11.4c-1.5,3.8-4.2,8-8,12.6
c-3.8,4.7-9.3,10.2-16.3,16.7l-15.5,15.4v0.8h43.4v17.4H249.4z"/>
</g>
</g>
<g>
<path class="st0" d="M345,262.2v-74.1h18.2v74.1H345z"/>
<path class="st0" d="M395.1,262.2h-18.2v-74.1h18.2v12.4h0.7c1.5-4,3.9-7.3,7.2-10c3.3-2.7,7.8-4,13.6-4c7.6,0,13.4,2.5,17.5,7.6
c4.1,5.1,6.2,12.2,6.2,21.4v46.8h-18.2v-45c0-5.3-0.9-9.3-2.8-11.9c-1.9-2.6-5-4-9.4-4c-2.5,0-4.8,0.5-7.1,1.3
c-2.3,0.9-4.1,2.2-5.5,4c-1.4,1.8-2.1,4-2.1,6.6V262.2z"/>
</g>
<path class="st1" d="M354.1,152.3c2.5,0,4.7,0.3,6.6,0.8c-14-32.8-49-56-89.9-56c-41.5,0-76.8,23.9-90.5,57.3h14.3
c8.8-30.8,39.6-53.5,76.1-53.5c36.1,0,66.6,22.2,75.8,52.5C348.6,152.7,351.1,152.3,354.1,152.3z"/>
<path class="st0" d="M354.1,178.1c-3.7,0-6.4-0.9-8.1-2.6c-1.7-1.7-2.6-3.9-2.6-6.5v-2.8c0-2.6,0.9-4.8,2.6-6.5
c1.7-1.7,4.4-2.6,8.1-2.6s6.4,0.9,8.1,2.6c1.7,1.7,2.6,3.9,2.6,6.5v2.8c0,2.6-0.9,4.7-2.6,6.5
C360.5,177.2,357.8,178.1,354.1,178.1z"/>
</g>
<g>
<path class="st0" d="M156.4,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H156.4z"/>
<path class="st0" d="M196.8,307l-3-6.3h-0.1v21.1h-6.4v-31.4h7.5l8.6,14.8l3,6.3h0.1v-21.1h6.4v31.4h-7.5L196.8,307z"/>
<path class="st0" d="M243.1,296.5v25.3h-6.8v-25.3h-8.5v-6.1h23.8v6.1H243.1z"/>
<path class="st0" d="M266.4,321.9v-31.4h21.4v6.1h-14.5v6.4h12.5v6h-12.5v6.8h14.5v6.1H266.4z"/>
<path class="st0" d="M310.7,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M317.9,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H317.9z"/>
<path class="st0" d="M343.2,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H343.2z"/>
<path class="st0" d="M372.8,306.2c0-10.7,5.5-16.2,13.8-16.2c8.3,0,13.8,5.6,13.8,16.2c0,10.7-5.5,16.2-13.8,16.2
C378.3,322.4,372.8,316.8,372.8,306.2z M393.2,308.5v-4.7c0-5-2.5-7.8-6.6-7.8s-6.6,2.8-6.6,7.8v4.7c0,5,2.5,7.8,6.6,7.8
S393.2,313.5,393.2,308.5z"/>
<path class="st0" d="M423.1,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M430.3,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H430.3z"/>
</g>
<line class="st2" x1="154.1" y1="275.5" x2="440.2" y2="275.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,322 @@
# Vorschau-Instanz auf dem Live-Server einrichten
**Ziel:** `https://preview.b2in.eu` zeigt den neuen Stand zur Abstimmung. **Die Live-Seite bleibt
unangetastet** – es wird keine einzige Datei im Live-Verzeichnis überschrieben und keine Zeile in
der Live-Datenbank geändert.
**Prinzip:** zweites, eigenständiges Verzeichnis · eigene `.env` · eigene Datenbank.
```
/var/www/b2in/ ← Live, bleibt wie es ist
/var/www/b2in-preview/ ← Kopie davon, darauf das Update
```
> Pfade unten sind Beispiele. Ersetze `/var/www/b2in` durch das echte Live-Verzeichnis und
> `b2in_live` / `b2in_preview` durch die echten Datenbanknamen.
---
## 0. Warum nicht einfach die Live-Dateien tauschen
Zwei Gründe, die diesen Aufwand rechtfertigen:
1. **Der Relaunch-Seeder überschreibt CMS-Sektionen.** `InteriorRelaunchCmsSeeder` schreibt 23
Sektionen mit den neuen Texten. Auf der Live-Datenbank ausgeführt wäre der alte Stand weg.
2. **Migrationen sind nicht umkehrbar.** `cms_brands` und die beiden Spalten `summary` /
`highlights` kommen neu dazu.
Mit einer eigenen Datenbank kann beim Testen nichts passieren, was die Live-Seite betrifft.
---
## 1. Voraussetzungen
- [ ] DNS-Eintrag `preview.b2in.eu` → dieselbe Server-IP wie `b2in.eu`
- [ ] SSL-Zertifikat für die Subdomain (Let's Encrypt genügt)
- [ ] vhost mit `DocumentRoot /var/www/b2in-preview/public`
- [ ] Zweite MySQL-Datenbank plus Benutzer
- [ ] Genug Plattenplatz für eine Kopie des Live-Verzeichnisses (vor allem `storage/`)
---
## 2. Sicherung ziehen
Auch wenn wir live nichts anfassen – der Dump ist zugleich die Grundlage für die Vorschau-DB.
```bash
cd /var/www/b2in
mysqldump -u USER -p b2in_live > ~/b2in_live_$(date +%F_%H%M).sql
ls -lh ~/b2in_live_*.sql # Größe prüfen, darf nicht 0 sein
```
---
## 3. Vorschau-Verzeichnis anlegen
Kopie des Live-Standes, **inklusive `vendor/`**. Das erspart ein `composer install` auf dem
Server – für `livewire/flux-pro` bräuchte es sonst die Zugangsdaten aus `auth.json`.
```bash
cp -a /var/www/b2in /var/www/b2in-preview
cd /var/www/b2in-preview
# Laufzeit-Reste der Kopie entfernen
rm -rf bootstrap/cache/*.php
rm -f public/storage # Symlink wird in Schritt 7 neu gesetzt
```
**Zu `storage/app/public` (Medienbibliothek, Display-Videos):** Durch `cp -a` liegt eine echte
Kopie in der Vorschau – sauber getrennt, kostet aber den Speicherplatz. Wenn das zu groß ist:
```bash
rm -rf storage/app/public
ln -s /var/www/b2in/storage/app/public storage/app/public
```
⚠️ Mit dem Symlink landen **Uploads aus der Vorschau in den Live-Dateien**. Zum Anschauen in
Ordnung, zum Testen von Uploads nicht. Im Zweifel kopieren.
---
## 4. `.env` der Vorschau anpassen
Der wichtigste Schritt. Der `ThemeServiceProvider` sucht den aufgerufenen Host in
`config/domains.php` und setzt `URL::forceRootUrl()` samt Asset-Origin auf die dort hinterlegte
URL. Fehlt `DOMAIN_B2IN_URL`, greift der Vorgabewert **`https://b2in.test`** – eine Domain, die es
im Internet nicht gibt. Dann laden Bilder, Favicon und `livewire.min.js` nicht mehr.
```bash
cd /var/www/b2in-preview
nano .env
```
Diese Werte ändern:
```dotenv
APP_ENV=preview
APP_DEBUG=false
APP_URL=https://preview.b2in.eu
APP_DOMAIN_NAME=preview.b2in.eu
# Vorschau-Domain, damit Routing, Links und Assets stimmen
DOMAIN_B2IN=preview.b2in.eu
DOMAIN_B2IN_URL=https://preview.b2in.eu
# Assets ebenfalls auf die Vorschau-Domain, sonst laedt der Browser sie von woanders
ASSET_URL=https://preview.b2in.eu
# Eigene Datenbank
DB_DATABASE=b2in_preview
DB_USERNAME=…
DB_PASSWORD=…
# Eigener Cache-/Session-Namensraum, damit sich Live und Vorschau nicht ins Gehege kommen
CACHE_PREFIX=b2in_preview
SESSION_COOKIE=b2in_preview_session
# Keine Mails aus der Vorschau verschicken
MAIL_MAILER=log
```
`APP_ENV=preview` sorgt automatisch für `noindex, nofollow` in jeder Seite
(`config/app.php` → `app.indexable`). Wer ganz sichergehen will, setzt zusätzlich
`SITE_INDEXABLE=false`.
**Nicht ändern:** `APP_KEY` – sonst sind verschlüsselte Werte in der kopierten Datenbank
nicht mehr lesbar.
---
## 5. Datenbank der Vorschau befüllen
```bash
mysql -u USER -p -e "CREATE DATABASE b2in_preview CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u USER -p b2in_preview < ~/b2in_live_JJJJ-MM-TT_HHMM.sql
```
Gegenprobe, dass die Vorschau wirklich auf ihrer eigenen DB steht:
```bash
cd /var/www/b2in-preview
php artisan tinker --execute="echo config('database.connections.mysql.database');"
# muss b2in_preview ausgeben – wenn hier b2in_live steht: STOPP
```
---
## 6. Update einspielen
Das Archiv `dev/2026-08-28/deploy/b2in-interior-update.tar.gz` enthält genau die geänderten und
neuen Dateien plus die gebauten Assets (`public/build/b2in`, `public/build/portal`). **Node wird
auf dem Server nicht gebraucht.** Die enthaltene Dateiliste steht in `deploy/dateiliste.txt`.
```bash
# Archiv hochladen, dann:
cd /var/www/b2in-preview
tar xzf ~/b2in-interior-update.tar.gz
```
Das Archiv enthält bewusst **keine** `.env`, kein `vendor/` und nichts aus `storage/`.
---
## 7. Artisan-Schritte
```bash
cd /var/www/b2in-preview
php artisan config:clear
php artisan storage:link
php artisan migrate --force # legt cms_brands an, ergänzt summary/highlights
# Kernmarken anlegen – überschreibt nichts, was schon gepflegt ist
php artisan db:seed --class=CmsBrandSeeder --force
# Die neuen Texte ins CMS: genau die 23 umgetexteten Sektionen
php artisan db:seed --class=InteriorRelaunchCmsSeeder --force
# Magazin: die zwei neuen Beiträge plus Reihenfolge
php artisan db:seed --class=CmsArticleSeeder --force
# Display-Branding auf das Interior-Logo
php artisan db:seed --class=DisplayInteriorBrandingSeeder --force
# Medien-URLs host-unabhängig (sonst zeigen vier Playlists auf b2in.test)
php artisan db:seed --class=DisplayMediaUrlNormalizerSeeder --force
php artisan config:cache
php artisan route:cache
php artisan view:clear
```
**Optional, nur für die Abstimmung:** Beispielbilder in die Markengalerien, damit Galerie,
Referenzen-Seite und Lightbox nicht leer sind. Alle Bildunterschriften beginnen mit „BEISPIEL".
```bash
php artisan db:seed --class=DemoBrandGallerySeeder --force
```
Wieder entfernen:
```bash
php artisan tinker --execute="App\Models\CmsBrand::query()->update(['gallery' => null]);"
```
---
## 8. Vorschau abschirmen
**Suchmaschinen** – `noindex` kommt schon aus `APP_ENV=preview`, die `robots.txt` gehört
zusätzlich umgestellt:
```bash
printf 'User-agent: *\nDisallow: /\n' > /var/www/b2in-preview/public/robots.txt
```
**Passwortschutz** (empfohlen, damit die Vorschau nicht zufällig gefunden wird):
```bash
htpasswd -c /etc/apache2/.htpasswd-preview vorschau
```
Apache:
```apache
<Directory /var/www/b2in-preview/public>
AuthType Basic
AuthName "B2in Vorschau"
AuthUserFile /etc/apache2/.htpasswd-preview
Require valid-user
</Directory>
```
Nginx:
```nginx
auth_basic "B2in Vorschau";
auth_basic_user_file /etc/nginx/.htpasswd-preview;
```
---
## 9. Prüfen
| Seite | Erwartung |
|---|---|
| `/` | Hero → Marken-Teaser → Vision → Sponsoring → Showroom-Display → Immobilien → CTA |
| `/marken` | Grauer Seitenkopf mit Bild, drei gerahmte Markenblöcke |
| `/marken/cabinet` | Detailseite, Galerie mit den zwei Rubriken (falls Demo-Seeder lief) |
| `/referenzen` | Realisierte Projekte, darunter das Showroom-Display im Hochformat |
| `/magazin` | Die zwei Interior-Beiträge stehen oben |
| `/faq` | Rubriken „Für Ihr Zuhause" und „Für Projekte und Objekte" |
| `/about` | Werte: Beratung statt Verkauf, Maßarbeit, … |
| `/contact` | Betreffliste beginnt mit Beratungstermin |
| `/immobilien` | unverändert |
Und diese vier Punkte:
- [ ] Terminanfrage-Widget unten rechts öffnet sich, Absenden zeigt die Bestätigung
- [ ] Alle Links bleiben auf `preview.b2in.eu` – **springt einer auf `b2in.eu`, stimmt Schritt 4 nicht**
- [ ] Seitenquelltext enthält `<meta name="robots" content="noindex, nofollow">`
- [ ] Die Live-Seite `https://b2in.eu` sieht unverändert aus
---
## 10. Aufräumen
Vorschau wieder abbauen:
```bash
rm -rf /var/www/b2in-preview
mysql -u USER -p -e "DROP DATABASE b2in_preview;"
# vhost und DNS-Eintrag entfernen
```
Beim späteren echten Livegang gilt **Abschnitt 8 des Entwicklungsplans**, nicht dieses Dokument.
Vorher offen: Rechtstexte, OG-Bild, Seeder-Passwort rotieren.
---
## Wenn etwas nicht stimmt
| Symptom | Ursache |
|---|---|
| **„Kein Server mit dem angegebenen Hostnamen gefunden" für Bilder, Favicon, `livewire.min.js`** | `DOMAIN_B2IN_URL` fehlt oder ist falsch geschrieben → Vorgabewert `https://b2in.test`. Siehe Diagnose unten. |
| Links springen auf `b2in.eu` | `DOMAIN_B2IN` / `DOMAIN_B2IN_URL` in Schritt 4 nicht gesetzt, oder `config:cache` fehlt |
| Seite ohne Styles | `public/build/b2in` fehlt – Archiv nicht vollständig entpackt |
| „Vite manifest not found" | dasselbe, oder `build/portal` fehlt für das Admin-Panel |
| Bilder fehlen | `php artisan storage:link` vergessen, oder `storage/app/public` nicht mitkopiert |
| Display-Kachel bleibt leer | Kein aktives Display mit veröffentlichter Playlist – dann wird die Sektion bewusst gar nicht angezeigt |
| Änderungen wirken nicht | `php artisan config:clear && php artisan config:cache` |
### Diagnose: Assets zeigen auf den falschen Host
```bash
cd /var/www/b2in-preview
# 1. Was steht in der .env?
grep -E '^(APP_ENV|APP_URL|APP_DOMAIN_NAME|ASSET_URL|DOMAIN_B2IN|DOMAIN_B2IN_URL)=' .env
# 2. Was macht die Anwendung daraus? (zeigt den zwischengespeicherten Stand)
php artisan tinker --execute="echo config('domains.domains.b2in.domain_name').' -> '.config('domains.domains.b2in.url').PHP_EOL;"
# 3. Liegt ein alter Config-Cache herum?
ls -l bootstrap/cache/config.php 2>/dev/null
```
Steht in Zeile 2 etwas mit `.test`, fehlen die beiden `DOMAIN_B2IN*`-Zeilen. Beheben:
```bash
cd /var/www/b2in-preview
php artisan config:clear # zuerst den Cache weg, sonst wirkt die .env nicht
nano .env # DOMAIN_B2IN, DOMAIN_B2IN_URL, ASSET_URL, APP_URL setzen
php artisan config:cache
php artisan view:clear
```
**Sicherheitsnetz seit dieser Runde:** Findet der `ThemeServiceProvider` den aufgerufenen Host
nicht in der Konfiguration, verwendet er außerhalb von `APP_ENV=production` den tatsächlich
aufgerufenen Origin – die Vorschau verlinkt sich dann auf sich selbst statt auf eine fremde
Domain. In `production` bleibt es bei der konfigurierten URL, damit ein gefälschter Host-Header
keine Links auf fremde Domains erzeugen kann. Die `.env` sollte trotzdem stimmen.

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View file

@ -0,0 +1,338 @@
# B2in Interior – Umsetzungsdokument Inhalte
**Arbeitsdokument für die Umsetzung.** Für jede Sektion: aktueller Stand → neuer Inhalt.
Schreibweisen verbindlich: **Interior** (nicht Interieur), **Febrü**, **Rotpunkt Küchen**, **CABINET** (Versalien).
Platzhalter sind mit `[PH]` markiert – dort fehlt Zulieferung von Marcel.
---
## 0. Globale Änderungen
### Meta / SEO
| | Alt | Neu |
|---|---|---|
| **Title** | B2IN - Connecting Design and Property | B2in Interior – Premium-Einrichtung & Maßanfertigung \| CABINET, Rotpunkt, Febrü |
| **Description** | B2in verbindet exklusive Immobilien in Dubai mit europäischem Einrichtungsdesign. Ihr Partner für Investment & Interior. | B2in Interior verbindet starke Einrichtungsmarken mit persönlicher Beratung – von der Maßanfertigung bis zur vollständigen Ausstattung für Zuhause und Projekte. |
| **OG-Title** | wie Title | wie neuer Title |
| **OG-Description** | wie Description | wie neue Description |
| **OG-Image** | b2in-og-default.jpg (Investment-Optik) | `[PH]` Interior-Motiv – vorerst bestehendes Hero-Bild verwenden |
### Logo
- Wortmarke **B2in** bleibt, Zusatz **Interior** in leichterer Schriftstärke darunter oder rechts daneben.
- Betrifft: `b2in-logo-positive.svg` (Header) und `b2in-logo-negative.svg` (Footer).
- `[PH]` Falls Marcel keine Datei liefert: Zusatz typografisch mit vorhandener Schrift ergänzen, sauber ausgerichtet.
### Navigation
| Alt | Neu |
|---|---|
| Home · Immobilien · Netzwerk · Magazin · FAQ · Über B2in | Home · **Marken** · **Referenzen** · Magazin · Immobilien · Über B2in |
- **Marken** = neue Kernmarken-Seite (Anker auf Startseite oder eigene Seite)
- **Referenzen** = die selbst pflegbare Rubrik (Web-Display)
- **Netzwerk** entfällt aus der Hauptnavigation (Inhalt geht in Marken/Über B2in auf), Seite bleibt erreichbar
- **FAQ** in den Footer verschieben oder unter „Über B2in" einhängen
- **Immobilien** rutscht nach hinten, bleibt aber in der Navigation
---
## 1. Ankündigungsleiste
**Alt:**
> NEW LAUNCH Azizi Creek Views 4 – Exklusives Off-Market-Projekt in Dubai ab 1.125.000 AED (ca. 283.000 EUR / 306.000 USD) → Exposé ansehen
**Neu (Startbelegung):**
> **NEU** Rotpunkt Küchen jetzt bei B2in Interior – jetzt Beratungstermin sichern → Termin anfragen
**Alternative, falls Rotpunkt-Studio noch nicht startklar:**
> **NEU** CABINET Einbauschränke nach Maß – Showroom Bielefeld → Termin anfragen
*Hinweis: Leiste bleibt technisch wie sie ist, nur Inhalt und Ziel-Link ändern sich. Dubai-Launches können dort später wieder erscheinen, aber nicht als Dauerzustand.*
---
## 2. Hero
**Alt:**
> # B2in – Wo exklusive Immobilien-Investments auf smartes Interior treffen.
> Ihr Partner für renditestarke internationale Immobilien und globales Supply-Chain-Management im Interior-Bereich.
> [Zu den Immobilien-Projekten] [Unser Netzwerk]
> Internationale Immobilien · Exklusive Einrichtung · Persönliche Beratung
**Neu:**
> # B2in Interior – 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.
**Buttons:**
- Primär (dunkel): **Unsere Marken entdecken** → `#marken`
- Sekundär (hell): **Termin anfragen** → öffnet Anfrage-Widget
**Checkmarks:**
- Premium-Marken
- Maßgefertigte Lösungen
- Persönliche Beratung
**Hero-Bild:** `[PH]` Interior-Motiv (Einbaumöbel oder Küche, warmes Licht). Bis zur Zulieferung bestehendes Bild belassen.
**Bild-Badge (unten rechts über dem Bild):**
| Alt | Neu |
|---|---|
| B2in / Connecting Design and Property | B2in Interior / Einrichtung, die bleibt. |
---
## 3. Marcel-Badge (kleiner Einschub unter dem Hero)
**Alt:**
> B2in by Marcel Scheibe – Ihr persönlicher Partner für internationale Immobilien und exklusive Einrichtungskonzepte.
> Marcel Scheibe · Gründer & CEO, B2in
**Neu:**
> B2in by Marcel Scheibe – Ihr persönlicher Partner für hochwertige Einrichtung und starke Marken.
> Marcel Scheibe · Gründer & CEO, B2in
---
## 4. Weiche Privat / Geschäft
**Ersetzt vollständig die Sektion „Zwei Welten. Ein Netzwerk."**
**Alt:**
> ## Zwei Welten. Ein Netzwerk.
> Wir verbinden den Immobilienkauf mit der perfekten Einrichtung. **Immobilien-Investoren** profitieren von unserem exklusiven Möbel-Netzwerk – **Projektentwickler** von unserer deutschen Vertragssicherheit im Supply-Chain-Management.
**Neu:**
> ## Für Ihr Zuhause. Für Ihr Projekt.
>
> Ob einzelnes Möbelstück oder komplette Ausstattung – wir begleiten Sie mit denselben Marken, demselben Anspruch und einem persönlichen Ansprechpartner.
**Karte 1**
> ### 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. Besuchen Sie unsere Showrooms oder vereinbaren Sie eine persönliche Beratung.
>
> **Button:** Beratung anfragen
**Karte 2**
> ### 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.
>
> **Button:** Projekt besprechen
*Bild „Das B2in-Ökosystem" aus der alten Sektion entfällt hier – kann bei „Über B2in" weiterverwendet werden.*
---
## 5. Kernmarken (neues CMS-Modul) — `#marken`
> ## Unsere Kernmarken
>
> Wir arbeiten nicht mit beliebigen Katalogen, sondern mit ausgewählten Herstellern, hinter denen wir stehen – jede Marke ein Spezialist auf ihrem Gebiet.
### Kachel 1 – 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.
>
> *Mehr zu CABINET →*
### Kachel 2 – Rotpunkt Küchen
> ### Rotpunkt Küchen
> **Küchen, die Haltung zeigen.**
>
> Premium-Küchen aus deutscher Fertigung – individuell geplant, nachhaltig gedacht und gebaut für den Mittelpunkt Ihres Zuhauses. Beratung und Planung in unserem Küchenstudio.
>
> *Mehr zu Rotpunkt →*
### Kachel 3 – Febrü
> ### Febrü
> **Arbeiten, wo man gerne ist.**
>
> Möbel für konzentriertes Arbeiten – vom durchdachten Home-Office bis zur kompletten Büroeinrichtung. Ergonomie und Design für Zuhause und fürs Unternehmen.
>
> *Mehr zu Febrü →*
### Detailseiten (Vorlage-Struktur je Marke)
Jede Marke bekommt eine Detailseite mit:
1. Header (Logo + Claim + Stimmungsbild)
2. Einleitungstext (3–5 Sätze)
3. Leistungen / Besonderheiten (3–4 Punkte)
4. Bildergalerie
5. CTA-Block: „Termin anfragen" + „Per WhatsApp schreiben"
`[PH]` Logos und Basis-Infos zu Rotpunkt und Febrü fehlen – Detailseiten vorerst mit obigen Kacheltexten plus Platzhalter-Galerie anlegen, damit die Struktur steht.
---
## 6. Über Marcel / Vertrauen
**Alt:**
> ## Gebaut auf Expertise und Vertrauen
> **B2in (Bridges2international)** verbindet zwei Welten: internationale Immobilien und exklusive Einrichtungskonzepte. Als Ihr persönlicher Partner navigiere ich Sie durch beide Bereiche – mit Expertise, Netzwerk und dem Anspruch, dass jede Entscheidung auf Vertrauen basiert.
> Ob ein **Investment in Dubai**, eine Villa in Lissabon oder die **maßgeschneiderte Einrichtung** Ihres neuen Zuhauses durch lokale Fachexperten – bei B2in laufen alle Fäden zusammen.
> **Regional verwurzelt, international vernetzt** – das ist B2in.
**Neu:**
> ## Gebaut auf Expertise und Vertrauen
>
> **B2in** stand ursprünglich für *Bridges to International*. Heute steht es ebenso für *Bridges to Interior*: hochwertige Einrichtung mit persönlicher Handschrift. Als Ihr Ansprechpartner verbinde ich ausgewählte Hersteller, regionale Fachexperten und ein verlässliches Netzwerk – damit aus Räumen Orte werden, die bleiben.
>
> Ob **maßgefertigter Einbauschrank**, neue **Küche** oder die **vollständige Ausstattung** eines Objekts: Bei B2in laufen alle Fäden zusammen. Und wer über die Einrichtung hinausdenkt, findet bei uns auch den Zugang zu internationalen Immobilien.
>
> **Regional verwurzelt, international vernetzt** – das ist B2in.
*Hier ist die Markenstory verankert, die Marcel selbst vorgeschlagen hat. Immobilien bewusst als vorletzter Satz.*
---
## 7. Engagement / Sponsoring
**Neue Sektion, direkt nach „Über Marcel" – schlanke Logo-Leiste.**
> ## Regional verbunden
>
> Wir unterstützen Vereine und Organisationen aus der Region – weil Verwurzelung mehr ist als eine Adresse.
**Logos:** 3 gelieferte Sponsoring-Logos in einer ruhigen, gleich großen Reihe (Graustufen oder gedeckt, damit sie den Markenauftritt nicht überstrahlen).
`[PH]` Weitere Logos ergänzen, sobald geliefert.
---
## 8. Aktuelles / Referenzen (Web-Display)
**Ersetzt die Sektion „Ein Ökosystem, drei Säulen" komplett.**
**Alt (entfällt):**
> ## Ein Ökosystem, drei Säulen
> Immobilien & Investments / Local-for-Local Marktplatz / Supply-Chain-Management
*Begründung: Zwei von drei Säulen sind interne Konzeptsprache, die Besucher nicht verstehen, und die dahinterliegenden Systeme sind pausiert.*
**Neu – Startseiten-Teaser:**
> ## Aktuelles aus unseren Showrooms
>
> Projekte, Neuheiten und Einblicke – laufend aktualisiert.
>
> **Button:** Alle Referenzen ansehen →
**Neue Unterseite `/referenzen`:**
> # Referenzen
>
> Realisierte Projekte aus unseren Showrooms und von unseren Kunden. Jedes Projekt maßgefertigt, jedes anders.
Inhalte kommen aus dem Web-Display im Display-CMS (Bild, Text, optional YouTube-Video), von Marcel selbst pflegbar.
`[PH]` Startbelegung mit CABINET-Projekten durch Marcel. Bis dahin 2–3 Platzhalter-Einträge anlegen, damit der Bereich nicht leer wirkt.
**Wichtig:** Unter „Referenzen" ausschließlich realisierte Projekte. Visualisierungen und Entwürfe gehören in die Kernmarken-Detailseiten, nicht hierher.
---
## 9. Immobilien-Teaser
**Neu, klein und dezent, kurz vor dem Abschluss-CTA:**
> ## Außerdem: Internationale Immobilien
>
> Neben der Einrichtung begleiten wir ausgewählte internationale Immobilienprojekte – persönlich kuratiert und mit klarem Blick auf Substanz.
>
> **Link (dezent):** Zu den Immobilien →
*Keine Preise, keine Renditeversprechen, kein Dubai im Teasertext. Wer gezielt sucht, findet den Weg.*
---
## 10. Abschluss-CTA
**Alt:**
> ## Ihr nächster Schritt
> Ob Immobilien-Investment, Supply-Chain-Partnerschaft oder Einrichtungs-Netzwerk – sprechen Sie direkt mit uns.
> [Kontakt aufnehmen]
**Neu:**
> ## Ihr nächster Schritt
>
> Ob einzelnes Möbelstück, komplette Einrichtung oder Projektausstattung – sprechen Sie mit uns. Persönlich, unkompliziert, verbindlich.
>
> **Button 1:** Termin anfragen (öffnet Widget)
> **Button 2:** Per WhatsApp schreiben (`wa.me`-Link)
---
## 11. Footer
| | Alt | Neu |
|---|---|---|
| **Claim unter Logo** | Connecting Design and Property | Premium-Einrichtung. Persönlich gemacht. |
| **Zeile darunter** | Marcel Scheibe – Gründer & CEO | bleibt |
*Falls Marcel „Bridges to Interior" als Claim bevorzugt, wird stattdessen das eingesetzt.*
---
## 12. Terminanfrage-Widget
Schwebendes Element unten rechts, auf allen Seiten.
**Öffnet mit:**
> **Termin anfragen**
> Kurz Ihre Daten, wir melden uns persönlich zurück.
**Felder:**
- Name *(Pflicht)*
- E-Mail *(Pflicht)*
- Telefon *(optional)*
- Anliegen / Marke *(Auswahl: CABINET · Rotpunkt Küchen · Febrü · Projektausstattung · Sonstiges)*
- Wunschtermin *(optional)*
- Nachricht *(optional)*
**Button:** Anfrage senden
**Bestätigung:** Danke für Ihre Anfrage – wir melden uns zeitnah bei Ihnen.
**Zweiter Button daneben:** WhatsApp → öffnet Chat direkt.
---
## Arbeitsreihenfolge (Vorschlag)
1. **Globales zuerst:** Logo-Zusatz, Meta/SEO, Navigation, Footer-Claim
2. **Startseite von oben nach unten:** Ankündigungsleiste → Hero → Weiche → Kernmarken → Über Marcel → Sponsoring → Aktuelles → Immobilien-Teaser → Abschluss
3. **Kernmarken-Modul im CMS** anlegen und mit den drei Marken befüllen
4. **Referenzen-Rubrik** (Web-Display) anlegen, 2–3 Platzhalter einstellen
5. **Terminanfrage-Widget + WhatsApp-Button**
6. **Durchgang auf Alt-Begriffe:** „Supply-Chain", „Local-for-Local", „Ökosystem", „Investment" auf Unterseiten prüfen und bereinigen (auch /netzwerk, /about, /faq)
---
## Offene Zulieferungen von Marcel
| Was | Status |
|---|---|
| Sponsoring-Logos | ✅ 3 Stück erhalten |
| Logo mit Zusatz „Interior" | ❌ offen – ggf. selbst ergänzen |
| Rotpunkt: Logo + Basis-Infos | ❌ offen – Platzhalter |
| Febrü: Logo + Basis-Infos | ❌ offen – Platzhalter |
| Hero-Bildmotiv (Interior) | ❌ offen – Bestandsbild belassen |
| Bilder Kernmarken-Kacheln | ❌ offen – Platzhalter |
| CABINET-Referenzprojekte | ❌ offen – Platzhalter-Einträge |
| WhatsApp-Nummer für Button | ❌ klären: Mobil +49 151 51002992? |

View file

@ -0,0 +1,120 @@
# B2in Interior – Umsetzungsliste
**Freigegebener Rahmen: ca. 37–39 Std.** (30 Basis + 3 Display-Spiegelung + 4–6 Rubrik-Seite)
Schreibweisen: **Interior**, **Febrü**, **Rotpunkt Küchen**, **CABINET**
Legende: ✅ freigegeben · 🟡 mitnehmen, wenn ohne Mehraufwand machbar · ⏸ später, separat zu beziffern
---
## 1. Website-Umstrukturierung & Umtextung ✅ *(ca. 18 Std.)*
- Meta-Title und Description auf Interior-Ausrichtung umstellen
- OG-Title / OG-Description anpassen
- Navigation umbauen: „Marken" und „Referenzen" rein, „Immobilien" nach hinten, „Netzwerk" raus aus der Hauptnavi (Seite bleibt erreichbar), FAQ in den Footer
- Ankündigungsleiste neu belegen (Interior-Thema statt Dubai-Launch)
- Hero: neue Headline, Subline, Buttons, Checkmarks, Bild-Badge
- Marcel-Badge unter dem Hero umtexten
- Sektion „Zwei Welten. Ein Netzwerk." ersetzen durch **Weiche Privat / Geschäft** (zwei Karten)
- Sektion „Über Marcel / Vertrauen" umtexten – inkl. Markenstory *Bridges to International / Bridges to Interior*
- Sektion „Ein Ökosystem, drei Säulen" entfernen (Fläche übernimmt Aktuelles/Referenzen)
- Immobilien-Teaser neu anlegen: klein, dezent, kurz vor dem Abschluss
- Abschluss-CTA umtexten, zwei Kontakt-Buttons
- Footer-Claim ersetzen („Connecting Design and Property" → neuer Claim)
- Durchgang auf Alt-Begriffe: „Supply-Chain", „Local-for-Local", „Ökosystem", „Investment" – auch auf /netzwerk, /about, /faq
*🟡 Logo-Zusatz „Interior": Wenn Marcel keine Datei liefert, typografisch mit vorhandener Schrift ergänzen – kleiner Aufwand, versuche ich mitzunehmen.*
---
## 2. Kernmarken-Modul im CMS ✅ *(ca. 8 Std.)*
- CMS-Struktur anlegen (adaptiert vom Magazin-Muster: Übersicht + Detailseite)
- Von Marcel selbst erweiterbar, weitere Marken ohne Codeänderung ergänzbar
- Kachel-Übersicht auf der Startseite (`#marken`)
- Drei Marken anlegen: CABINET, Rotpunkt Küchen, Febrü
- Detailseiten-Vorlage: Header, Einleitung, Leistungen, Galerie, CTA-Block
*Platzhalter, solange Logos und Infos zu Rotpunkt und Febrü fehlen.*
---
## 3. Terminanfrage-Widget ✅ *(ca. 4 Std.)*
- Schwebendes Element unten rechts, auf allen Seiten
- Felder: Name, E-Mail, Telefon (optional), Anliegen/Marke (Auswahl), Wunschtermin (optional), Nachricht
- Versand per Mail an Marcel, Bestätigungstext für den Absender
- **WhatsApp-Button** daneben: Klick-to-Chat, öffnet direkt den Chat (Handy: App, Desktop: WhatsApp Web)
- *Kein Buchungskalender – bewusst schlank*
*Offen: WhatsApp-Nummer bestätigen lassen.*
---
## 4. Display-Spiegelung auf der Website ✅ *(ca. 3 Std.)*
- Bestehende Display-Inhalte (9:16) werden auf der Website ausgespielt
- Nutzt die vorhandene Display-/Player-API als Datenquelle
- Keine doppelte Pflege: Marcel pflegt weiterhin nur im Display-CMS
*⏸ 16:9-Modul wurde zurückgestellt.*
---
## 5. Rubrik-Seite „Referenzen" (Web-Display) ✅ *(ca. 4–6 Std.)*
- Zweites, separates Display im Display-CMS anlegen
- Eigene Unterseite `/referenzen`, die diese Inhalte ausspielt
- Von Marcel selbst pflegbar: Bilder, Texte, YouTube-Verlinkungen
- Teaser-Sektion auf der Startseite mit Verlinkung
- Aufnahme in die Navigation
- 2–3 Platzhalter-Einträge, damit der Bereich nicht leer wirkt
*Inhaltliche Regel: Unter „Referenzen" nur realisierte Projekte. Visualisierungen und Entwürfe gehören in die Kernmarken-Detailseiten.*
---
## 6. Engagement / Sponsoring 🟡 *(Versuch ohne Mehraufwand)*
- Schlanke Sektion „Regional verbunden" nach der Marcel-Sektion
- Drei gelieferte Logos in ruhiger Reihe, gedeckt oder Graustufen
- Ein Satz Einleitungstext
*Wird mitgenommen, solange es bei Logo-Leiste plus Satz bleibt. Eigene Unterseite oder Text je Verein wäre ein Zusatz.*
---
## Später separat zu beziffern ⏸
| Thema | Warum nicht jetzt |
|---|---|
| **Referenzbereich voll ausgebaut** | Eigene Projektseiten, Kategorien, Galerien – eigenes Modul. Die schlanke Rubrik (Punkt 5) reicht für den Start. |
| **16:9-Display-Modul** | Von Marcel selbst zurückgestellt. Braucht zusätzlich querformat-taugliches Bildmaterial. |
| **Immobilien-CMS** | Immobilien bleiben statisch, rücken nur in der Gewichtung nach hinten. Separater Kostenplan liegt vor. |
| **Dubai-Map** | Interaktive Karte – eigenes Paket, setzt das Immobilien-CMS voraus. |
| **Markenrichtlinie Fahrzeug / Print** | Kombinationsregeln B2in + Kernmarken für Fahrzeugbeschriftung, Visitenkarten, Social Media. Eigene Design-Aufgabe, sinnvoll nach dem Website-Livegang. |
| **Weitere frei anlegbare Rubriken** | Jede zusätzliche Rubrik ist ein eigener kleiner Baustein. |
| **Eigene Videolösung / Player** | Nicht nötig – Videos laufen über YouTube-Einbindung. |
---
## Spielregeln
- **Eine Korrekturschleife je Bereich** ist enthalten (Abstände, Positionen, Farben, Gewichtungen)
- Weitere Runden oder neue Anforderungen: vorher kurz melden, Mehraufwand nennen, erst nach Freigabe umsetzen
- **Deadline Ende August** – abhängig von rechtzeitigen Zulieferungen
---
## Offene Zulieferungen
| Was | Status |
|---|---|
| Sponsoring-Logos | ✅ 3 Stück erhalten |
| Logo mit Zusatz „Interior" | offen – ggf. selbst ergänzen |
| Rotpunkt: Logo + Basis-Infos | offen – Platzhalter |
| Febrü: Logo + Basis-Infos | offen – Platzhalter |
| Hero-Bildmotiv (Interior) | offen – Bestandsbild belassen |
| Bilder Kernmarken-Kacheln | offen – Platzhalter |
| CABINET-Referenzprojekte | offen – Platzhalter-Einträge |
| WhatsApp-Nummer | offen – bestätigen lassen |

View file

@ -0,0 +1,130 @@
# B2in Interior – Unterseite `/marken`
Vollständige Texte zur Umsetzung. Schreibweisen: **Interior**, **Febrü**, **Rotpunkt Küchen**, **CABINET**.
Platzhalter: `[PH]`
---
## Meta / SEO
| Feld | Inhalt |
|---|---|
| **Title** | Unsere Marken – CABINET, Rotpunkt Küchen & Febrü \| B2in Interior |
| **Description** | Ausgewählte Hersteller statt beliebiger Kataloge: maßgefertigte Einbauschränke, Premium-Küchen und Möbel fürs Arbeiten – mit persönlicher Beratung in der Region. |
| **URL** | `/marken` |
| **Breadcrumb** | Home → Marken |
---
## 1. Seitenkopf
> # Unsere Marken
>
> Wir arbeiten nicht mit beliebigen Katalogen, sondern mit ausgewählten Herstellern, hinter denen wir stehen. Jede Marke ist Spezialist auf ihrem Gebiet – und jede bringt mit, was gute Einrichtung ausmacht: Qualität, die man sieht, und Beratung, die zuhört.
**Bild:** `[PH]` Ruhiges Interior-Motiv oder Showroom-Aufnahme. Alternativ ohne Headerbild, dafür großzügiger Weißraum.
---
## 2. Kernmarken
*Reihenfolge wie auf der Startseite. Pro Marke: Logo, Bild, Claim, Beschreibung, Merkmale, Link zur Detailseite.*
### CABINET
> ### CABINET
> **Einbauschränke nach Maß.**
>
> 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.
>
> - Millimetergenaue Planung
> - Individuelle Fronten und Innenausstattung
> - Persönliche Beratung vor Ort
>
> **Mehr zu CABINET →**
### Rotpunkt Küchen
> ### Rotpunkt Küchen
> **Küchen, die Haltung zeigen.**
>
> 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.
>
> - Fertigung in Deutschland
> - Individuelle Planung nach Ihrem Grundriss
> - Nachhaltige Materialien
>
> **Mehr zu Rotpunkt →**
### Febrü
> ### Febrü
> **Arbeiten, wo man gerne ist.**
>
> 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.
>
> - Ergonomie, die man merkt
> - Vom einzelnen Arbeitsplatz bis zur Bürofläche
> - Für Zuhause und fürs Unternehmen
>
> **Mehr zu Febrü →**
`[PH]` Logos und Bildmaterial zu Rotpunkt und Febrü fehlen – Platzhalter setzen.
---
## 3. Partnernetzwerk
*Nimmt den Netzwerk-Gedanken der alten Seite auf, in kundenverständlicher Sprache.*
> ## Mehr als Marken: unser Netzwerk
>
> Gute Einrichtung endet nicht beim Möbelstück. Deshalb arbeiten wir mit Fachbetrieben und Handwerkern aus der Region zusammen – von der Aufmaß-Erstellung über die Montage bis zu angrenzenden Gewerken.
>
> Für Sie bedeutet das: ein Ansprechpartner, der die Fäden zusammenhält, statt vieler Einzelgespräche. Und Partner, die wir kennen und für deren Arbeit wir geradestehen.
**Optionale drei Punkte darunter:**
> **Aufmaß & Planung**
> Wir kommen zu Ihnen, nehmen Maß und planen mit Ihnen gemeinsam.
>
> **Montage durch Fachbetriebe**
> Handwerker aus der Region, die wir persönlich kennen.
>
> **Ein Ansprechpartner**
> Von der ersten Idee bis zur Übergabe koordiniert aus einer Hand.
---
## 4. Marken werden ergänzt
*Kurzer Block, der die Erweiterbarkeit signalisiert. Optional – nur einbauen, wenn die Seite sonst zu kurz wirkt.*
> ## Unsere Markenwelt wächst
>
> Wir erweitern unser Portfolio laufend um Hersteller, die zu unserem Anspruch passen. Sie vermissen eine bestimmte Marke oder haben einen konkreten Wunsch? Sprechen Sie uns an – oft finden wir über unser Netzwerk genau das Richtige.
---
## 5. Abschluss-CTA
> ## Lernen Sie unsere Marken kennen
>
> Am besten dort, wo Sie sie anfassen können: in unseren Showrooms. Vereinbaren Sie einen unverbindlichen Beratungstermin – wir nehmen uns Zeit.
>
> **Button 1:** Termin anfragen
> **Button 2:** Per WhatsApp schreiben
---
## 6. Umsetzungshinweise
- **Startseiten-Sektion bleibt** als Teaser mit verkürzten Texten. Kacheln verlinken direkt auf die jeweilige Detailseite, darunter ein Button **„Alle Marken ansehen"** → `/marken`.
- **Navigation:** „Marken" verlinkt auf `/marken`, nicht mehr per Anker auf die Startseite.
- **Detailseiten** hängen unter `/marken/cabinet`, `/marken/rotpunkt`, `/marken/februe` – dadurch saubere Hierarchie und funktionierender Rückweg.
- **Alte `/netzwerk`-Seite:** Inhalt geht in Abschnitt 3 auf. Seite entfernen und per 301 auf `/marken` weiterleiten, damit bestehende Links und Rankings nicht ins Leere laufen.
- **Reihenfolge der Marken** über das CMS steuerbar halten, damit Marcel sie später selbst ändern kann.

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="153 96 289.3 227.5" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#2C9FDA;}
.st2{fill:none;stroke:#2C9FDA;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M156.8,262.4V158.9h40.7c7.5,0,13.8,1.1,18.9,3.4c5,2.3,8.8,5.4,11.3,9.4c2.5,4,3.8,8.6,3.8,13.8
c0,4.1-0.8,7.6-2.4,10.7c-1.6,3-3.8,5.5-6.6,7.4c-2.8,1.9-5.9,3.3-9.5,4.1v1c3.9,0.1,7.5,1.2,10.9,3.3c3.4,2.1,6.2,4.9,8.3,8.6
c2.1,3.7,3.2,8.1,3.2,13.2c0,5.5-1.3,10.3-4,14.7c-2.7,4.3-6.6,7.7-11.8,10.2c-5.2,2.5-11.7,3.7-19.5,3.7H156.8z M177.8,202.4
h16.6c3,0,5.7-0.5,8.1-1.6c2.4-1.1,4.3-2.6,5.6-4.7c1.4-2,2-4.4,2-7.2c0-3.8-1.3-6.8-4-9.2c-2.6-2.4-6.4-3.5-11.3-3.5h-17V202.4
z M177.8,244.9H196c6.2,0,10.7-1.2,13.5-3.6c2.8-2.4,4.3-5.6,4.3-9.5c0-2.9-0.7-5.5-2.1-7.7c-1.4-2.2-3.4-4-6-5.3
c-2.6-1.3-5.6-1.9-9.2-1.9h-18.8V244.9z"/>
<path class="st0" d="M249.4,262.4v-15.3l36.4-34.4c3.1-3.1,5.8-5.8,7.9-8.3c2.1-2.5,3.8-4.9,4.9-7.3c1.1-2.4,1.7-5,1.7-7.7
c0-3.1-0.7-5.8-2.1-8c-1.4-2.2-3.3-3.9-5.7-5.1c-2.4-1.2-5.1-1.8-8.2-1.8c-3.2,0-6,0.6-8.3,1.9c-2.4,1.3-4.2,3.1-5.5,5.6
c-1.3,2.4-1.9,5.3-1.9,8.7h-20.1c0-6.7,1.5-12.6,4.6-17.5c3.1-5,7.3-8.8,12.7-11.5c5.4-2.7,11.7-4.1,18.8-4.1
c7.3,0,13.6,1.3,19,3.9c5.4,2.6,9.6,6.2,12.7,10.8c3,4.6,4.5,9.9,4.5,15.8c0,3.8-0.7,7.6-2.2,11.4c-1.5,3.8-4.2,8-8,12.6
c-3.8,4.7-9.3,10.2-16.3,16.7l-15.5,15.4v0.8h43.4v17.4H249.4z"/>
</g>
</g>
<g>
<path class="st0" d="M345,262.2v-74.1h18.2v74.1H345z"/>
<path class="st0" d="M395.1,262.2h-18.2v-74.1h18.2v12.4h0.7c1.5-4,3.9-7.3,7.2-10c3.3-2.7,7.8-4,13.6-4c7.6,0,13.4,2.5,17.5,7.6
c4.1,5.1,6.2,12.2,6.2,21.4v46.8h-18.2v-45c0-5.3-0.9-9.3-2.8-11.9c-1.9-2.6-5-4-9.4-4c-2.5,0-4.8,0.5-7.1,1.3
c-2.3,0.9-4.1,2.2-5.5,4c-1.4,1.8-2.1,4-2.1,6.6V262.2z"/>
</g>
<path class="st1" d="M354.1,152.3c2.5,0,4.7,0.3,6.6,0.8c-14-32.8-49-56-89.9-56c-41.5,0-76.8,23.9-90.5,57.3h14.3
c8.8-30.8,39.6-53.5,76.1-53.5c36.1,0,66.6,22.2,75.8,52.5C348.6,152.7,351.1,152.3,354.1,152.3z"/>
<path class="st0" d="M354.1,178.1c-3.7,0-6.4-0.9-8.1-2.6c-1.7-1.7-2.6-3.9-2.6-6.5v-2.8c0-2.6,0.9-4.8,2.6-6.5
c1.7-1.7,4.4-2.6,8.1-2.6s6.4,0.9,8.1,2.6c1.7,1.7,2.6,3.9,2.6,6.5v2.8c0,2.6-0.9,4.7-2.6,6.5
C360.5,177.2,357.8,178.1,354.1,178.1z"/>
</g>
<g>
<path class="st0" d="M156.4,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H156.4z"/>
<path class="st0" d="M196.8,307l-3-6.3h-0.1v21.1h-6.4v-31.4h7.5l8.6,14.8l3,6.3h0.1v-21.1h6.4v31.4h-7.5L196.8,307z"/>
<path class="st0" d="M243.1,296.5v25.3h-6.8v-25.3h-8.5v-6.1h23.8v6.1H243.1z"/>
<path class="st0" d="M266.4,321.9v-31.4h21.4v6.1h-14.5v6.4h12.5v6h-12.5v6.8h14.5v6.1H266.4z"/>
<path class="st0" d="M310.7,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M317.9,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H317.9z"/>
<path class="st0" d="M343.2,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H343.2z"/>
<path class="st0" d="M372.8,306.2c0-10.7,5.5-16.2,13.8-16.2c8.3,0,13.8,5.6,13.8,16.2c0,10.7-5.5,16.2-13.8,16.2
C378.3,322.4,372.8,316.8,372.8,306.2z M393.2,308.5v-4.7c0-5-2.5-7.8-6.6-7.8s-6.6,2.8-6.6,7.8v4.7c0,5,2.5,7.8,6.6,7.8
S393.2,313.5,393.2,308.5z"/>
<path class="st0" d="M423.1,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M430.3,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H430.3z"/>
</g>
<line class="st2" x1="154.1" y1="275.5" x2="440.2" y2="275.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="153 96 289.3 227.5" xml:space="preserve">
<style type="text/css">
.st0{fill:#314052;}
.st1{fill:#2C9FDA;}
.st2{fill:none;stroke:#2C9FDA;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M156.8,262.4V158.9h40.7c7.5,0,13.8,1.1,18.9,3.4c5,2.3,8.8,5.4,11.3,9.4c2.5,4,3.8,8.6,3.8,13.8
c0,4.1-0.8,7.6-2.4,10.7c-1.6,3-3.8,5.5-6.6,7.4c-2.8,1.9-5.9,3.3-9.5,4.1v1c3.9,0.1,7.5,1.2,10.9,3.3c3.4,2.1,6.2,4.9,8.3,8.6
c2.1,3.7,3.2,8.1,3.2,13.2c0,5.5-1.3,10.3-4,14.7c-2.7,4.3-6.6,7.7-11.8,10.2c-5.2,2.5-11.7,3.7-19.5,3.7H156.8z M177.8,202.4
h16.6c3,0,5.7-0.5,8.1-1.6c2.4-1.1,4.3-2.6,5.6-4.7c1.4-2,2-4.4,2-7.2c0-3.8-1.3-6.8-4-9.2c-2.6-2.4-6.4-3.5-11.3-3.5h-17V202.4
z M177.8,244.9H196c6.2,0,10.7-1.2,13.5-3.6c2.8-2.4,4.3-5.6,4.3-9.5c0-2.9-0.7-5.5-2.1-7.7c-1.4-2.2-3.4-4-6-5.3
c-2.6-1.3-5.6-1.9-9.2-1.9h-18.8V244.9z"/>
<path class="st0" d="M249.4,262.4v-15.3l36.4-34.4c3.1-3.1,5.8-5.8,7.9-8.3c2.1-2.5,3.8-4.9,4.9-7.3c1.1-2.4,1.7-5,1.7-7.7
c0-3.1-0.7-5.8-2.1-8c-1.4-2.2-3.3-3.9-5.7-5.1c-2.4-1.2-5.1-1.8-8.2-1.8c-3.2,0-6,0.6-8.3,1.9c-2.4,1.3-4.2,3.1-5.5,5.6
c-1.3,2.4-1.9,5.3-1.9,8.7h-20.1c0-6.7,1.5-12.6,4.6-17.5c3.1-5,7.3-8.8,12.7-11.5c5.4-2.7,11.7-4.1,18.8-4.1
c7.3,0,13.6,1.3,19,3.9c5.4,2.6,9.6,6.2,12.7,10.8c3,4.6,4.5,9.9,4.5,15.8c0,3.8-0.7,7.6-2.2,11.4c-1.5,3.8-4.2,8-8,12.6
c-3.8,4.7-9.3,10.2-16.3,16.7l-15.5,15.4v0.8h43.4v17.4H249.4z"/>
</g>
</g>
<g>
<path class="st0" d="M345,262.2v-74.1h18.2v74.1H345z"/>
<path class="st0" d="M395.1,262.2h-18.2v-74.1h18.2v12.4h0.7c1.5-4,3.9-7.3,7.2-10c3.3-2.7,7.8-4,13.6-4c7.6,0,13.4,2.5,17.5,7.6
c4.1,5.1,6.2,12.2,6.2,21.4v46.8h-18.2v-45c0-5.3-0.9-9.3-2.8-11.9c-1.9-2.6-5-4-9.4-4c-2.5,0-4.8,0.5-7.1,1.3
c-2.3,0.9-4.1,2.2-5.5,4c-1.4,1.8-2.1,4-2.1,6.6V262.2z"/>
</g>
<path class="st1" d="M354.1,152.3c2.5,0,4.7,0.3,6.6,0.8c-14-32.8-49-56-89.9-56c-41.5,0-76.8,23.9-90.5,57.3h14.3
c8.8-30.8,39.6-53.5,76.1-53.5c36.1,0,66.6,22.2,75.8,52.5C348.6,152.7,351.1,152.3,354.1,152.3z"/>
<path class="st0" d="M354.1,178.1c-3.7,0-6.4-0.9-8.1-2.6c-1.7-1.7-2.6-3.9-2.6-6.5v-2.8c0-2.6,0.9-4.8,2.6-6.5
c1.7-1.7,4.4-2.6,8.1-2.6s6.4,0.9,8.1,2.6c1.7,1.7,2.6,3.9,2.6,6.5v2.8c0,2.6-0.9,4.7-2.6,6.5
C360.5,177.2,357.8,178.1,354.1,178.1z"/>
</g>
<g>
<path class="st0" d="M156.4,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H156.4z"/>
<path class="st0" d="M196.8,307l-3-6.3h-0.1v21.1h-6.4v-31.4h7.5l8.6,14.8l3,6.3h0.1v-21.1h6.4v31.4h-7.5L196.8,307z"/>
<path class="st0" d="M243.1,296.5v25.3h-6.8v-25.3h-8.5v-6.1h23.8v6.1H243.1z"/>
<path class="st0" d="M266.4,321.9v-31.4h21.4v6.1h-14.5v6.4h12.5v6h-12.5v6.8h14.5v6.1H266.4z"/>
<path class="st0" d="M310.7,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M317.9,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H317.9z"/>
<path class="st0" d="M343.2,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H343.2z"/>
<path class="st0" d="M372.8,306.2c0-10.7,5.5-16.2,13.8-16.2c8.3,0,13.8,5.6,13.8,16.2c0,10.7-5.5,16.2-13.8,16.2
C378.3,322.4,372.8,316.8,372.8,306.2z M393.2,308.5v-4.7c0-5-2.5-7.8-6.6-7.8s-6.6,2.8-6.6,7.8v4.7c0,5,2.5,7.8,6.6,7.8
S393.2,313.5,393.2,308.5z"/>
<path class="st0" d="M423.1,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M430.3,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H430.3z"/>
</g>
<line class="st2" x1="154.1" y1="275.5" x2="440.2" y2="275.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -145,7 +145,7 @@
/* B2in Brand (positionable logo + claim) */
.b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; }
.b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
.b2in-brand-logo img { height: 4.6vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
.b2in-brand-claim {
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
text-transform: uppercase; color: rgba(255,255,255,0.85);
@ -1337,7 +1337,7 @@ class B2inRenderer {
layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
layer.setAttribute('data-theme', this.theme);
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '/_cabinet/assets/b2in-logo-positive.svg');
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '/_cabinet/assets/b2in-interior-logo-negative.svg');
const headerClaim = this.settings.header_claim || 'Connecting Design & Property';
const footerUrl = this.settings.footer_url || 'B2in.eu';
const footerName = this.settings.footer_name || '';

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="153 96 289.3 227.5" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#2C9FDA;}
.st2{fill:none;stroke:#2C9FDA;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M156.8,262.4V158.9h40.7c7.5,0,13.8,1.1,18.9,3.4c5,2.3,8.8,5.4,11.3,9.4c2.5,4,3.8,8.6,3.8,13.8
c0,4.1-0.8,7.6-2.4,10.7c-1.6,3-3.8,5.5-6.6,7.4c-2.8,1.9-5.9,3.3-9.5,4.1v1c3.9,0.1,7.5,1.2,10.9,3.3c3.4,2.1,6.2,4.9,8.3,8.6
c2.1,3.7,3.2,8.1,3.2,13.2c0,5.5-1.3,10.3-4,14.7c-2.7,4.3-6.6,7.7-11.8,10.2c-5.2,2.5-11.7,3.7-19.5,3.7H156.8z M177.8,202.4
h16.6c3,0,5.7-0.5,8.1-1.6c2.4-1.1,4.3-2.6,5.6-4.7c1.4-2,2-4.4,2-7.2c0-3.8-1.3-6.8-4-9.2c-2.6-2.4-6.4-3.5-11.3-3.5h-17V202.4
z M177.8,244.9H196c6.2,0,10.7-1.2,13.5-3.6c2.8-2.4,4.3-5.6,4.3-9.5c0-2.9-0.7-5.5-2.1-7.7c-1.4-2.2-3.4-4-6-5.3
c-2.6-1.3-5.6-1.9-9.2-1.9h-18.8V244.9z"/>
<path class="st0" d="M249.4,262.4v-15.3l36.4-34.4c3.1-3.1,5.8-5.8,7.9-8.3c2.1-2.5,3.8-4.9,4.9-7.3c1.1-2.4,1.7-5,1.7-7.7
c0-3.1-0.7-5.8-2.1-8c-1.4-2.2-3.3-3.9-5.7-5.1c-2.4-1.2-5.1-1.8-8.2-1.8c-3.2,0-6,0.6-8.3,1.9c-2.4,1.3-4.2,3.1-5.5,5.6
c-1.3,2.4-1.9,5.3-1.9,8.7h-20.1c0-6.7,1.5-12.6,4.6-17.5c3.1-5,7.3-8.8,12.7-11.5c5.4-2.7,11.7-4.1,18.8-4.1
c7.3,0,13.6,1.3,19,3.9c5.4,2.6,9.6,6.2,12.7,10.8c3,4.6,4.5,9.9,4.5,15.8c0,3.8-0.7,7.6-2.2,11.4c-1.5,3.8-4.2,8-8,12.6
c-3.8,4.7-9.3,10.2-16.3,16.7l-15.5,15.4v0.8h43.4v17.4H249.4z"/>
</g>
</g>
<g>
<path class="st0" d="M345,262.2v-74.1h18.2v74.1H345z"/>
<path class="st0" d="M395.1,262.2h-18.2v-74.1h18.2v12.4h0.7c1.5-4,3.9-7.3,7.2-10c3.3-2.7,7.8-4,13.6-4c7.6,0,13.4,2.5,17.5,7.6
c4.1,5.1,6.2,12.2,6.2,21.4v46.8h-18.2v-45c0-5.3-0.9-9.3-2.8-11.9c-1.9-2.6-5-4-9.4-4c-2.5,0-4.8,0.5-7.1,1.3
c-2.3,0.9-4.1,2.2-5.5,4c-1.4,1.8-2.1,4-2.1,6.6V262.2z"/>
</g>
<path class="st1" d="M354.1,152.3c2.5,0,4.7,0.3,6.6,0.8c-14-32.8-49-56-89.9-56c-41.5,0-76.8,23.9-90.5,57.3h14.3
c8.8-30.8,39.6-53.5,76.1-53.5c36.1,0,66.6,22.2,75.8,52.5C348.6,152.7,351.1,152.3,354.1,152.3z"/>
<path class="st0" d="M354.1,178.1c-3.7,0-6.4-0.9-8.1-2.6c-1.7-1.7-2.6-3.9-2.6-6.5v-2.8c0-2.6,0.9-4.8,2.6-6.5
c1.7-1.7,4.4-2.6,8.1-2.6s6.4,0.9,8.1,2.6c1.7,1.7,2.6,3.9,2.6,6.5v2.8c0,2.6-0.9,4.7-2.6,6.5
C360.5,177.2,357.8,178.1,354.1,178.1z"/>
</g>
<g>
<path class="st0" d="M156.4,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H156.4z"/>
<path class="st0" d="M196.8,307l-3-6.3h-0.1v21.1h-6.4v-31.4h7.5l8.6,14.8l3,6.3h0.1v-21.1h6.4v31.4h-7.5L196.8,307z"/>
<path class="st0" d="M243.1,296.5v25.3h-6.8v-25.3h-8.5v-6.1h23.8v6.1H243.1z"/>
<path class="st0" d="M266.4,321.9v-31.4h21.4v6.1h-14.5v6.4h12.5v6h-12.5v6.8h14.5v6.1H266.4z"/>
<path class="st0" d="M310.7,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M317.9,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H317.9z"/>
<path class="st0" d="M343.2,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H343.2z"/>
<path class="st0" d="M372.8,306.2c0-10.7,5.5-16.2,13.8-16.2c8.3,0,13.8,5.6,13.8,16.2c0,10.7-5.5,16.2-13.8,16.2
C378.3,322.4,372.8,316.8,372.8,306.2z M393.2,308.5v-4.7c0-5-2.5-7.8-6.6-7.8s-6.6,2.8-6.6,7.8v4.7c0,5,2.5,7.8,6.6,7.8
S393.2,313.5,393.2,308.5z"/>
<path class="st0" d="M423.1,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M430.3,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H430.3z"/>
</g>
<line class="st2" x1="154.1" y1="275.5" x2="440.2" y2="275.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.0" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="153 96 289.3 227.5" xml:space="preserve">
<style type="text/css">
.st0{fill:#314052;}
.st1{fill:#2C9FDA;}
.st2{fill:none;stroke:#2C9FDA;stroke-width:3;stroke-miterlimit:10;}
</style>
<g>
<g>
<g>
<g>
<path class="st0" d="M156.8,262.4V158.9h40.7c7.5,0,13.8,1.1,18.9,3.4c5,2.3,8.8,5.4,11.3,9.4c2.5,4,3.8,8.6,3.8,13.8
c0,4.1-0.8,7.6-2.4,10.7c-1.6,3-3.8,5.5-6.6,7.4c-2.8,1.9-5.9,3.3-9.5,4.1v1c3.9,0.1,7.5,1.2,10.9,3.3c3.4,2.1,6.2,4.9,8.3,8.6
c2.1,3.7,3.2,8.1,3.2,13.2c0,5.5-1.3,10.3-4,14.7c-2.7,4.3-6.6,7.7-11.8,10.2c-5.2,2.5-11.7,3.7-19.5,3.7H156.8z M177.8,202.4
h16.6c3,0,5.7-0.5,8.1-1.6c2.4-1.1,4.3-2.6,5.6-4.7c1.4-2,2-4.4,2-7.2c0-3.8-1.3-6.8-4-9.2c-2.6-2.4-6.4-3.5-11.3-3.5h-17V202.4
z M177.8,244.9H196c6.2,0,10.7-1.2,13.5-3.6c2.8-2.4,4.3-5.6,4.3-9.5c0-2.9-0.7-5.5-2.1-7.7c-1.4-2.2-3.4-4-6-5.3
c-2.6-1.3-5.6-1.9-9.2-1.9h-18.8V244.9z"/>
<path class="st0" d="M249.4,262.4v-15.3l36.4-34.4c3.1-3.1,5.8-5.8,7.9-8.3c2.1-2.5,3.8-4.9,4.9-7.3c1.1-2.4,1.7-5,1.7-7.7
c0-3.1-0.7-5.8-2.1-8c-1.4-2.2-3.3-3.9-5.7-5.1c-2.4-1.2-5.1-1.8-8.2-1.8c-3.2,0-6,0.6-8.3,1.9c-2.4,1.3-4.2,3.1-5.5,5.6
c-1.3,2.4-1.9,5.3-1.9,8.7h-20.1c0-6.7,1.5-12.6,4.6-17.5c3.1-5,7.3-8.8,12.7-11.5c5.4-2.7,11.7-4.1,18.8-4.1
c7.3,0,13.6,1.3,19,3.9c5.4,2.6,9.6,6.2,12.7,10.8c3,4.6,4.5,9.9,4.5,15.8c0,3.8-0.7,7.6-2.2,11.4c-1.5,3.8-4.2,8-8,12.6
c-3.8,4.7-9.3,10.2-16.3,16.7l-15.5,15.4v0.8h43.4v17.4H249.4z"/>
</g>
</g>
<g>
<path class="st0" d="M345,262.2v-74.1h18.2v74.1H345z"/>
<path class="st0" d="M395.1,262.2h-18.2v-74.1h18.2v12.4h0.7c1.5-4,3.9-7.3,7.2-10c3.3-2.7,7.8-4,13.6-4c7.6,0,13.4,2.5,17.5,7.6
c4.1,5.1,6.2,12.2,6.2,21.4v46.8h-18.2v-45c0-5.3-0.9-9.3-2.8-11.9c-1.9-2.6-5-4-9.4-4c-2.5,0-4.8,0.5-7.1,1.3
c-2.3,0.9-4.1,2.2-5.5,4c-1.4,1.8-2.1,4-2.1,6.6V262.2z"/>
</g>
<path class="st1" d="M354.1,152.3c2.5,0,4.7,0.3,6.6,0.8c-14-32.8-49-56-89.9-56c-41.5,0-76.8,23.9-90.5,57.3h14.3
c8.8-30.8,39.6-53.5,76.1-53.5c36.1,0,66.6,22.2,75.8,52.5C348.6,152.7,351.1,152.3,354.1,152.3z"/>
<path class="st0" d="M354.1,178.1c-3.7,0-6.4-0.9-8.1-2.6c-1.7-1.7-2.6-3.9-2.6-6.5v-2.8c0-2.6,0.9-4.8,2.6-6.5
c1.7-1.7,4.4-2.6,8.1-2.6s6.4,0.9,8.1,2.6c1.7,1.7,2.6,3.9,2.6,6.5v2.8c0,2.6-0.9,4.7-2.6,6.5
C360.5,177.2,357.8,178.1,354.1,178.1z"/>
</g>
<g>
<path class="st0" d="M156.4,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H156.4z"/>
<path class="st0" d="M196.8,307l-3-6.3h-0.1v21.1h-6.4v-31.4h7.5l8.6,14.8l3,6.3h0.1v-21.1h6.4v31.4h-7.5L196.8,307z"/>
<path class="st0" d="M243.1,296.5v25.3h-6.8v-25.3h-8.5v-6.1h23.8v6.1H243.1z"/>
<path class="st0" d="M266.4,321.9v-31.4h21.4v6.1h-14.5v6.4h12.5v6h-12.5v6.8h14.5v6.1H266.4z"/>
<path class="st0" d="M310.7,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M317.9,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H317.9z"/>
<path class="st0" d="M343.2,321.9v-5.4h4v-20.5h-4v-5.4h14.9v5.4h-4v20.5h4v5.4H343.2z"/>
<path class="st0" d="M372.8,306.2c0-10.7,5.5-16.2,13.8-16.2c8.3,0,13.8,5.6,13.8,16.2c0,10.7-5.5,16.2-13.8,16.2
C378.3,322.4,372.8,316.8,372.8,306.2z M393.2,308.5v-4.7c0-5-2.5-7.8-6.6-7.8s-6.6,2.8-6.6,7.8v4.7c0,5,2.5,7.8,6.6,7.8
S393.2,313.5,393.2,308.5z"/>
<path class="st0" d="M423.1,321.9h-6.8v-31.4h14.9c5.8,0,9.5,4,9.5,10c0,4.2-1.9,7.6-5.7,9l6.2,12.3h-7.6l-5.5-11.5h-4.9V321.9z
M430.3,304.6c2,0,3.3-1.1,3.3-3.1v-2c0-2-1.3-3.1-3.3-3.1h-7.2v8.2H430.3z"/>
</g>
<line class="st2" x1="154.1" y1="275.5" x2="440.2" y2="275.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -5,45 +5,83 @@ return [
'b2in' => [
'announcement_bar' => [
'enabled' => true,
'id' => 'azizi-launch-2026',
'badge' => 'NEW LAUNCH',
'text' => 'Azizi Creek Views 4 – Exklusives Off-Market-Projekt in Dubai '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'ab'),
'link_text' => 'Exposé ansehen',
'link_url' => '/immobilien/azizi-creek-views-4',
'id' => 'interior-rotpunkt-2026',
'badge' => 'NEU',
'text' => 'Rotpunkt Küchen jetzt bei B2in Interior – jetzt Beratungstermin sichern',
'link_text' => 'Termin anfragen',
'link_url' => '/contact',
],
'appointment_widget' => [
'button_label' => 'Termin anfragen',
'title' => 'Termin anfragen',
'intro' => 'Sagen Sie uns kurz, worum es geht – wir melden uns und stimmen einen Termin ab. Persönlich, unverbindlich.',
'success' => 'Vielen Dank! Wir melden uns zeitnah bei Ihnen und stimmen den Termin ab.',
'whatsapp_text' => 'Hallo B2in Interior, ich hätte gern einen Beratungstermin.',
],
'referenzen_hero' => [
'title' => 'Unsere <span class="text-secondary">Referenzen</span>',
'text' => 'Hier zeigen wir Projekte, die wir tatsächlich ausgeführt haben – keine Entwürfe und keine Visualisierungen. Der Bereich wächst mit jedem Auftrag, den wir zeigen dürfen.',
'image' => '',
'image_alt' => 'B2in Interior – realisierte Projekte',
],
'reference_projects' => [
'title' => 'Realisierte <span class="text-secondary">Projekte</span>',
'text' => 'Ausgeführte Arbeiten aus unseren Aufträgen, sortiert nach Marke. Entwürfe und Visualisierungen finden Sie bewusst getrennt davon auf den Markenseiten.',
'empty' => 'Die ersten Projekte sind in Arbeit und erscheinen hier, sobald wir sie zeigen dürfen. Bis dahin schauen wir uns gern gemeinsam an, was für Ihre Räume möglich ist.',
],
'display_feed' => [
'title' => 'Aktuelles aus unserem <span class="text-secondary">Showroom</span>',
'text' => 'Was gerade auf dem Bildschirm im Showroom läuft, sehen Sie hier live – dieselbe Ansicht, derselbe Stand.',
'note' => 'Der Inhalt wechselt automatisch.',
'link_text' => 'Zu den Referenzen',
'link_url' => '/referenzen',
'iframe_title' => 'Live-Ansicht des Showroom-Displays',
'display_id' => '',
],
'footer' => [
'claim' => 'Premium-Einrichtung. <span class="text-secondary">Persönlich gemacht.</span>',
'byline' => 'Marcel Scheibe',
'links' => [
['label' => 'Häufige Fragen', 'url' => '/faq'],
['label' => 'Datenschutz', 'url' => '/privacy'],
['label' => 'AGB', 'url' => '/terms'],
['label' => 'Cookie-Richtlinie', 'url' => '/cookie-policy'],
['label' => 'Impressum', 'url' => '/impressum'],
],
],
'header' => [
'portal_login' => 'Partner-Login',
'navigation' => [
['label' => 'Home', 'url' => '/'],
['label' => 'Immobilien', 'url' => '/immobilien'],
['label' => 'Netzwerk', 'url' => '/netzwerk'],
['label' => 'Marken', 'url' => '/marken'],
['label' => 'Referenzen', 'url' => '/referenzen'],
['label' => 'Magazin', 'url' => '/magazin'],
['label' => 'FAQ', 'url' => '/faq'],
['label' => 'Immobilien', 'url' => '/immobilien'],
['label' => 'Über B2in', 'url' => '/about'],
],
],
'hero' => [
'title' => '<span class="text-secondary">B2in</span> – Wo exklusive Immobilien-Investments auf smartes <span class="text-secondary">Interior</span> treffen.',
'subtitle' => 'Ihr Partner für renditestarke internationale Immobilien und globales Supply-Chain-Management im Interior-Bereich.',
'title' => '<span class="text-secondary">B2in Interior</span> – Premium-Einrichtung von starken Marken. Für Ihr Zuhause. Für Ihr Projekt.',
'subtitle' => 'Von der einzelnen Maßanfertigung bis zur vollständigen Ausstattung – mit CABINET, Rotpunkt Küchen, Febrü und einem Netzwerk regionaler Fachexperten.',
'image' => 'b2in/hero-room.jpg',
'image_alt' => 'B2in – Internationale Immobilien und exklusive Einrichtungskonzepte',
'cta1_text' => 'Zu den Immobilien-Projekten',
'cta1_link' => '/immobilien',
'cta2_text' => 'Unser Netzwerk',
'cta2_link' => '/netzwerk',
'image_alt' => 'B2in Interior – Premium-Einrichtung von starken Marken',
'cta1_text' => 'Unsere Marken entdecken',
'cta1_link' => '/marken',
'cta2_text' => 'Termin anfragen',
'cta2_link' => '/contact',
'stats' => [
'Internationale Immobilien',
'Exklusive Einrichtung',
'Premium-Marken',
'Maßgefertigte Lösungen',
'Persönliche Beratung',
],
'card_title' => 'B2in',
'card_text' => 'Connecting Design and <span class="text-secondary">Property</span>',
'card_title' => 'B2in Interior',
'card_text' => 'Einrichtung, <span class="text-secondary">die bleibt.</span>',
],
'founder_bar' => [
'image' => 'b2in/marcel-scheibe.jpg',
'name' => 'Marcel Scheibe',
'title' => 'Gründer & CEO, B2in',
'statement' => 'B2in by Marcel Scheibe – <span class="text-secondary">Ihr persönlicher Partner</span> für internationale Immobilien und exklusive Einrichtungskonzepte.',
'statement' => 'B2in by Marcel Scheibe – <span class="text-secondary">Ihr persönlicher Partner</span> für hochwertige Einrichtung und starke Marken.',
],
'synergie_section' => [
'title' => 'Zwei Welten. <span class="text-secondary">Ein Netzwerk.</span>',
@ -81,8 +119,8 @@ return [
'vision_section' => [
'title' => 'Gebaut auf Expertise und Vertrauen',
'paragraphs' => [
'<strong>B2in (Bridges2international)</strong> verbindet zwei Welten: internationale Immobilien und exklusive Einrichtungskonzepte. Als Ihr persönlicher Partner navigiere ich Sie durch beide Bereiche – mit Expertise, Netzwerk und dem Anspruch, dass jede Entscheidung auf Vertrauen basiert.',
'Ob ein <strong>Investment in Dubai</strong>, eine Villa in Lissabon oder die <strong>maßgeschneiderte Einrichtung</strong> Ihres neuen Zuhauses durch lokale Fachexperten – bei B2in laufen alle Fäden zusammen.',
'<strong>B2in</strong> stand ursprünglich für <em>Bridges to International</em>. Heute steht es ebenso für <em>Bridges to Interior</em>: hochwertige Einrichtung mit persönlicher Handschrift. Als Ihr Ansprechpartner verbinde ich ausgewählte Hersteller, regionale Fachexperten und ein verlässliches Netzwerk – damit aus Räumen Orte werden, die bleiben.',
'Ob <strong>maßgefertigter Einbauschrank</strong>, neue <strong>Küche</strong> oder die <strong>vollständige Ausstattung</strong> eines Objekts: Bei B2in laufen alle Fäden zusammen. Und wer über die Einrichtung hinausdenkt, findet bei uns auch den Zugang zu internationalen Immobilien.',
'<strong>Regional verwurzelt, international vernetzt</strong> – das ist B2in.',
],
'image' => 'b2in/marcel-scheibe-about.jpg',
@ -132,10 +170,131 @@ return [
'image_alt' => 'Das Ergebnis für den Kunden – Immobilie und Einrichtung aus einer Hand',
'image_caption' => 'Immobilie und Einrichtung – aus einer Hand',
],
'audience_switch' => [
'title' => 'Für Ihr Zuhause. <span class="text-secondary">Für Ihr Projekt.</span>',
'subtitle' => 'Ob einzelnes Möbelstück oder komplette Ausstattung – wir begleiten Sie mit denselben Marken, demselben Anspruch und einem persönlichen Ansprechpartner.',
'cards' => [
[
'icon' => 'home',
'title' => 'Einrichtung für Ihr Zuhause',
'description' => '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. Besuchen Sie unsere Showrooms oder vereinbaren Sie eine persönliche Beratung.',
'button_text' => 'Beratung anfragen',
'button_link' => '/contact',
],
[
'icon' => 'building-office-2',
'title' => 'Ausstattung für Ihr Projekt',
'description' => '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.',
'button_text' => 'Projekt besprechen',
'button_link' => '/contact',
],
],
],
'core_brands' => [
'title' => 'Unsere <span class="text-secondary">Kernmarken</span>',
'subtitle' => 'Wir arbeiten nicht mit beliebigen Katalogen, sondern mit ausgewählten Herstellern, hinter denen wir stehen – jede Marke ein Spezialist auf ihrem Gebiet.',
'brands' => [
[
'name' => 'CABINET',
'tagline' => 'Einbauschränke nach Maß.',
'teaser' => 'Maßgefertigte Schranklösungen, geplant bis auf den Millimeter – gefertigt für Räume, in denen Standard nicht genügt. Erleben Sie die Möglichkeiten in unserem Showroom in Bielefeld.',
'image' => '',
'logo' => '',
'link_text' => 'Mehr zu CABINET',
],
[
'name' => 'Rotpunkt Küchen',
'tagline' => 'Küchen, die Haltung zeigen.',
'teaser' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant, nachhaltig gedacht und gebaut für den Mittelpunkt Ihres Zuhauses. Beratung und Planung in unserem Küchenstudio.',
'image' => '',
'logo' => '',
'link_text' => 'Mehr zu Rotpunkt',
],
[
'name' => 'Febrü',
'tagline' => 'Arbeiten, wo man gerne ist.',
'teaser' => 'Möbel für konzentriertes Arbeiten – vom durchdachten Home-Office bis zur kompletten Büroeinrichtung. Ergonomie und Design für Zuhause und fürs Unternehmen.',
'image' => '',
'logo' => '',
'link_text' => 'Mehr zu Febrü',
],
],
],
'sponsoring' => [
'title' => 'Regional <span class="text-secondary">verbunden</span>',
'subtitle' => 'Wir unterstützen Vereine und Organisationen aus der Region – weil Verwurzelung mehr ist als eine Adresse.',
// ⚠️ Die Kurztexte sind Entwürfe – von Marcel bestätigen lassen, bevor es live geht.
'logos' => [
[
'name' => 'DSC Arminia Bielefeld',
'image' => 'sponsoring/arminia-bielefeld.png',
'text' => 'Traditionsverein aus Bielefeld – der Stadt, in der wir zu Hause sind.',
],
[
'name' => 'TC Bad Waldliesborn-Horn',
'image' => 'sponsoring/tc-bad-waldliesborn-horn.jpg',
'text' => 'Tennisclub aus Bad Waldliesborn. Vereinssport, der Menschen vor Ort zusammenbringt.',
],
[
'name' => 'TC Hövelhof',
'image' => 'sponsoring/tc-hoevelhof.png',
'text' => 'Tennisclub aus Hövelhof – Breitensport im Kreis Paderborn.',
],
],
],
'immobilien_teaser' => [
'title' => 'Außerdem: <span class="text-secondary">Internationale Immobilien</span>',
'text' => 'Neben der Einrichtung begleiten wir ausgewählte internationale Immobilienprojekte – persönlich kuratiert und mit klarem Blick auf Substanz.',
'link_text' => 'Zu den Immobilien',
'link_url' => '/immobilien',
],
'marken_hero' => [
'title' => 'Unsere <span class="text-secondary">Marken</span>',
'text' => 'Wir arbeiten nicht mit beliebigen Katalogen, sondern mit ausgewählten Herstellern, hinter denen wir stehen. Jede Marke ist Spezialist auf ihrem Gebiet – und jede bringt mit, was gute Einrichtung ausmacht: Qualität, die man sieht, und Beratung, die zuhört.',
// Platzhalter aus dem Altbestand – gegen ein eigenes Motiv tauschen, sobald geliefert.
'image' => 'b2in/b2a.jpg',
'image_alt' => 'B2in Interior – unsere Kernmarken',
],
'marken_network' => [
'title' => 'Mehr als Marken: <span class="text-secondary">unser Netzwerk</span>',
'paragraphs' => [
'Gute Einrichtung endet nicht beim Möbelstück. Deshalb arbeiten wir mit Fachbetrieben und Handwerkern aus der Region zusammen – von der Aufmaß-Erstellung über die Montage bis zu angrenzenden Gewerken.',
'Für Sie bedeutet das: ein Ansprechpartner, der die Fäden zusammenhält, statt vieler Einzelgespräche. Und Partner, die wir kennen und für deren Arbeit wir geradestehen.',
],
'points' => [
[
'icon' => 'pencil-square',
'title' => 'Aufmaß & Planung',
'description' => 'Wir kommen zu Ihnen, nehmen Maß und planen mit Ihnen gemeinsam.',
],
[
'icon' => 'wrench-screwdriver',
'title' => 'Montage durch Fachbetriebe',
'description' => 'Handwerker aus der Region, die wir persönlich kennen.',
],
[
'icon' => 'user',
'title' => 'Ein Ansprechpartner',
'description' => 'Von der ersten Idee bis zur Übergabe koordiniert aus einer Hand.',
],
],
],
'marken_growth' => [
'enabled' => true,
'title' => 'Unsere <span class="text-secondary">Markenwelt wächst</span>',
'text' => 'Wir erweitern unser Portfolio laufend um Hersteller, die zu unserem Anspruch passen. Sie vermissen eine bestimmte Marke oder haben einen konkreten Wunsch? Sprechen Sie uns an – oft finden wir über unser Netzwerk genau das Richtige.',
],
'marken_cta' => [
'title' => 'Lernen Sie unsere <span class="text-secondary">Marken</span> kennen',
'text' => 'Am besten dort, wo Sie sie anfassen können: in unseren Showrooms. Vereinbaren Sie einen unverbindlichen Beratungstermin – wir nehmen uns Zeit.',
'button_text' => 'Termin anfragen',
'button_link' => '/contact',
'whatsapp_text' => 'Per WhatsApp schreiben',
],
'cta_section' => [
'title' => 'Ihr nächster <span class="text-primary">Schritt</span>',
'subtitle' => 'Ob Immobilien-Investment, Supply-Chain-Partnerschaft oder Einrichtungs-Netzwerk – sprechen Sie direkt mit uns.',
'button_text' => 'Kontakt aufnehmen',
'subtitle' => 'Ob einzelnes Möbelstück, komplette Einrichtung oder Projektausstattung – sprechen Sie mit uns. Persönlich, unkompliziert, verbindlich.',
'button_text' => 'Termin anfragen',
'button_link' => '/contact',
],
'immobilien_hero' => [
@ -377,14 +536,14 @@ return [
// NETZWERK SOFT LAUNCH - Kombinierte Teaser-Seite
// ============================================================
'netzwerk_hero' => [
'title' => 'Das <span class="text-secondary">B2in Ökosystem</span> – Immobilien trifft Interior.',
'subtitle' => 'Wir bauen aktuell das intelligenteste Local-for-Local Einrichtungsnetzwerk. Als Immobilienkunde von B2in profitieren Sie in Zukunft exklusiv von unserem Closed-Shop.',
'title' => 'Das <span class="text-secondary">B2in Netzwerk</span> – regionale Fachexperten und starke Marken.',
'subtitle' => 'Wir verbinden Fachhändler aus der Region mit ausgewählten europäischen Herstellern – damit Sie vor Ort beraten werden und trotzdem Zugriff auf das gesamte Sortiment haben.',
'image' => 'b2in/ecosystem-hero.jpg',
'image_alt' => 'B2in Ökosystem – Einrichtung und Immobilien',
'image_alt' => 'B2in Netzwerk – Einrichtung und regionale Fachexperten',
],
'netzwerk_image_break' => [
'image' => 'b2in/ecosystem-hero.jpg',
'image_alt' => 'B2in Ökosystem – Einrichtung und Immobilien',
'image_alt' => 'B2in Netzwerk – Einrichtung und regionale Fachexperten',
'quote' => 'Wir verbinden, was zusammengehört: Immobilien und Einrichtung.',
'author' => 'Marcel Scheibe',
],
@ -406,7 +565,7 @@ return [
[
'icon' => 'globe-alt',
'title' => 'Für Entwickler & Marken',
'description' => 'Deutsche Vertragssicherheit für internationale Immobilienentwickler. Supply-Chain-Management mit Fokus auf Termintreue, Qualitätskontrolle und Durchsetzungskraft.',
'description' => 'Deutsche Vertragssicherheit für Objekte und Projektentwicklungen: Beschaffung aus einer Hand, mit Fokus auf Termintreue, Qualitätskontrolle und Durchsetzungskraft.',
'status' => 'Vorab-Registrierung möglich',
],
],
@ -422,7 +581,7 @@ return [
'title' => 'Unser Premiumpartner: <span class="text-secondary">CABINET Einbauschränke</span>',
'lead' => 'Maßgefertigte Exzellenz für höchste Ansprüche.',
'paragraphs' => [
'Im B2in-Ökosystem setzen wir auf Qualität, die Bestand hat. Wir sind stolz darauf, CABINET als Premiumpartner in unserem Netzwerk zu präsentieren. Der CABINET Store in Bielefeld (unser physischer Ankerpunkt) steht für maßgefertigte Einbaulösungen, die Design, Funktionalität und handwerkliche Präzision perfekt vereinen.',
'Im B2in-Netzwerk setzen wir auf Qualität, die Bestand hat. Wir sind stolz darauf, CABINET als Premiumpartner in unserem Netzwerk zu präsentieren. Der CABINET Store in Bielefeld (unser physischer Ankerpunkt) steht für maßgefertigte Einbaulösungen, die Design, Funktionalität und handwerkliche Präzision perfekt vereinen.',
'Ob begehbare Kleiderschränke, smarte Raumlösungen oder exklusive Innenausbauten – CABINET liefert den Premium-Standard, den unsere Immobilien-Investoren und Entwickler für ihre High-End-Projekte fordern. Diese Partnerschaft ist ein lebendiger Beweis für unsere Philosophie: Wir verbinden internationale Immobilien mit deutscher Einrichtungsexzellenz.',
],
'image' => 'b2in/cabinet_logo.png',
@ -573,63 +732,71 @@ return [
],
'faq' => [
'title' => 'Häufig gestellte Fragen',
'subtitle' => 'Hier finden Sie Antworten auf die häufigsten Fragen zu B2in und unserem Ökosystem.',
'subtitle' => 'Antworten auf die Fragen, die uns am häufigsten gestellt werden – zur Beratung, zur Maßanfertigung und zur Ausstattung von Projekten.',
'questions' => [
[
'question' => 'Was ist B2in und welche Services bieten Sie an?',
'answer' => 'B2in ist die Brücke zwischen internationalen Premium-Immobilien und exklusiven Einrichtungskonzepten. Wir bieten Immobilieninvestoren Zugang zu Off-Market-Projekten (Fokus: Dubai) und begleiten sie durch den gesamten Kaufprozess. Gleichzeitig bieten wir Bauträgern ein knallhartes Supply-Chain-Management für die Beschaffung deutscher Qualitätsmöbel und bauen ein innovatives Einrichtungsnetzwerk für lokale Händler auf.',
'answer' => 'B2in Interior steht für hochwertige Einrichtung von starken Marken – CABINET, Rotpunkt Küchen und Febrü – ergänzt um ein Netzwerk regionaler Fachexperten. Wir begleiten Privatkunden von der einzelnen Maßanfertigung bis zur kompletten Einrichtung und statten Objekte, Büros und Projektentwicklungen vollständig aus. Darüber hinaus begleiten wir ausgewählte internationale Immobilienprojekte.',
],
[
'question' => 'Was bedeutet Supply-Chain-Management bei B2in?',
'answer' => 'Für internationale Immobilienentwickler fungieren wir als verlängerter Arm in Deutschland. Wir übernehmen die operative und strategische Steuerung der Möbel- und Innenausstattungsbeschaffung. Das bedeutet: Wir sichern Lieferverträge ab, überwachen Meilensteine direkt bei den Herstellern und eskalieren bei Abweichungen sofort auf Managementebene. Unser Ziel ist absolute Vertragssicherheit und termingerechte Lieferung ohne Reibungsverluste.',
'question' => 'Was bedeutet Projektausstattung bei B2in?',
'answer' => 'Für Objekte, Büros und Projektentwicklungen übernehmen wir die vollständige Ausstattung – koordiniert aus einer Hand. Wir steuern die Beschaffung von Möbeln und Innenausstattung operativ und strategisch. Das bedeutet: Wir sichern Lieferverträge ab, überwachen Meilensteine direkt bei den Herstellern und eskalieren bei Abweichungen sofort auf Managementebene. Unser Ziel ist absolute Vertragssicherheit und termingerechte Lieferung ohne Reibungsverluste.',
],
[
'question' => 'Wie kann ich Partner bei B2in werden?',
'answer' => 'Unser Netzwerk richtet sich an Immobilienentwickler, Makler und den regionalen Möbelfachhandel. Wenn Sie als Entwickler deutsche Beschaffungssicherheit suchen oder als Händler Teil unseres künftigen "Local for Local"-Netzwerks werden möchten, um qualifizierte Leads von Immobilienkäufern zu erhalten, nutzen Sie einfach unser Kontaktformular. Wir prüfen jede Partnerschaft individuell auf Qualität und Passgenauigkeit.',
'question' => 'Wie kann ich Partner bei B2in Interior werden?',
'answer' => 'Unser Netzwerk umfasst Fachbetriebe und Handwerker rund um die Einrichtung – von der Montage über das Aufmaß bis zu angrenzenden Gewerken. Wenn Sie Interesse an einer Zusammenarbeit haben, schreiben Sie uns über das Kontaktformular. Wir prüfen jede Partnerschaft einzeln, weil wir für die Arbeit unserer Partner geradestehen.',
],
[
'question' => 'Wie funktioniert das Immobilien-Investment über B2in?',
'answer' => 'Wir vermitteln nicht nur, wir begleiten Sie. Marcel Scheibe ist Ihr persönlicher Berater, der den Markt (insbesondere Dubai) aus eigener Investorensicht kennt. Nach einer Bedarfsanalyse stellen wir Ihnen exklusive Projekte vor (z. B. von Azizi Developments). Der Kaufprozess selbst ist durch das staatliche Escrow-System in Dubai maximal abgesichert. Ihr besonderer Vorteil: Als B2in-Kunde erhalten Sie im Anschluss exklusiven Zugang zu unserem Netzwerk, um Ihr Investment schlüsselfertig (Turnkey) und renditeoptimiert einrichten zu lassen.',
'question' => 'Wie läuft eine Beratung bei Ihnen ab?',
'answer' => 'Am Anfang steht ein Gespräch – im Showroom, bei Ihnen zu Hause oder telefonisch. Wir schauen uns an, welche Räume es betrifft, wie Sie sie nutzen und was Ihnen wichtig ist. Danach folgt das Aufmaß vor Ort, anschließend die Planung. Sie sehen Ihre Lösung, bevor etwas gefertigt wird. Termine vereinbaren Sie über das Formular auf dieser Seite oder direkt telefonisch.',
],
[
'question' => 'Was macht B2in zu einem vertrauenswürdigen Partner?',
'answer' => 'Vertrauen entsteht durch Transparenz und eigene Markterfahrung. B2in-Gründer Marcel Scheibe investiert selbst in die Märkte, die wir anbieten, und ist regelmäßig vor Ort, um Baufortschritte zu prüfen. Wir verbinden die immense Dynamik internationaler Märkte (wie Dubai) mit deutscher Verlässlichkeit, striktem Vertragsmanagement und einem kuratierten Netzwerk von Premiumpartnern wie CABINET. Bei uns haben Sie immer einen persönlichen Ansprechpartner, der Ihre Interessen vertritt.',
'question' => 'Was macht B2in Interior zu einem verlässlichen Partner?',
'answer' => 'Wir arbeiten mit einer überschaubaren Zahl ausgewählter Hersteller statt mit beliebigen Katalogen – und stehen für deren Arbeit gerade. Hinter B2in Interior steht mit Marcel Scheibe ein Gesicht und ein fester Ansprechpartner, vom ersten Gespräch bis nach der Montage. Für die Gewerke rund um die Einrichtung arbeiten wir mit Fachbetrieben aus der Region zusammen, die wir kennen.',
],
],
'sections' => [
[
'title' => 'Fokus: Immobilien-Investoren (B2C)',
'title' => 'Für Ihr Zuhause',
'icon' => 'home-modern',
'questions' => [
[
'question' => 'Muss ich für den Immobilienkauf persönlich nach Dubai reisen?',
'answer' => 'Nein, der gesamte Kaufprozess kann vollständig digital und rechtsverbindlich aus der Ferne abgewickelt werden. Das Dubai Land Department bietet hierfür hochsichere Prozesse. Wir empfehlen unseren Investoren zwar gerne, sich vor Ort ein Bild von der Dynamik der Stadt zu machen, für den rechtssicheren Erwerb ist Ihre physische Anwesenheit jedoch nicht zwingend erforderlich.',
'question' => 'Muss ich gleich die ganze Wohnung einrichten?',
'answer' => 'Nein. Viele Aufträge beginnen mit einem einzigen Möbelstück – einem Einbauschrank für die Dachschräge, einer neuen Küche, einem Arbeitsplatz. Ob daraus mehr wird, entscheiden Sie. Wir planen so, dass eine spätere Ergänzung passt und nicht wie ein Fremdkörper wirkt.',
],
[
'question' => 'Was passiert nach dem Immobilienkauf? Helfen Sie bei der Einrichtung?',
'answer' => 'Genau hier liegt der große Vorteil des B2in-Ökosystems. Unser Service endet nicht mit dem Kaufvertrag. Als B2in-Kunde erhalten Sie exklusiven Zugang zu unserem Einrichtungsnetzwerk. Wir unterstützen Sie dabei, Ihre Immobilie renditeoptimiert und schlüsselfertig („Turnkey") mit Qualitätsmöbeln auszustatten – die perfekte Grundlage für eine lukrative Kurzzeitvermietung.',
'question' => 'Was kostet eine Maßanfertigung im Vergleich zu Möbeln von der Stange?',
'answer' => 'Das hängt vom Raum, den Materialien und der Ausstattung ab – eine seriöse Zahl lässt sich erst nach dem Aufmaß nennen. Was sich sagen lässt: Maßanfertigung nutzt Flächen, die ein Standardmöbel liegen lässt – Schrägen, Nischen, ungewöhnliche Höhen. Sie zahlen für den Raum, den Sie sonst verschenken. Das Angebot bekommen Sie vor der Fertigung, verbindlich und aufgeschlüsselt.',
],
[
'question' => 'Welche steuerlichen Vorteile bietet ein Investment in Dubai?',
'answer' => 'Dubai bietet ein weltweit einzigartiges wirtschaftliches Umfeld. Es fallen weder Einkommensteuer auf Mieteinnahmen noch Kapitalertragsteuer beim Verkauf der Immobilie an. In Kombination mit den starken Mietrenditen macht dies den Standort für internationale Anleger strategisch so wertvoll.',
'question' => 'Übernehmen Sie auch die Montage?',
'answer' => 'Ja. Lieferung und Montage laufen über uns beziehungsweise über Fachbetriebe aus unserem Netzwerk, mit denen wir regelmäßig arbeiten. Sie haben einen Ansprechpartner, der die Termine koordiniert – auch dann, wenn mehrere Gewerke beteiligt sind.',
],
[
'question' => 'Kann ich mir die Marken vorher ansehen?',
'answer' => 'Ja, im Showroom in Bielefeld lassen sich Oberflächen, Fronten und Beschläge in Ruhe anfassen und vergleichen. Fotos und Muster helfen bei der Vorauswahl, ersetzen aber nicht den direkten Eindruck. Vereinbaren Sie dafür am besten vorher einen Termin, damit wir uns Zeit nehmen können.',
],
],
],
[
'title' => 'Fokus: B2B-Partner (Makler & Händler)',
'title' => 'Für Projekte und Objekte',
'icon' => 'briefcase',
'questions' => [
[
'question' => 'Wie profitieren Immobilienmakler von einer Partnerschaft mit B2in?',
'answer' => 'B2in bietet Maklern ein exklusives Werkzeug zur Kundenbindung: Sie können Ihren Immobilienkäufern den Zugang zu unserem geschlossenen Einrichtungsnetzwerk als exklusives „Closing-Geschenk" überreichen. Gleichzeitig profitieren Sie als Makler durch unser technisches Clearing-System von einer passiven Lifetime-Vergütung an den Möbelumsätzen Ihrer Kunden.',
'question' => 'Ab welcher Größe lohnt sich eine Projektausstattung?',
'answer' => 'Eine feste Untergrenze gibt es nicht. Sinnvoll wird die Zusammenarbeit, sobald mehrere Einheiten oder mehrere Gewerke im Spiel sind und die Koordination zum eigentlichen Aufwand wird – bei Büroflächen, möblierten Wohneinheiten oder der Ausstattung eines ganzen Objekts.',
],
[
'question' => 'Was genau ist der „Local for Local" Marktplatz?',
'answer' => 'Unser „Local for Local"-Prinzip ist der Gegenentwurf zu anonymen Online-Möbelgiganten. Wir machen die sofort verfügbaren Bestände und Ausstellungsstücke regionaler Fachhändler (Säule „Local Express") für einen geschlossenen Kundenkreis transparent. So stärken wir den lokalen Handel vor Ort und bieten Käufern gleichzeitig Insider-Konditionen für Premium-Einrichtung.',
'question' => 'Wie stellen Sie Termintreue sicher?',
'answer' => 'Wir arbeiten mit festen Lieferverträgen, verfolgen die Meilensteine direkt beim Hersteller und melden Abweichungen, sobald sie sichtbar werden – nicht erst zum Liefertermin. Bei mehreren Gewerken übernehmen wir die Abstimmung untereinander, damit die Reihenfolge auf der Baustelle stimmt.',
],
[
'question' => 'Ist das Einrichtungsnetzwerk für jeden öffentlich zugänglich?',
'answer' => 'Nein. Um die exklusiven Insider-Konditionen und Rabatte unserer Hersteller und lokalen Händler zu schützen, agiert der B2in-Möbelmarktplatz als „Closed Shop". Der Zugang erfolgt ausschließlich über Einladungen – beispielsweise durch unsere Partner-Makler beim Erwerb einer Immobilie.',
'question' => 'Arbeiten Sie mit Architekten und Planungsbüros zusammen?',
'answer' => 'Ja. Wir übernehmen bestehende Planungen und setzen sie um oder steigen früher ein, wenn Möblierung und Grundriss noch aufeinander abgestimmt werden müssen. Für Sonderlösungen sprechen wir direkt mit den Herstellern.',
],
[
'question' => 'Wie werde ich Partner im regionalen Netzwerk?',
'answer' => 'Unser Netzwerk umfasst Fachbetriebe und Handwerker rund um die Einrichtung – von der Montage bis zu angrenzenden Gewerken. Wenn Sie Interesse haben, schreiben Sie uns über das Kontaktformular. Wir prüfen jede Zusammenarbeit einzeln, weil wir für die Arbeit unserer Partner geradestehen.',
],
],
],
@ -652,10 +819,13 @@ return [
],
'subjects' => [
'' => 'Wählen Sie einen Betreff',
'beratung' => 'Beratungstermin',
'massanfertigung' => 'Maßanfertigung für mein Zuhause',
'kueche' => 'Küchenplanung',
'supply_chain' => 'Ausstattung für Projekte',
'partnership' => 'Zusammenarbeit im Netzwerk',
'immobilien' => 'Internationale Immobilien',
'supply_chain' => 'Supply-Chain-Management',
'general' => 'Allgemeine Anfrage',
'partnership' => 'Partnerschaft',
'press' => 'Presse',
'career' => 'Karriere',
],
@ -704,14 +874,14 @@ return [
],
'about_hero' => [
'title' => '<span class="text-secondary">Über B2in:</span> Unsere Mission',
'quote' => '"Meine Mission ist es, zwei Welten zu verbinden, die zusammengehören: internationale Immobilien und exklusive Einrichtung. B2in gibt dem lokalen Fachexperten die digitalen Werkzeuge und den Immobilienentwickler den operativen Partner vor Ort.<br><br>Wir bauen Brücken – zwischen europäischem Design, internationalen Märkten und dem Zuhause der Menschen."',
'quote' => '"Gute Einrichtung entsteht nicht im Katalog, sondern im Gespräch. Deshalb arbeiten wir mit wenigen, ausgewählten Herstellern, die wir kennen – und mit Fachbetrieben aus der Region, für deren Arbeit wir geradestehen.<br><br>Mein Anspruch ist einfach: ein Ansprechpartner, vom ersten Aufmaß bis nach der Montage."',
'founder_name' => 'Marcel Scheibe',
'founder_title' => 'Gründer & CEO, B2in',
'image' => 'b2in/about-hero.jpg',
'image_alt' => 'Marcel Scheibe, Gründer und CEO von B2in',
'card_title' => 'B2in',
'card_text' => 'Connecting Design and <span class="text-secondary">Property</span>',
'card_text' => 'Premium-Einrichtung. <span class="text-secondary">Persönlich gemacht.</span>',
],
'broker_section' => [
@ -997,26 +1167,26 @@ return [
'timeline' => [
[
'title' => 'Die Idee',
'description' => '2024 erkannten wir eine entscheidende Lücke: Der lokale Fachhandel braucht digitale Sichtbarkeit und Immobilienkäufer suchen nach nahtlosen Einrichtungslösungen. Diese beiden Welten gehören zusammen.',
'description' => '2024 war der Ausgangspunkt eine einfache Beobachtung: Wer hochwertig einrichten will, findet online viele Kataloge – aber selten jemanden, der zuhört, aufmisst und am Ende geradesteht.',
'icon' => 'light-bulb',
],
[
'title' => 'Das Fundament',
'description' => 'Wir entwickeln die B2in-Plattform: ein Ökosystem, das lokale Einrichtungsexperten, europäische Hersteller und Immobilienprofis auf einer gemeinsamen Plattform verbindet – fair, transparent und technologiegestützt.',
'description' => 'Wir bauen das Netzwerk auf: ausgewählte Hersteller, Fachbetriebe und Handwerker aus der Region – verbunden durch dieselbe Grundhaltung, dass Qualität und Wort halten zusammengehören.',
'icon' => 'rocket-launch',
],
[
'title' => 'Die Erweiterung',
'description' => '2025/2026 erweitert B2in sein Ökosystem: Internationale Immobilien und Supply-Chain-Management für Entwickler werden zur dominanten Säule. Der lokale Möbelmarktplatz bleibt als starker ergänzender Bereich bestehen.',
'description' => '2026 rückt das Interior in den Mittelpunkt: Mit CABINET, Rotpunkt Küchen und Febrü stehen starke Kernmarken im Zentrum, ergänzt um ein Netzwerk regionaler Fachexperten. Internationale Immobilien bleiben als ergänzender Bereich bestehen.',
'icon' => 'globe-alt',
],
[
'title' => 'Die Vision',
'description' => 'B2in wird zum zentralen Netzwerk für "Design & Property" – regional verwurzelt, international vernetzt. In jeder Region der verlässliche Partner für Immobilien-Investments und exklusive Einrichtungskonzepte.',
'description' => 'B2in Interior wird in der Region die erste Adresse für hochwertige Einrichtung – vom einzelnen Einbauschrank bis zur vollständigen Ausstattung. Regional verwurzelt, international vernetzt.',
'icon' => 'star',
],
],
'summary' => 'Was als Vision begann, den lokalen Handel zu stärken, ist heute ein Netzwerk, das Immobilien und Einrichtung nahtlos verbindet. B2in schließt die Lücke zwischen internationalen Investments und lokaler Expertise – mit Marcel Scheibe als Gesicht und Ansprechpartner.',
'summary' => 'Aus der Idee, hochwertige Einrichtung wieder persönlich zu machen, ist ein Netzwerk aus starken Marken und regionalen Fachbetrieben geworden – mit Marcel Scheibe als Gesicht und festem Ansprechpartner.',
],
'about_image_break' => [
'image' => 'b2in/best-of-two-worlds.jpg',
@ -1026,37 +1196,37 @@ return [
],
'our_values' => [
'title' => 'Unsere <span class="text-secondary">Werte</span>',
'subtitle' => 'Diese sechs Grundpfeiler leiten unser tägliches Handeln und definieren, wer wir als Unternehmen sind und wofür wir stehen.',
'subtitle' => 'Sechs Grundsätze, an denen wir uns messen lassen – in der Beratung, in der Planung und nach der Montage.',
'values' => [
[
'title' => 'Innovation',
'description' => 'Wir entwickeln digitale Lösungen, die Immobilienprofis und lokale Einrichtungsexperten gleichermaßen einen echten Wettbewerbsvorteil verschaffen.',
'icon' => 'light-bulb',
'title' => 'Beratung statt Verkauf',
'description' => 'Wir fragen zuerst, wie Sie Ihre Räume nutzen, und schlagen erst danach etwas vor. Manchmal ist die richtige Antwort ein kleineres Möbelstück als gedacht.',
'icon' => 'chat-bubble-left-right',
],
[
'title' => 'Konnektivität',
'description' => 'Wir verbinden internationale Immobilienmärkte mit lokaler Expertise und europäische Manufakturen mit den Menschen, die ihre Produkte schätzen.',
'icon' => 'globe-alt',
'title' => 'Maßarbeit',
'description' => 'Schrägen, Nischen und ungewöhnliche Höhen sind kein Problem, sondern der Grund für eine Maßanfertigung. Wir planen den Raum, den ein Standardmöbel liegen lässt.',
'icon' => 'cube-transparent',
],
[
'title' => 'Qualität',
'description' => 'Kompromisslose Standards – bei der Auswahl unserer Partner, der Kuration der Einrichtung, der Überwachung von Lieferketten und der Technologie, die alles zusammenhält.',
'title' => 'Ausgewählte Marken',
'description' => 'Lieber wenige Hersteller, die wir kennen, als ein beliebiges Sortiment. Für die Marken, die wir führen, stehen wir gerade.',
'icon' => 'check-badge',
],
[
'title' => 'Vertrauen',
'description' => 'Transparente Modelle und verlässliche Partnerschaften sind unser Fundament. Hinter B2in steht ein Gesicht – und das Versprechen, dass wir liefern, was wir zusagen.',
'title' => 'Regional verwurzelt',
'description' => 'Wir arbeiten mit Fachbetrieben und Handwerkern aus der Region – Menschen, die wir kennen und die vor Ort sind, wenn es darauf ankommt.',
'icon' => 'map-pin',
],
[
'title' => 'Ein Ansprechpartner',
'description' => 'Vom ersten Gespräch über das Aufmaß bis nach der Montage bleibt der Kontakt derselbe. Auch dann, wenn mehrere Gewerke beteiligt sind.',
'icon' => 'user-group',
],
[
'title' => 'Nachhaltigkeit',
'description' => 'Wir stärken den lokalen Handel, optimieren Transportwege und sorgen dafür, dass internationale Beschaffung verantwortungsvoll und effizient abläuft.',
'icon' => 'arrow-path',
],
[
'title' => 'Design-Exzellenz',
'description' => 'Design ist der Kern unserer Wertschöpfung – von internationaler Architektur über kuratierte Einrichtung bis zur intuitiven Gestaltung unserer digitalen Plattform.',
'icon' => 'cube-transparent',
'title' => 'Verlässlichkeit',
'description' => 'Zusagen zu Terminen und Preisen halten wir ein. Wenn sich etwas verschiebt, sagen wir es, sobald wir es wissen – nicht erst am Liefertag.',
'icon' => 'shield-check',
],
],
],
@ -1478,6 +1648,73 @@ return [
],
],
'articles' => [
// ⚠️ Platzhalter-Beiträge zum neuen Interior-Auftritt. Inhaltlich bewusst allgemein
// gehalten (keine Referenzen, keine Zahlen, keine Namen) – vor dem Livegang von Marcel
// gegenlesen und bei Bedarf um echte Beispiele ergänzen.
6 => [
'id' => 6,
'slug' => 'massanfertigung-warum-der-millimeter-zaehlt',
'title' => '<span class="text-secondary">Nach Maß statt von der Stange:</span><br>Warum der Millimeter über den Raum entscheidet',
'subtitle' => 'Dachschrägen, Nischen, ungewöhnliche Höhen: Wo Standardmöbel aufgeben, fängt die Maßanfertigung erst an – und macht aus totem Raum nutzbare Fläche.',
'image' => 'b2in/stileigentum.jpg',
'category' => 'Einrichtung',
'date' => 'August 26, 2026',
'readTime' => '5 min read',
'author' => [
'name' => 'Marcel Scheibe',
'bio' => 'Gründer & CEO von B2in Interior. Mit Wurzeln in der Möbelbranche begleitet er Kunden von der Planung bis nach der Montage.',
'avatar' => 'b2in/marcel-scheibe.jpg',
],
'content' => [
'intro' => 'Fast jede Wohnung hat sie: die Ecke, in der nichts richtig passt. Die Schräge unter dem Dach, die Nische neben dem Schornstein, der Flur, der für einen Schrank zu schmal und für nichts zu breit ist. Wer dort ein Standardmöbel hinstellt, verliert doppelt – vorne bleibt ein Spalt, hinten bleibt Luft. Maßanfertigung setzt genau da an, und der Unterschied entsteht nicht am Möbel, sondern lange vorher: beim Aufmaß.',
'sections' => [
[
'title' => 'Aufmaß ist keine Formalie',
'content' => 'Wände sind selten gerade, Böden selten eben, und in Altbauten weicht die Höhe über die Länge eines Raums gern um mehrere Zentimeter ab. Ein Aufmaß vor Ort nimmt diese Abweichungen auf, statt sie zu ignorieren. Erst danach lässt sich planen, was am Ende auf den Millimeter passt – ohne Blenden, die etwas kaschieren müssen, und ohne Fugen, in denen sich Staub sammelt.',
],
[
'title' => 'Der Raum, den man sonst verschenkt',
'content' => 'Bei einer Dachschräge liegt der Gewinn nicht in der Optik, sondern in der Fläche. Ein Schrank, der der Schräge folgt, nutzt den Bereich, den ein gerader Korpus abschneidet – je nach Neigung ist das ein erheblicher Teil des Stauraums. Ähnlich in Nischen: Ein eingepasster Einbau bringt Tiefe, die ein freistehendes Möbel nie erreicht, weil es Abstand zur Wand braucht.',
],
[
'title' => 'Wo sich Maß nicht lohnt',
'content' => 'Ehrlich bleibt, wer auch die Gegenseite nennt: Bei geraden Wänden, üblichen Raumhöhen und Standardmaßen ist ein gutes Serienmöbel oft die vernünftigere Wahl – schneller lieferbar und günstiger. Die Frage lautet deshalb nicht „Maß oder nicht", sondern: Wo im Raum gibt es tatsächlich etwas zu gewinnen? Diese Antwort gehört an den Anfang der Beratung, nicht ans Ende.',
],
],
],
],
7 => [
'id' => 7,
'slug' => 'kueche-planen-vom-wunsch-zum-grundriss',
'title' => '<span class="text-secondary">Küche planen:</span><br>Wie aus Wünschen ein Grundriss wird',
'subtitle' => 'Bevor über Fronten und Farben gesprochen wird, entscheidet sich der Erfolg einer Küche an drei Fragen – und die haben mit Design zunächst wenig zu tun.',
'image' => 'b2in/style2own.jpg',
'category' => 'Küche',
'date' => 'August 20, 2026',
'readTime' => '6 min read',
'author' => [
'name' => 'Marcel Scheibe',
'bio' => 'Gründer & CEO von B2in Interior. Mit Wurzeln in der Möbelbranche begleitet er Kunden von der Planung bis nach der Montage.',
'avatar' => 'b2in/marcel-scheibe.jpg',
],
'content' => [
'intro' => 'Die meisten Küchengespräche beginnen bei der Front: matt oder glänzend, hell oder dunkel, Griff oder grifflos. Verständlich, denn das sieht man. Nur entscheidet sich an dieser Stelle noch gar nichts. Ob eine Küche im Alltag funktioniert, hängt an Fragen, die sich vor der ersten Farbmustertafel klären lassen – und die man später kaum noch korrigieren kann.',
'sections' => [
[
'title' => 'Frage eins: Wie kochen Sie wirklich?',
'content' => 'Nicht, wie Sie gern kochen würden – wie Sie es tatsächlich tun. Wer täglich frisch kocht, braucht Arbeitsfläche neben dem Herd und kurze Wege zwischen Kühlschrank, Spüle und Kochfeld. Wer die Küche vor allem als Treffpunkt nutzt, plant anders: mehr Platz zum Stehen, eine Fläche, an der jemand sitzen kann, während gekocht wird. Beides sind gute Küchen, aber es sind nicht dieselben.',
],
[
'title' => 'Frage zwei: Was gibt der Raum vor?',
'content' => 'Fenster, Türen, Anschlüsse und tragende Wände bestimmen den Rahmen, bevor irgendjemand etwas plant. Manches lässt sich verlegen, manches nicht – und was es kostet, sollte auf dem Tisch liegen, bevor die Planung darauf aufbaut. Auch die Deckenhöhe gehört dazu: Sie entscheidet, ob Oberschränke bis nach oben laufen können oder ob darüber eine Staubkante entsteht.',
],
[
'title' => 'Frage drei: Was soll in zehn Jahren noch stimmen?',
'content' => 'Eine Küche bleibt lange. Fronten lassen sich in vielen Systemen später tauschen, Korpusmaße und Grundriss praktisch nicht. Es lohnt sich deshalb, bei Auszügen, Beschlägen und Arbeitsplatte auf Haltbarkeit zu achten und die Aufmerksamkeit für Modisches auf die Teile zu lenken, die austauschbar sind. Erst danach kommt die Farbe – und dann darf sie auch mutig sein.',
],
],
],
],
1 => [
'id' => 1,
'title' => '<span class="text-secondary">Sicherheit statt Bürokratie:</span><br>Warum das Dubai Escrow-System deutsche Investoren überrascht',

View file

@ -16,9 +16,60 @@ return [
'menu_close' => 'Menü schließen',
'menu_open' => 'Menü öffnen',
'display_feed_title' => 'Live-Ansicht des Showroom-Displays',
'references_empty' => 'Die ersten Projekte erscheinen hier, sobald wir sie zeigen dürfen.',
// Terminanfrage-Widget
'appointment' => [
'name' => 'Name',
'email' => 'E-Mail',
'phone' => 'Telefon',
'topic' => 'Anliegen',
'topic_placeholder' => 'Bitte auswählen',
'preferred_date' => 'Wunschtermin',
'message' => 'Nachricht',
'message_placeholder' => 'Worum geht es? Gern in zwei Sätzen.',
'submit' => 'Anfrage senden',
'sending' => 'Wird gesendet …',
'success' => 'Vielen Dank! Wir melden uns zeitnah bei Ihnen und stimmen den Termin ab.',
'new_request' => 'Weitere Anfrage',
'date_in_past' => 'Bitte einen Termin ab heute wählen.',
'whatsapp_intro' => 'Lieber direkt schreiben? Wir bereiten die Nachricht mit Ihren Angaben vor.',
'whatsapp_text' => 'Hallo B2in Interior, ich hätte gern einen Beratungstermin.',
'whatsapp_note' => 'Öffnet WhatsApp mit vorbereitetem Text. Abgeschickt wird die Nachricht erst von Ihnen.',
],
// Kernmarken
'all_brands_cta' => 'Alle Marken ansehen',
'brands' => 'Marken',
'whatsapp' => 'Per WhatsApp schreiben',
'all_brands' => 'Alle Marken',
'brand_services' => 'Leistungen und Besonderheiten',
'brand_cta_title' => 'Interesse an :name?',
'brand_cta_text' => 'Vereinbaren Sie einen Termin – wir beraten Sie persönlich und unverbindlich.',
'request_appointment' => 'Termin anfragen',
'brand_website' => 'Zur Herstellerseite',
'other_brands' => 'Unsere weiteren Kernmarken',
'more_about' => 'Mehr zu :name',
// Markengalerie – realisierte Projekte vs. Entwuerfe
'brand_gallery_title' => 'Referenzen und Inspirationen',
'brand_gallery_intro' => 'Realisierte Projekte aus unseren Aufträgen – und Visualisierungen, die zeigen, was möglich ist.',
'brand_gallery_tab_projects' => 'Realisierte Projekte',
'brand_gallery_tab_inspirations' => 'Inspirationen und Entwürfe',
'brand_gallery_badge_project' => 'Realisiertes Projekt',
'brand_gallery_badge_inspiration' => 'Visualisierung',
'brand_gallery_inspiration_note' => 'Diese Darstellungen sind Entwürfe und Visualisierungen – keine ausgeführten Projekte.',
'previous' => 'Vorheriges Bild',
'next' => 'Nächstes Bild',
// Footer
'founder_ceo' => 'Gründer & CEO',
'legal_notice' => 'Impressum',
'privacy_policy' => 'Datenschutz',
'terms_of_service' => 'AGB',
'cookie_policy' => 'Cookie-Richtlinie',
'faq' => 'Häufige Fragen',
'cookie_settings' => 'Cookie-Einstellungen',
// Announcement Bar

View file

@ -5,45 +5,83 @@ return [
'b2in' => [
'announcement_bar' => [
'enabled' => true,
'id' => 'azizi-launch-2026',
'badge' => 'NEW LAUNCH',
'text' => 'Azizi Creek Views 4 – Exclusive Off-Market Project in Dubai '.\App\Helpers\PriceHelper::formatAed(1_125_000, 'from'),
'link_text' => 'View Exposé',
'link_url' => '/immobilien/azizi-creek-views-4',
'id' => 'interior-rotpunkt-2026',
'badge' => 'NEW',
'text' => 'Rotpunkt Küchen now available at B2in Interior – book your consultation',
'link_text' => 'Request an appointment',
'link_url' => '/contact',
],
'appointment_widget' => [
'button_label' => 'Request an appointment',
'title' => 'Request an appointment',
'intro' => 'Tell us briefly what it is about – we will get back to you and arrange a date. Personal, without obligation.',
'success' => 'Thank you! We will get back to you shortly to arrange the appointment.',
'whatsapp_text' => 'Hello B2in Interior, I would like to arrange an appointment.',
],
'referenzen_hero' => [
'title' => 'Our <span class="text-secondary">references</span>',
'text' => 'Here we show projects we have actually carried out – no drafts and no visualisations. The section grows with every project we are allowed to show.',
'image' => '',
'image_alt' => 'B2in Interior – completed projects',
],
'reference_projects' => [
'title' => 'Completed <span class="text-secondary">projects</span>',
'text' => 'Work carried out for our clients, sorted by brand. Drafts and visualisations are deliberately kept separate, on the brand pages.',
'empty' => 'The first projects are under way and will appear here as soon as we are allowed to show them. Until then, we are happy to look at what is possible for your rooms.',
],
'display_feed' => [
'title' => 'Live from our <span class="text-secondary">showroom</span>',
'text' => 'Whatever is currently running on the screen in the showroom is shown here live – the same view, the same state.',
'note' => 'The content changes automatically.',
'link_text' => 'See the references',
'link_url' => '/referenzen',
'iframe_title' => 'Live view of the showroom display',
'display_id' => '',
],
'footer' => [
'claim' => 'Premium interiors. <span class="text-secondary">Personally delivered.</span>',
'byline' => 'Marcel Scheibe',
'links' => [
['label' => 'FAQ', 'url' => '/faq'],
['label' => 'Privacy Policy', 'url' => '/privacy'],
['label' => 'Terms of Service', 'url' => '/terms'],
['label' => 'Cookie Policy', 'url' => '/cookie-policy'],
['label' => 'Legal Notice', 'url' => '/impressum'],
],
],
'header' => [
'portal_login' => 'Partner Login',
'navigation' => [
['label' => 'Home', 'url' => '/'],
['label' => 'Real Estate', 'url' => '/immobilien'],
['label' => 'Network', 'url' => '/netzwerk'],
['label' => 'Brands', 'url' => '/marken'],
['label' => 'References', 'url' => '/referenzen'],
['label' => 'Magazine', 'url' => '/magazin'],
['label' => 'FAQ', 'url' => '/faq'],
['label' => 'Real Estate', 'url' => '/immobilien'],
['label' => 'About B2in', 'url' => '/about'],
],
],
'hero' => [
'title' => '<span class="text-secondary">B2in</span> – Where Exclusive Real Estate Investments Meet Smart <span class="text-secondary">Interior</span> Design.',
'subtitle' => 'Your partner for high-yield international real estate and global supply chain management in the interior sector.',
'title' => '<span class="text-secondary">B2in Interior</span> – Premium furnishing from strong brands. For your home. For your project.',
'subtitle' => 'From a single bespoke piece to a complete fit-out – with CABINET, Rotpunkt Küchen, Febrü and a network of regional specialists.',
'image' => 'b2in/hero-room.jpg',
'image_alt' => 'B2in – International Real Estate and Exclusive Interior Concepts',
'cta1_text' => 'View Real Estate Projects',
'cta1_link' => '/immobilien',
'cta2_text' => 'Our Network',
'cta2_link' => '/netzwerk',
'image_alt' => 'B2in Interior – Premium furnishing from strong brands',
'cta1_text' => 'Discover our brands',
'cta1_link' => '/marken',
'cta2_text' => 'Request an appointment',
'cta2_link' => '/contact',
'stats' => [
'International Real Estate',
'Exclusive Furnishing',
'Personal Consulting',
'Premium brands',
'Bespoke solutions',
'Personal consulting',
],
'card_title' => 'B2in',
'card_text' => 'Connecting Design and <span class="text-secondary">Property</span>',
'card_title' => 'B2in Interior',
'card_text' => 'Interiors <span class="text-secondary">that last.</span>',
],
'founder_bar' => [
'image' => 'b2in/marcel-scheibe.jpg',
'name' => 'Marcel Scheibe',
'title' => 'Founder & CEO, B2in',
'statement' => 'B2in by Marcel Scheibe – <span class="text-secondary">Your personal partner</span> for international real estate and exclusive interior concepts.',
'statement' => 'B2in by Marcel Scheibe – <span class="text-secondary">Your personal partner</span> for high-quality furnishing and strong brands.',
],
'synergie_section' => [
'title' => 'Two Worlds. <span class="text-secondary">One Network.</span>',
@ -81,8 +119,8 @@ return [
'vision_section' => [
'title' => 'Built on Expertise and Trust',
'paragraphs' => [
'<strong>B2in (Bridges2international)</strong> connects two worlds: international real estate and exclusive interior concepts. As your personal partner, I guide you through both areas – with expertise, network, and the commitment that every decision is based on trust.',
'Whether an <strong>investment in Dubai</strong>, a villa in Lisbon, or the <strong>bespoke furnishing</strong> of your new home by local specialists – at B2in, everything comes together.',
'<strong>B2in</strong> originally stood for <em>Bridges to International</em>. Today it stands just as much for <em>Bridges to Interior</em>: high-quality furnishing with a personal signature. As your contact, I connect selected manufacturers, regional specialists and a dependable network – so that rooms become places that last.',
'Whether a <strong>bespoke fitted wardrobe</strong>, a new <strong>kitchen</strong> or the <strong>complete fit-out</strong> of a property: at B2in, everything comes together. And anyone thinking beyond the interior will also find access to international real estate here.',
'<strong>Locally rooted, internationally connected</strong> – that is B2in.',
],
'image' => 'b2in/marcel-scheibe-about.jpg',
@ -132,10 +170,129 @@ return [
'image_alt' => 'The result for the customer – Property and interior from one source',
'image_caption' => 'Property and interior – from one source',
],
'audience_switch' => [
'title' => 'For your home. <span class="text-secondary">For your project.</span>',
'subtitle' => 'Whether a single piece of furniture or a complete fit-out – we accompany you with the same brands, the same standards and one personal contact.',
'cards' => [
[
'icon' => 'home',
'title' => 'Furnishing for your home',
'description' => 'Single pieces, bespoke built-in solutions or a complete interior: our core brands and our specialist retail network accompany you from the first idea to installation. Visit our showrooms or arrange a personal consultation.',
'button_text' => 'Request a consultation',
'button_link' => '/contact',
],
[
'icon' => 'building-office-2',
'title' => 'Fit-out for your project',
'description' => 'Complete furnishing for properties, offices and developments – coordinated from a single source, from the kitchen to the last flower. With German contractual reliability and an established manufacturer network.',
'button_text' => 'Discuss your project',
'button_link' => '/contact',
],
],
],
'core_brands' => [
'title' => 'Our <span class="text-secondary">core brands</span>',
'subtitle' => 'We do not work with arbitrary catalogues, but with selected manufacturers we stand behind – each brand a specialist in its field.',
'brands' => [
[
'name' => 'CABINET',
'tagline' => 'Fitted wardrobes made to measure.',
'teaser' => 'Bespoke wardrobe solutions, planned to the millimetre – built for rooms where standard will not do. Experience the possibilities in our Bielefeld showroom.',
'image' => '',
'logo' => '',
'link_text' => 'More about CABINET',
],
[
'name' => 'Rotpunkt Küchen',
'tagline' => 'Kitchens with character.',
'teaser' => 'Premium kitchens made in Germany – individually planned, sustainably conceived and built for the heart of your home. Consultation and planning in our kitchen studio.',
'image' => '',
'logo' => '',
'link_text' => 'More about Rotpunkt',
],
[
'name' => 'Febrü',
'tagline' => 'Work where you like to be.',
'teaser' => 'Furniture for focused work – from a well-considered home office to a complete office fit-out. Ergonomics and design for home and company alike.',
'image' => '',
'logo' => '',
'link_text' => 'More about Febrü',
],
],
],
'sponsoring' => [
'title' => 'Rooted <span class="text-secondary">in the region</span>',
'subtitle' => 'We support clubs and organisations from our region – because being rooted is more than an address.',
'logos' => [
[
'name' => 'DSC Arminia Bielefeld',
'image' => 'sponsoring/arminia-bielefeld.png',
'text' => 'A long-standing club from Bielefeld – the city we call home.',
],
[
'name' => 'TC Bad Waldliesborn-Horn',
'image' => 'sponsoring/tc-bad-waldliesborn-horn.jpg',
'text' => 'Tennis club in Bad Waldliesborn. Club sport that brings people together locally.',
],
[
'name' => 'TC Hövelhof',
'image' => 'sponsoring/tc-hoevelhof.png',
'text' => 'Tennis club in Hövelhof – grassroots sport in the Paderborn district.',
],
],
],
'immobilien_teaser' => [
'title' => 'Also: <span class="text-secondary">international real estate</span>',
'text' => 'Alongside interiors, we accompany selected international real estate projects – personally curated and with a clear eye for substance.',
'link_text' => 'To the properties',
'link_url' => '/immobilien',
],
'marken_hero' => [
'title' => 'Our <span class="text-secondary">brands</span>',
'text' => 'We do not work with arbitrary catalogues, but with selected manufacturers we stand behind. Each brand is a specialist in its field – and each brings what good interiors are made of: visible quality and advice that listens.',
'image' => 'b2in/b2a.jpg',
'image_alt' => 'B2in Interior – our core brands',
],
'marken_network' => [
'title' => 'More than brands: <span class="text-secondary">our network</span>',
'paragraphs' => [
'Good interiors do not end with the furniture. That is why we work with specialist firms and craftspeople from the region – from measuring up and installation to adjacent trades.',
'For you that means one point of contact who keeps things together, instead of many separate conversations. And partners we know and whose work we stand behind.',
],
'points' => [
[
'icon' => 'pencil-square',
'title' => 'Measuring & planning',
'description' => 'We come to you, measure up and plan together with you.',
],
[
'icon' => 'wrench-screwdriver',
'title' => 'Installation by specialists',
'description' => 'Craftspeople from the region whom we know personally.',
],
[
'icon' => 'user',
'title' => 'One point of contact',
'description' => 'Coordinated from a single source, from first idea to handover.',
],
],
],
'marken_growth' => [
'enabled' => true,
'title' => 'Our <span class="text-secondary">brand world is growing</span>',
'text' => 'We continuously extend our portfolio with manufacturers that match our standards. Missing a particular brand or have a specific wish? Talk to us – our network often turns up exactly the right thing.',
],
'marken_cta' => [
'title' => 'Get to know our <span class="text-secondary">brands</span>',
'text' => 'Ideally where you can touch them: in our showrooms. Arrange a no-obligation consultation – we take our time.',
'button_text' => 'Request an appointment',
'button_link' => '/contact',
'whatsapp_text' => 'Message us on WhatsApp',
],
'cta_section' => [
'title' => 'Your Next <span class="text-primary">Step</span>',
'subtitle' => 'Whether real estate investment, supply chain partnership, or interior network – speak directly with us.',
'button_text' => 'Get in Touch',
'subtitle' => 'Whether a single piece of furniture, a complete interior or a project fit-out – talk to us. Personal, straightforward, reliable.',
'button_text' => 'Request an appointment',
'button_link' => '/contact',
],
'immobilien_hero' => [
@ -573,63 +730,71 @@ return [
],
'faq' => [
'title' => 'Frequently Asked Questions',
'subtitle' => 'Here you will find answers to the most common questions about B2in and our ecosystem.',
'subtitle' => 'Answers to the questions we are asked most often – about advice, bespoke furniture and fitting out projects.',
'questions' => [
[
'question' => 'What is B2in and what services do you offer?',
'answer' => 'B2in is the bridge between international premium real estate and exclusive interior concepts. We offer real estate investors access to off-market projects (focus: Dubai) and guide them through the entire purchase process. At the same time, we provide developers with rigorous supply chain management for sourcing German quality furniture and are building an innovative interior network for local retailers.',
'question' => 'What is B2in Interior and what do you offer?',
'answer' => 'B2in Interior stands for high-quality interiors from strong brands – CABINET, Rotpunkt Küchen and Febrü – complemented by a network of regional specialists. We support private customers from a single bespoke piece to a complete interior, and fit out properties, offices and developments in full. In addition, we accompany selected international real estate projects.',
],
[
'question' => 'What does supply chain management mean at B2in?',
'answer' => 'For international property developers, we act as an extended arm in Germany. We take over the operational and strategic management of furniture and interior procurement. This means: We secure supply contracts, monitor milestones directly with manufacturers, and escalate immediately to management level in case of deviations. Our goal is absolute contractual reliability and on-time delivery without friction losses.',
'question' => 'What does fitting out a project involve?',
'answer' => 'For properties, offices and developments we take on the complete fit-out, coordinated from a single source. We manage the procurement of furniture and interiors both operationally and strategically: we secure supply contracts, monitor milestones directly with the manufacturers and escalate deviations immediately. The goal is contractual reliability and on-time delivery without friction.',
],
[
'question' => 'How can I become a partner at B2in?',
'answer' => 'Our network is aimed at property developers, brokers, and regional furniture retailers. If you are a developer seeking German procurement reliability or a retailer wanting to become part of our future "Local for Local" network to receive qualified leads from property buyers, simply use our contact form. We evaluate every partnership individually for quality and fit.',
'question' => 'How can I become a partner?',
'answer' => 'Our network covers specialist firms and craftspeople around interiors – from installation to adjacent trades. If you are interested, write to us using the contact form. We review every collaboration individually, because we stand behind the work of our partners.',
],
[
'question' => 'How does real estate investment through B2in work?',
'answer' => 'We don\'t just broker, we guide you. Marcel Scheibe is your personal advisor who knows the market (especially Dubai) from his own investor perspective. After a needs analysis, we present you with exclusive projects (e.g., from Azizi Developments). The purchase process itself is maximally secured through Dubai\'s government escrow system. Your special advantage: As a B2in client, you subsequently receive exclusive access to our network to furnish your investment turnkey and optimized for returns.',
'question' => 'How does a consultation work?',
'answer' => 'It starts with a conversation – in the showroom, at your home or by phone. We look at the rooms involved, how you use them and what matters to you. Measuring up on site follows, then the planning. You see your solution before anything is made. Appointments can be arranged through the form on this page or by phone.',
],
[
'question' => 'What makes B2in a trustworthy partner?',
'answer' => 'Trust comes from transparency and personal market experience. B2in founder Marcel Scheibe invests himself in the markets we offer and is regularly on-site to check construction progress. We combine the immense dynamics of international markets (like Dubai) with German reliability, strict contract management, and a curated network of premium partners like CABINET. With us, you always have a personal contact who represents your interests.',
'question' => 'What makes B2in Interior a reliable partner?',
'answer' => 'We work with a manageable number of selected manufacturers instead of arbitrary catalogues – and we stand behind their work. Behind B2in Interior there is a face and a fixed point of contact in Marcel Scheibe, from the first conversation to after installation. For the trades around the interior we work with specialist firms from the region that we know.',
],
],
'sections' => [
[
'title' => 'Focus: Real Estate Investors (B2C)',
'title' => 'For your home',
'icon' => 'home-modern',
'questions' => [
[
'question' => 'Do I need to travel to Dubai personally for the property purchase?',
'answer' => 'No, the entire purchase process can be completed fully digitally and legally binding from abroad. The Dubai Land Department offers highly secure processes for this. While we do recommend our investors to visit and experience the city\'s dynamics firsthand, your physical presence is not strictly required for the legally secure acquisition.',
'question' => 'Do I have to furnish the whole flat at once?',
'answer' => 'No. Many projects start with a single piece – a fitted wardrobe under a sloping ceiling, a new kitchen, a workspace. Whether more follows is up to you. We plan in a way that lets a later addition fit in rather than look bolted on.',
],
[
'question' => 'What happens after the property purchase? Do you help with furnishing?',
'answer' => 'This is exactly where the great advantage of the B2in ecosystem lies. Our service doesn\'t end with the purchase contract. As a B2in client, you receive exclusive access to our interior network. We support you in furnishing your property turnkey and optimized for returns with quality furniture – the perfect foundation for lucrative short-term rentals.',
'question' => 'What does bespoke cost compared to off-the-shelf furniture?',
'answer' => 'That depends on the room, the materials and the fittings – a serious figure is only possible after measuring up. What can be said: bespoke uses the space standard furniture leaves behind – slopes, niches, unusual heights. You pay for the space you would otherwise give away. You receive the quote before anything is made, binding and itemised.',
],
[
'question' => 'What tax advantages does an investment in Dubai offer?',
'answer' => 'Dubai offers a globally unique economic environment. There is neither income tax on rental income nor capital gains tax on the sale of the property. Combined with the strong rental yields, this makes the location strategically valuable for international investors.',
'question' => 'Do you handle installation as well?',
'answer' => 'Yes. Delivery and installation run through us or through specialist firms from our network that we work with regularly. You have one point of contact who coordinates the appointments – including when several trades are involved.',
],
[
'question' => 'Can I see the brands beforehand?',
'answer' => 'Yes, in the showroom in Bielefeld you can take your time with surfaces, fronts and fittings. Photos and samples help with a pre-selection, but they do not replace the direct impression. It is best to arrange an appointment so we can take the time.',
],
],
],
[
'title' => 'Focus: B2B Partners (Brokers & Retailers)',
'title' => 'For projects and properties',
'icon' => 'briefcase',
'questions' => [
[
'question' => 'How do real estate brokers benefit from a partnership with B2in?',
'answer' => 'B2in offers brokers an exclusive tool for client retention: You can present your property buyers with access to our closed interior network as an exclusive "closing gift." At the same time, as a broker, you benefit from our technical clearing system with a passive lifetime compensation on your clients\' furniture purchases.',
'question' => 'From what size is a project fit-out worthwhile?',
'answer' => 'There is no fixed lower limit. Working together makes sense as soon as several units or several trades are involved and coordination becomes the actual effort – for office space, furnished residential units or fitting out an entire property.',
],
[
'question' => 'What exactly is the "Local for Local" marketplace?',
'answer' => 'Our "Local for Local" principle is the counter-concept to anonymous online furniture giants. We make the immediately available stock and showroom pieces of regional specialist retailers (the "Local Express" pillar) transparent to a closed customer base. This strengthens local trade on-site while offering buyers insider rates for premium furnishing.',
'question' => 'How do you ensure delivery dates are met?',
'answer' => 'We work with binding supply contracts, track milestones directly with the manufacturer and report deviations as soon as they become visible – not just on the delivery date. Where several trades are involved, we handle the coordination between them so the sequence on site works.',
],
[
'question' => 'Is the interior network publicly accessible to everyone?',
'answer' => 'No. To protect the exclusive insider rates and discounts from our manufacturers and local retailers, the B2in furniture marketplace operates as a "closed shop." Access is exclusively through invitations – for example, through our partner brokers upon property acquisition.',
'question' => 'Do you work with architects and planning offices?',
'answer' => 'Yes. We take on existing plans and implement them, or come in earlier when furniture and floor plan still need to be matched up. For special solutions we talk directly to the manufacturers.',
],
[
'question' => 'How do I join the regional network?',
'answer' => 'Our network covers specialist firms and craftspeople around interiors – from installation to adjacent trades. If you are interested, write to us using the contact form. We review every collaboration individually, because we stand behind the work of our partners.',
],
],
],
@ -652,10 +817,13 @@ return [
],
'subjects' => [
'' => 'Select a subject',
'beratung' => 'Consultation appointment',
'massanfertigung' => 'Bespoke furniture for my home',
'kueche' => 'Kitchen planning',
'supply_chain' => 'Fitting out a project',
'partnership' => 'Working together in the network',
'immobilien' => 'International Real Estate',
'supply_chain' => 'Supply Chain Management',
'general' => 'General Inquiry',
'partnership' => 'Partnership',
'press' => 'Press',
'career' => 'Career',
],
@ -704,14 +872,14 @@ return [
],
'about_hero' => [
'title' => '<span class="text-secondary">About B2in:</span> Our Mission',
'quote' => '"My mission is to connect two worlds that belong together: international real estate and exclusive interior design. B2in gives the local specialist the digital tools and the property developer the operational partner on the ground.<br><br>We build bridges – between European design, international markets, and people\'s homes."',
'quote' => '"Good interiors are not created in a catalogue, but in conversation. That is why we work with a few selected manufacturers we know – and with specialist firms from the region whose work we stand behind.<br><br>My standard is simple: one point of contact, from the first measurement to after installation."',
'founder_name' => 'Marcel Scheibe',
'founder_title' => 'Founder & CEO, B2in',
'image' => 'b2in/about-hero.jpg',
'image_alt' => 'Marcel Scheibe, Founder and CEO of B2in',
'card_title' => 'B2in',
'card_text' => 'Connecting Design and <span class="text-secondary">Property</span>',
'card_text' => 'Premium interiors. <span class="text-secondary">Made personal.</span>',
],
'broker_section' => [
@ -997,26 +1165,26 @@ return [
'timeline' => [
[
'title' => 'The Idea',
'description' => 'In 2024, we identified a crucial gap: Local retailers need digital visibility, and property buyers are looking for seamless furnishing solutions. These two worlds belong together.',
'description' => 'In 2024 the starting point was a simple observation: anyone wanting a high-quality interior finds plenty of catalogues online – but rarely someone who listens, measures up and stands behind the result.',
'icon' => 'light-bulb',
],
[
'title' => 'The Foundation',
'description' => 'We are developing the B2in platform: an ecosystem that connects local interior specialists, European manufacturers, and real estate professionals on a shared platform – fair, transparent, and technology-driven.',
'description' => 'We are building the network: selected manufacturers, specialist firms and craftspeople from the region – connected by the same conviction that quality and keeping your word belong together.',
'icon' => 'rocket-launch',
],
[
'title' => 'The Expansion',
'description' => '2025/2026 sees B2in expanding its ecosystem: International real estate and supply chain management for developers become the dominant pillar. The local furniture marketplace remains as a strong complementary area.',
'description' => 'In 2026 interiors move to the centre: CABINET, Rotpunkt Küchen and Febrü form the core, complemented by a network of regional specialists. International real estate remains as a complementary area.',
'icon' => 'globe-alt',
],
[
'title' => 'The Vision',
'description' => 'B2in becomes the central network for "Design & Property" – locally rooted, internationally connected. In every region, the reliable partner for real estate investments and exclusive interior concepts.',
'description' => 'B2in Interior becomes the first address in the region for high-quality interiors – from a single fitted wardrobe to a complete fit-out. Locally rooted, internationally connected.',
'icon' => 'star',
],
],
'summary' => 'What began as a vision to strengthen local trade is today a network that seamlessly connects real estate and interior. B2in bridges the gap between international investments and local expertise – with Marcel Scheibe as the face and point of contact.',
'summary' => 'What began as an idea – to make high-quality interiors personal again – is today a network of strong brands and regional specialists, with Marcel Scheibe as the face and fixed point of contact.',
],
'about_image_break' => [
'image' => 'b2in/best-of-two-worlds.jpg',
@ -1026,37 +1194,37 @@ return [
],
'our_values' => [
'title' => 'Our <span class="text-secondary">Values</span>',
'subtitle' => 'These six pillars guide our daily actions and define who we are as a company and what we stand for.',
'subtitle' => 'Six principles we want to be measured by – in the consultation, in the planning and after installation.',
'values' => [
[
'title' => 'Innovation',
'description' => 'We develop digital solutions that give real estate professionals and local interior specialists a genuine competitive advantage.',
'icon' => 'light-bulb',
'title' => 'Advice, not sales',
'description' => 'We first ask how you use your rooms and only then suggest something. Sometimes the right answer is a smaller piece than expected.',
'icon' => 'chat-bubble-left-right',
],
[
'title' => 'Connectivity',
'description' => 'We connect international real estate markets with local expertise and European manufacturers with the people who value their products.',
'icon' => 'globe-alt',
'title' => 'Bespoke work',
'description' => 'Slopes, niches and unusual heights are not a problem but the reason for bespoke furniture. We plan the space that standard furniture leaves behind.',
'icon' => 'cube-transparent',
],
[
'title' => 'Quality',
'description' => 'Uncompromising standards – in the selection of our partners, the curation of interiors, the monitoring of supply chains, and the technology that holds it all together.',
'title' => 'Selected brands',
'description' => 'A few manufacturers we know, rather than an arbitrary range. We stand behind the brands we carry.',
'icon' => 'check-badge',
],
[
'title' => 'Trust',
'description' => 'Transparent models and reliable partnerships are our foundation. Behind B2in stands a face – and the promise that we deliver what we commit to.',
'title' => 'Regionally rooted',
'description' => 'We work with specialist firms and craftspeople from the region – people we know and who are on site when it matters.',
'icon' => 'map-pin',
],
[
'title' => 'One point of contact',
'description' => 'From the first conversation through measuring up to after installation, the contact stays the same – including when several trades are involved.',
'icon' => 'user-group',
],
[
'title' => 'Sustainability',
'description' => 'We strengthen local trade, optimize transport routes, and ensure that international procurement runs responsibly and efficiently.',
'icon' => 'arrow-path',
],
[
'title' => 'Design Excellence',
'description' => 'Design is the core of our value creation – from international architecture to curated interiors to the intuitive design of our digital platform.',
'icon' => 'cube-transparent',
'title' => 'Reliability',
'description' => 'We keep our commitments on dates and prices. If something shifts, we say so as soon as we know – not on the delivery date.',
'icon' => 'shield-check',
],
],
],
@ -1474,6 +1642,70 @@ return [
],
],
'articles' => [
6 => [
'id' => 6,
'slug' => 'massanfertigung-warum-der-millimeter-zaehlt',
'title' => '<span class="text-secondary">Bespoke, not off the shelf:</span><br>Why the millimetre decides what a room can do',
'subtitle' => 'Sloping ceilings, niches, unusual heights: where standard furniture gives up, bespoke work begins – turning dead space into usable area.',
'image' => 'b2in/stileigentum.jpg',
'category' => 'Interiors',
'date' => 'August 26, 2026',
'readTime' => '5 min read',
'author' => [
'name' => 'Marcel Scheibe',
'bio' => 'Founder & CEO of B2in Interior. With roots in the furniture trade, he accompanies customers from planning to after installation.',
'avatar' => 'b2in/marcel-scheibe.jpg',
],
'content' => [
'intro' => 'Almost every home has one: the corner where nothing quite fits. The slope under the roof, the niche beside the chimney, the hallway too narrow for a wardrobe and too wide for nothing. Put a standard piece there and you lose twice – a gap at the front, wasted air at the back. Bespoke work starts exactly there, and the difference is not made by the furniture but long before: when measuring up.',
'sections' => [
[
'title' => 'Measuring up is not a formality',
'content' => 'Walls are rarely straight, floors rarely level, and in older buildings the height can vary by several centimetres across a single room. Measuring on site records these deviations instead of ignoring them. Only then can you plan something that fits to the millimetre – without panels to hide things and without joints where dust collects.',
],
[
'title' => 'The space you would otherwise give away',
'content' => 'With a sloping ceiling the gain is not in the look but in the area. A wardrobe that follows the slope uses the part a straight carcass cuts off – depending on the angle, a substantial share of the storage. The same applies to niches: a fitted unit gains depth a free-standing piece can never reach, because it needs clearance from the wall.',
],
[
'title' => 'Where bespoke is not worth it',
'content' => 'Being honest means naming the other side too: with straight walls, usual ceiling heights and standard dimensions, a good series product is often the more sensible choice – available faster and cheaper. So the question is not "bespoke or not" but: where in this room is there actually something to gain? That answer belongs at the start of the consultation, not at the end.',
],
],
],
],
7 => [
'id' => 7,
'slug' => 'kueche-planen-vom-wunsch-zum-grundriss',
'title' => '<span class="text-secondary">Planning a kitchen:</span><br>How wishes turn into a floor plan',
'subtitle' => 'Before anyone talks about fronts and colours, a kitchen is decided by three questions – and at first they have little to do with design.',
'image' => 'b2in/style2own.jpg',
'category' => 'Kitchen',
'date' => 'August 20, 2026',
'readTime' => '6 min read',
'author' => [
'name' => 'Marcel Scheibe',
'bio' => 'Founder & CEO of B2in Interior. With roots in the furniture trade, he accompanies customers from planning to after installation.',
'avatar' => 'b2in/marcel-scheibe.jpg',
],
'content' => [
'intro' => 'Most kitchen conversations start with the front: matt or gloss, light or dark, handle or handleless. Understandable, because that is what you see. Only nothing is decided at that point. Whether a kitchen works day to day depends on questions that can be settled before the first colour sample – and that are hard to correct later.',
'sections' => [
[
'title' => 'Question one: how do you actually cook?',
'content' => 'Not how you would like to cook – how you really do. Anyone cooking fresh every day needs worktop beside the hob and short distances between fridge, sink and cooking zone. Anyone using the kitchen mainly as a meeting point plans differently: more room to stand, a surface where someone can sit while food is being made. Both are good kitchens, but they are not the same kitchen.',
],
[
'title' => 'Question two: what does the room dictate?',
'content' => 'Windows, doors, connections and load-bearing walls set the frame before anyone plans anything. Some of it can be moved, some cannot – and what that costs should be on the table before the planning builds on it. Ceiling height belongs here too: it decides whether wall units can run all the way up or whether a dust ledge is left above them.',
],
[
'title' => 'Question three: what should still work in ten years?',
'content' => 'A kitchen stays for a long time. In many systems fronts can be replaced later; carcass dimensions and the layout practically cannot. So it pays to focus on durability for drawers, fittings and the worktop, and to keep the appetite for fashion to the parts that can be swapped. The colour comes after that – and then it may well be bold.',
],
],
],
],
1 => [
'id' => 1,
'title' => '<span class="text-secondary">Security Over Bureaucracy:</span><br>Why Dubai\'s Escrow System Surprises German Investors',

View file

@ -16,9 +16,60 @@ return [
'menu_close' => 'Close menu',
'menu_open' => 'Open menu',
'display_feed_title' => 'Live view of the showroom display',
'references_empty' => 'The first projects will appear here as soon as we are allowed to show them.',
// Appointment widget
'appointment' => [
'name' => 'Name',
'email' => 'Email',
'phone' => 'Phone',
'topic' => 'Subject',
'topic_placeholder' => 'Please choose',
'preferred_date' => 'Preferred date',
'message' => 'Message',
'message_placeholder' => 'What is it about? Two sentences are enough.',
'submit' => 'Send request',
'sending' => 'Sending …',
'success' => 'Thank you! We will get back to you shortly to arrange the appointment.',
'new_request' => 'Another request',
'date_in_past' => 'Please choose a date from today onwards.',
'whatsapp_intro' => 'Prefer to write directly? We prepare the message with your details.',
'whatsapp_text' => 'Hello B2in Interior, I would like to arrange an appointment.',
'whatsapp_note' => 'Opens WhatsApp with a prepared text. You send the message yourself.',
],
// Kernmarken
'all_brands_cta' => 'View all brands',
'brands' => 'Brands',
'whatsapp' => 'Message us on WhatsApp',
'all_brands' => 'All brands',
'brand_services' => 'Services and specialities',
'brand_cta_title' => 'Interested in :name?',
'brand_cta_text' => 'Arrange an appointment – we advise you personally and without obligation.',
'request_appointment' => 'Request an appointment',
'brand_website' => 'Visit the manufacturer',
'other_brands' => 'Our other core brands',
'more_about' => 'More about :name',
// Brand gallery – realised projects vs. drafts
'brand_gallery_title' => 'References and inspiration',
'brand_gallery_intro' => 'Completed projects from our work – and visualisations showing what is possible.',
'brand_gallery_tab_projects' => 'Completed projects',
'brand_gallery_tab_inspirations' => 'Inspiration and drafts',
'brand_gallery_badge_project' => 'Completed project',
'brand_gallery_badge_inspiration' => 'Visualisation',
'brand_gallery_inspiration_note' => 'These images are drafts and visualisations – not completed projects.',
'previous' => 'Previous image',
'next' => 'Next image',
// Footer
'founder_ceo' => 'Founder & CEO',
'legal_notice' => 'Legal Notice',
'privacy_policy' => 'Privacy Policy',
'terms_of_service' => 'Terms of Service',
'cookie_policy' => 'Cookie Policy',
'faq' => 'FAQ',
'cookie_settings' => 'Cookie Settings',
// Announcement Bar

View file

@ -0,0 +1,504 @@
<?php
use App\Models\CmsBrand;
use App\Services\CmsFluxEditorHtmlTransformer;
use Flux\Flux;
use FluxCms\Core\Models\CmsMedia;
use function Livewire\Volt\{computed, layout, on, state, title};
layout('components.layouts.app');
title('Kernmarken verwalten');
state([
'search' => '',
'showForm' => false,
'editingId' => null,
'editLocale' => 'de',
'slug' => '',
'brandName' => '',
'tagline' => '',
'teaser' => '',
'summary' => '',
'highlights' => [],
'description' => '',
'logo' => '',
'image' => '',
'services' => [],
'gallery' => [],
'website_url' => '',
'showroom' => '',
'is_published' => true,
'order' => 0,
]);
on(['media-selected' => function ($mediaId, $url, $field) {
$media = $mediaId ? CmsMedia::find($mediaId) : null;
if (! $media) {
return;
}
if ($field === 'brand_logo') {
$this->logo = $media->filename;
}
if ($field === 'brand_image') {
$this->image = $media->filename;
}
if (str_starts_with($field, 'brand_gallery:')) {
$index = (int) substr($field, strlen('brand_gallery:'));
if (isset($this->gallery[$index])) {
$this->gallery[$index]['image'] = $media->filename;
}
}
}]);
$brands = computed(
fn () => CmsBrand::query()
->when($this->search, fn ($q, $s) => $q->where('slug', 'like', "%{$s}%")
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(name, '$.de')) LIKE ?", ["%{$s}%"]))
->ordered()
->get(),
);
$openCreate = function () {
$this->editingId = null;
$this->reset(['slug', 'brandName', 'tagline', 'teaser', 'summary', 'description', 'logo', 'image', 'website_url', 'showroom']);
$this->highlights = ['', '', ''];
$this->services = [['title' => '', 'description' => '']];
$this->gallery = [];
$this->is_published = true;
$this->order = (CmsBrand::max('order') ?? 0) + 1;
$this->showForm = true;
};
$loadLocaleFields = function (CmsBrand $brand, string $locale) {
$this->brandName = $brand->getTranslation('name', $locale) ?? '';
$this->tagline = $brand->getTranslation('tagline', $locale) ?? '';
$this->teaser = $brand->getTranslation('teaser', $locale) ?? '';
$this->summary = $brand->getTranslation('summary', $locale) ?? '';
$this->description = CmsFluxEditorHtmlTransformer::toEditor((string) ($brand->getTranslation('description', $locale) ?? ''));
};
$openEdit = function (int $id) {
$brand = CmsBrand::find($id);
if (! $brand) {
return;
}
$this->editingId = $id;
$this->slug = $brand->slug;
$this->logo = $brand->logo ?? '';
$this->image = $brand->image ?? '';
$this->website_url = $brand->website_url ?? '';
$this->showroom = $brand->showroom ?? '';
$this->is_published = $brand->is_published;
$this->order = $brand->order ?? 0;
$this->highlights = $brand->highlights ?: ['', '', ''];
$this->services = $brand->services ?: [['title' => '', 'description' => '']];
$this->gallery = CmsBrand::normalizeGallery($brand->gallery);
$this->loadLocaleFields($brand, $this->editLocale);
$this->showForm = true;
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$brand = CmsBrand::find($this->editingId);
if ($brand) {
$this->loadLocaleFields($brand, $locale);
}
}
};
$addHighlight = fn () => $this->highlights[] = '';
$removeHighlight = function (int $index) {
unset($this->highlights[$index]);
$this->highlights = array_values($this->highlights);
};
$addService = fn () => $this->services[] = ['title' => '', 'description' => ''];
$removeService = function (int $index) {
unset($this->services[$index]);
$this->services = array_values($this->services);
};
$addGalleryImage = function (string $category = CmsBrand::GALLERY_PROJECT) {
$this->gallery[] = [
'image' => '',
'category' => in_array($category, CmsBrand::GALLERY_CATEGORIES, true) ? $category : CmsBrand::GALLERY_INSPIRATION,
'caption' => '',
];
};
$removeGalleryImage = function (int $index) {
unset($this->gallery[$index]);
$this->gallery = array_values($this->gallery);
};
$save = function () {
$validated = validator([
'slug' => $this->slug,
'brandName' => $this->brandName,
'tagline' => $this->tagline,
'teaser' => $this->teaser,
'summary' => $this->summary,
'website_url' => $this->website_url,
'showroom' => $this->showroom,
'is_published' => $this->is_published,
'order' => $this->order,
], [
'slug' => 'required|string|max:255|regex:/^[a-z0-9-]+$/',
'brandName' => 'required|string|max:255',
'tagline' => 'nullable|string|max:255',
'teaser' => 'nullable|string|max:2000',
'summary' => 'nullable|string|max:4000',
'website_url' => 'nullable|url|max:500',
'showroom' => 'nullable|string|max:255',
'is_published' => 'boolean',
'order' => 'integer|min:0',
], [
'slug.regex' => 'Der Slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.',
'website_url.url' => 'Bitte eine vollständige URL angeben (mit https://).',
])->validate();
$duplicate = CmsBrand::query()
->where('slug', $validated['slug'])
->when($this->editingId, fn ($q) => $q->whereKeyNot($this->editingId))
->exists();
if ($duplicate) {
Flux::toast(variant: 'danger', heading: 'Slug vergeben', text: 'Es gibt bereits eine Marke mit diesem Slug.');
return;
}
$brand = $this->editingId
? CmsBrand::findOrFail($this->editingId)
: CmsBrand::query()->make();
$brand->slug = $validated['slug'];
$brand->setTranslation('name', $this->editLocale, $validated['brandName']);
$brand->setTranslation('tagline', $this->editLocale, $validated['tagline'] ?? '');
$brand->setTranslation('teaser', $this->editLocale, $validated['teaser'] ?? '');
$brand->setTranslation('summary', $this->editLocale, $validated['summary'] ?? '');
$brand->setTranslation('description', $this->editLocale, CmsFluxEditorHtmlTransformer::fromEditor((string) $this->description));
$brand->logo = $this->logo ?: null;
$brand->image = $this->image ?: null;
$brand->website_url = $validated['website_url'] ?: null;
$brand->showroom = $validated['showroom'] ?: null;
$brand->highlights = collect($this->highlights)
->map(fn ($h) => trim((string) $h))
->filter()
->values()
->toArray();
$brand->services = collect($this->services)
->filter(fn ($s) => ! empty($s['title']) || ! empty($s['description']))
->values()
->toArray();
$brand->gallery = CmsBrand::normalizeGallery($this->gallery);
$brand->is_published = $validated['is_published'];
$brand->order = $validated['order'];
$brand->save();
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Marke wurde erfolgreich gespeichert.');
};
$togglePublished = function (int $id) {
$brand = CmsBrand::findOrFail($id);
$brand->update(['is_published' => ! $brand->is_published]);
Flux::toast(heading: 'Status geändert', text: $brand->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$deleteBrand = function (int $id) {
CmsBrand::findOrFail($id)->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Marke wurde entfernt.');
};
$cancelForm = function () {
$this->showForm = false;
$this->editingId = null;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Kernmarken</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="openCreate">Neue Marke</flux:button>
</div>
</div>
<flux:card class="mb-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-3">
<flux:icon.information-circle class="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
<div class="text-sm text-blue-800 dark:text-blue-200">
Die Marken erscheinen als Kacheln auf der Startseite unter „Unsere Kernmarken" und
bekommen jeweils eine eigene Detailseite unter <code>/marken/&lt;slug&gt;</code>.
Die Reihenfolge steuert das Feld „Sortierung".
</div>
</div>
</flux:card>
<div class="mb-4">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Marke suchen..." icon="magnifying-glass"
size="sm" class="w-64" />
</div>
@if ($showForm)
<flux:card class="mb-6">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $editingId ? 'Marke bearbeiten' : 'Neue Marke' }}</flux:heading>
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="slug" label="Slug (Teil der Adresse)" placeholder="cabinet" />
@error('slug') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
<flux:input wire:model="brandName" label="Name ({{ strtoupper($editLocale) }})" placeholder="CABINET" />
@error('brandName') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
<div class="md:col-span-2">
<flux:input wire:model="tagline" label="Claim ({{ strtoupper($editLocale) }})"
placeholder="Einbauschränke nach Maß." />
</div>
<flux:input wire:model="showroom" label="Showroom (optional)" placeholder="Showroom Bielefeld" />
<flux:input wire:model="website_url" label="Herstellerseite (optional)" placeholder="https://..." />
@error('website_url') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
<flux:input wire:model="order" label="Sortierung" type="number" />
</div>
<div class="mt-4 space-y-4">
<flux:textarea wire:model="teaser" label="Kacheltext Startseite ({{ strtoupper($editLocale) }})" rows="3"
placeholder="Kurztext für die Kachel auf der Startseite..." />
<flux:textarea wire:model="summary" label="Text auf /marken ({{ strtoupper($editLocale) }})" rows="6"
placeholder="Ausführlicher Text für die Markenübersicht. Leerzeile trennt Absätze." />
</div>
{{-- Drei Merkmale für die Übersichtsseite --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">
<flux:heading size="sm">Merkmale auf /marken</flux:heading>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addHighlight">Merkmal hinzufügen</flux:button>
</div>
<div class="space-y-2">
@foreach ($highlights as $i => $highlight)
<div wire:key="highlight-{{ $i }}" class="flex items-center gap-2">
<flux:input wire:model="highlights.{{ $i }}" class="flex-1"
placeholder="z.B. Millimetergenaue Planung" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeHighlight({{ $i }})" />
</div>
@endforeach
</div>
</div>
{{-- Bilder --}}
<div class="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Logo</label>
<div class="flex items-start gap-3">
@if ($logo)
<div class="flex h-16 w-24 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-zinc-200 bg-white p-2 dark:border-zinc-700">
<img src="{{ media_url($logo) }}" class="max-h-full max-w-full object-contain" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker :value="null" field="brand_logo" type="image"
label="Logo wählen" :key="'brand-logo-' . ($editingId ?? 'new')" />
<p class="mt-1 text-xs text-zinc-400">{{ $logo ?: 'Kein Logo' }}</p>
</div>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Stimmungsbild</label>
<div class="flex items-start gap-3">
@if ($image)
<div class="h-16 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker :value="null" field="brand_image" type="image"
profile="card" label="Bild wählen" :key="'brand-img-' . ($editingId ?? 'new')" />
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
</div>
</div>
</div>
</div>
{{-- Einleitung --}}
<div class="mt-6">
<flux:heading size="sm" class="mb-3">Einleitung ({{ strtoupper($editLocale) }})</flux:heading>
<flux:editor wire:model="description" />
</div>
{{-- Leistungen --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">
<flux:heading size="sm">Leistungen und Besonderheiten</flux:heading>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addService">Punkt hinzufügen</flux:button>
</div>
<div class="space-y-4">
@foreach ($services as $i => $service)
<div wire:key="service-{{ $i }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-zinc-500">Punkt {{ $i + 1 }}</span>
@if (count($services) > 1)
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeService({{ $i }})" />
@endif
</div>
<flux:input wire:model="services.{{ $i }}.title" label="Überschrift" class="mb-3"
placeholder="z.B. Aufmaß vor Ort" />
<flux:textarea wire:model="services.{{ $i }}.description" label="Text" rows="2"
placeholder="Kurze Erläuterung..." />
</div>
@endforeach
</div>
</div>
{{-- Galerie --}}
<div class="mt-6">
<div class="mb-2 flex items-center justify-between">
<flux:heading size="sm">Bildergalerie</flux:heading>
<div class="flex gap-1">
<flux:button size="xs" variant="ghost" icon="plus"
wire:click="addGalleryImage('{{ CmsBrand::GALLERY_PROJECT }}')">Projekt</flux:button>
<flux:button size="xs" variant="ghost" icon="plus"
wire:click="addGalleryImage('{{ CmsBrand::GALLERY_INSPIRATION }}')">Inspiration</flux:button>
</div>
</div>
<div class="mb-3 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
<strong>Projekt</strong> = tatsächlich ausgeführter Auftrag.
<strong>Inspiration</strong> = Entwurf, Rendering oder KI-Darstellung.
Auf der Seite werden beide getrennt gezeigt und beschriftet – bitte nichts vermischen.
</div>
@if (empty($gallery))
<p class="text-sm text-zinc-400">Noch keine Bilder.</p>
@else
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
@foreach ($gallery as $i => $item)
<div wire:key="gallery-{{ $i }}" class="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
@if (!empty($item['image']))
<div class="mb-2 h-24 w-full overflow-hidden rounded-md">
<img src="{{ media_url($item['image']) }}" class="h-full w-full object-cover" />
</div>
@endif
<flux:select wire:model="gallery.{{ $i }}.category" size="sm" class="mb-2">
<flux:select.option value="{{ CmsBrand::GALLERY_PROJECT }}">Realisiertes Projekt</flux:select.option>
<flux:select.option value="{{ CmsBrand::GALLERY_INSPIRATION }}">Inspiration / Entwurf</flux:select.option>
</flux:select>
<flux:input wire:model="gallery.{{ $i }}.caption" size="sm" class="mb-2"
placeholder="Bildunterschrift (optional)" />
<div class="flex items-center justify-between gap-2">
<livewire:admin.cms.media-picker :value="null" field="brand_gallery:{{ $i }}"
type="image" profile="card" label="Wählen"
:key="'brand-gallery-' . ($editingId ?? 'new') . '-' . $i" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeGalleryImage({{ $i }})" />
</div>
<flux:input wire:model="gallery.{{ $i }}.image" size="sm" class="mt-2"
placeholder="Oder Dateiname/URL manuell..." />
</div>
@endforeach
</div>
@endif
</div>
<div class="mt-4">
<flux:switch wire:model="is_published" label="Veröffentlicht" />
</div>
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancelForm">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->brands as $brand)
<div wire:key="brand-{{ $brand->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
@if ($brand->image)
<div class="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($brand->image) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
</div>
@else
<div class="flex h-12 w-20 shrink-0 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-700">
<flux:icon.building-storefront class="h-6 w-6 text-zinc-400" />
</div>
@endif
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-zinc-800 dark:text-zinc-200">
{{ $brand->getTranslation('name', $editLocale) }}
</span>
@unless ($brand->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<p class="truncate text-sm text-zinc-500">
{{ $brand->getTranslation('tagline', $editLocale) ?: '–' }} · /marken/{{ $brand->slug }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<flux:button size="sm" variant="ghost" icon="arrow-top-right-on-square"
href="{{ url('/marken/'.$brand->slug) }}" target="_blank" />
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $brand->id }})" />
<flux:button size="sm" variant="ghost" :icon="$brand->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $brand->id }})" />
<flux:button size="sm" variant="ghost" icon="trash" wire:click="deleteBrand({{ $brand->id }})"
wire:confirm="Marke wirklich löschen?" />
</div>
</div>
@empty
<div class="py-12 text-center">
<flux:icon.building-storefront class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Keine Marken</flux:heading>
<flux:text>Lege die erste Kernmarke mit dem Button oben an.</flux:text>
</div>
@endforelse
</div>
</flux:card>
</div>

View file

@ -40,10 +40,13 @@
</x-success-alert>
@endif
<form wire:submit.prevent="save" x-data
{{-- Nur EIN Submit-Handler: die Zeit-Picker melden das Leeren nicht zuverlaessig an
wire:model, deshalb werden ihre Werte hier direkt aus dem DOM gelesen und an save()
uebergeben. Ein zusaetzliches wire:submit wuerde save() ein zweites Mal aufrufen. --}}
<form x-data
x-on:submit.prevent="
const hours = {};
document.querySelectorAll('[data-hours-prop]').forEach(el => {
$el.querySelectorAll('[data-hours-prop]').forEach(el => {
const prop = el.dataset.hoursProp;
const picker = el.querySelector('ui-time-picker');
hours[prop] = picker ? (picker.value ?? '') : '';
@ -99,24 +102,31 @@
<div class="space-y-3">
@foreach ([['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'], ['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'], ['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'], ['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'], ['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'], ['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'], ['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose']] as $row)
@php
$isClosedDay = empty($this->{$row['open']}) && empty($this->{$row['close']});
@endphp
<div
class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
{{-- Geoeffnet/Geschlossen wird clientseitig gerechnet, damit die Anzeige
sofort auf das Leeren per X reagiert. wire:model.live wuerde bei jeder
Aenderung einen Roundtrip ausloesen, der geleerte Felder wieder mit dem
alten Serverwert ueberschreibt. --}}
<div class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0"
x-data="{
open: @js((string) ($this->{$row['open']} ?? '')),
close: @js((string) ($this->{$row['close']} ?? '')),
get isClosed() { return this.open === '' && this.close === '' },
}">
<span
class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
<div data-hours-prop="{{ $row['open'] }}">
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung"
<div data-hours-prop="{{ $row['open'] }}"
x-on:input="open = $event.target.value ?? ''">
<flux:time-picker wire:model="{{ $row['open'] }}" label="Öffnung"
time-format="24-hour" interval="15" clearable placeholder="–" />
</div>
<div data-hours-prop="{{ $row['close'] }}">
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss"
<div data-hours-prop="{{ $row['close'] }}"
x-on:input="close = $event.target.value ?? ''">
<flux:time-picker wire:model="{{ $row['close'] }}" label="Schluss"
time-format="24-hour" interval="15" clearable placeholder="–" />
</div>
<span
class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
{{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }}
<span class="text-xs text-center"
:class="isClosed ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400'"
x-text="isClosed ? 'Geschlossen' : 'Geöffnet'">
</span>
</div>
@endforeach

View file

@ -35,19 +35,23 @@ if (! function_exists('_cmsParseJsonItems')) {
if (! function_exists('_cmsFieldLooksLikeImage')) {
function _cmsFieldLooksLikeImage(string $fieldKey, mixed $fieldValue): bool
{
if (! is_string($fieldValue) || trim($fieldValue) === '') {
if (! is_string($fieldValue)) {
return false;
}
$key = strtolower($fieldKey);
if (in_array($key, ['image', 'photo', 'avatar', 'picture', 'thumbnail', 'img', 'hero_image', 'background_image', 'cover_image'], true)) {
if (in_array($key, ['image', 'photo', 'avatar', 'picture', 'thumbnail', 'img', 'logo', 'hero_image', 'background_image', 'cover_image'], true)) {
return true;
}
if (preg_match('/_(image|photo|avatar|picture|thumb)$/i', $fieldKey)) {
if (preg_match('/_(image|photo|avatar|picture|thumb|logo)$/i', $fieldKey)) {
return true;
}
if (trim($fieldValue) === '') {
return false;
}
return (bool) preg_match('/\.(jpe?g|png|gif|webp|svg)$/i', $fieldValue);
}
}

View file

@ -1,6 +1,7 @@
<?php
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use App\Models\CmsProject;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
@ -16,6 +17,8 @@ $stats = computed(fn () => [
'projects_published' => CmsProject::published()->count(),
'articles' => CmsArticle::count(),
'articles_published' => CmsArticle::published()->count(),
'brands' => CmsBrand::count(),
'brands_published' => CmsBrand::published()->count(),
'media' => CmsMedia::count(),
]);
@ -64,6 +67,18 @@ $stats = computed(fn () => [
</flux:card>
</a>
<a href="{{ route('cms.brands.index') }}" wire:navigate>
<flux:card class="hover:border-emerald-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="building-storefront" class="text-emerald-500" />
<div>
<flux:heading size="lg">{{ $this->stats['brands'] }}</flux:heading>
<flux:text class="text-sm">Kernmarken ({{ $this->stats['brands_published'] }} veröffentlicht)</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.media.index') }}" wire:navigate>
<flux:card class="hover:border-violet-500 transition-colors">
<div class="flex items-center gap-3">

View file

@ -613,7 +613,7 @@ $setActiveTab = function (string $tab): void {
<div class="space-y-4">
<flux:input wire:model="trustTitle" label="Überschrift" placeholder="Maximale Sicherheit für Ihr Investment" />
<flux:textarea wire:model="trustIntro" label="Einleitung" rows="2" placeholder="Kurzer Intro-Text unter der Überschrift" />
<flux:input wire:model="trustCtaUrl" label="CTA-Link (Magazin o. ä.)" placeholder="/magazin/1" />
<flux:input wire:model="trustCtaUrl" label="CTA-Link (Magazin o. ä.)" placeholder="/magazin/escrow-system-dubai-investoren" />
<flux:input wire:model="trustCtaLabel" label="CTA-Button-Text" placeholder="Deep Dive: …" />
<div>

View file

@ -0,0 +1,51 @@
@php
if (!function_exists('renderHeroIcon')) {
function renderHeroIcon($iconName, $style = 'outline', $color = 'text-secondary')
{
$iconPath = public_path("heroicons/optimized/24/{$style}/{$iconName}.svg");
$fallbackPath = public_path('heroicons/optimized/24/outline/sparkles.svg');
if (file_exists($iconPath)) {
return str_replace('<svg', '<svg class="w-10 h-10 ' . $color . '"', file_get_contents($iconPath));
}
if (file_exists($fallbackPath)) {
return str_replace('<svg', '<svg class="w-10 h-10 ' . $color . '"', file_get_contents($fallbackPath));
}
return '';
}
}
@endphp
<section class="section-padding {{ $bg }}" id="wege">
<div class="container-padding">
<div class="text-center mb-16 slide-up delay-300">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
@if (!empty($content['subtitle']))
<p class="text-large text-muted-foreground mt-4 max-w-2xl mx-auto">{{ $content['subtitle'] }}</p>
@endif
</div>
<div class="grid md:grid-cols-2 gap-8 lg:gap-12 slide-up delay-400">
@foreach ($content['cards'] ?? [] as $index => $card)
<div class="card-elevated rounded-2xl p-8 flex flex-col" wire:key="audience-card-{{ $index }}">
<div class="w-20 h-20 icon-secondary-linear glow-soft rounded-2xl flex items-center justify-center mb-6">
{!! renderHeroIcon($card['icon'] ?? 'home', 'outline', 'text-secondary-foreground') !!}
</div>
<h3 class="text-xl font-medium mb-3">{{ $card['title'] ?? '' }}</h3>
<p class="text-muted-foreground leading-relaxed grow">{!! $card['description'] ?? '' !!}</p>
@if (!empty($card['button_text']))
<div class="pt-6">
<a href="{{ $card['button_link'] ?? '/contact' }}" class="btn-primary-accent px-6 py-3 inline-block">
{{ $card['button_text'] }}
</a>
</div>
@endif
</div>
@endforeach
</div>
</div>
</section>

View file

@ -0,0 +1,256 @@
{{-- Detailseiten-Vorlage laut umsetzung-inhalte.md §5:
Header (Logo + Claim + Stimmungsbild) → Einleitung → Leistungen → Galerie → CTA --}}
<article>
{{-- 1. Header --}}
<section class="section-padding">
<div class="container-padding">
<a href="{{ route('marken') }}"
class="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors mb-8">
<span aria-hidden="true">&larr;</span> {{ __('ui.all_brands') }}
</a>
<div class="grid lg:grid-cols-2 gap-12 items-center">
<div>
@if (!empty($brand['logo']))
<img src="{{ theme_image_url($brand['logo']) }}" alt="{{ $brand['name'] }}"
class="h-10 w-auto mb-6 object-contain object-left" />
@endif
<h1 class="text-section-title">{{ $brand['name'] }}</h1>
<p class="text-large text-secondary font-medium mt-2">{{ $brand['tagline'] }}</p>
@if (!empty($brand['showroom']))
<p class="text-muted-foreground mt-4 inline-flex items-center gap-2">
<svg class="w-5 h-5 text-secondary" fill="none" stroke="currentColor" stroke-width="1.5"
viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
</svg>
{{ $brand['showroom'] }}
</p>
@endif
</div>
<div>
@if (!empty($brand['image']))
<img src="{{ theme_image_url($brand['image']) }}" alt="{{ $brand['name'] }}"
class="w-full rounded-2xl object-cover" />
@else
<div class="w-full aspect-[4/3] rounded-2xl bg-muted flex items-center justify-center">
<span class="text-muted-foreground text-sm">{{ $brand['name'] }}</span>
</div>
@endif
</div>
</div>
</div>
</section>
{{-- 2. Einleitung --}}
@if (!empty($brand['description']))
<section class="pb-16">
<div class="container-narrow">
<div class="prose-content text-large text-muted-foreground leading-relaxed">
{!! $brand['description'] !!}
</div>
</div>
</section>
@endif
{{-- 3. Leistungen --}}
@if (!empty($brand['services']))
<section class="section-padding bg-accent">
<div class="container-padding">
<h2 class="text-section-title text-center mb-12">{{ __('ui.brand_services') }}</h2>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-8">
@foreach ($brand['services'] as $index => $service)
<div class="card-elevated rounded-2xl p-6" wire:key="service-{{ $index }}">
<h3 class="text-lg font-medium">{{ $service['title'] ?? '' }}</h3>
<p class="text-muted-foreground leading-relaxed mt-2">{{ $service['description'] ?? '' }}</p>
</div>
@endforeach
</div>
</div>
</section>
@endif
{{-- 4. Galerie: realisierte Projekte und Inspirationen strikt getrennt --}}
@php
$galleryProjects = $gallery['projects'] ?? [];
$galleryInspirations = $gallery['inspirations'] ?? [];
$hasBothGalleryGroups = $galleryProjects !== [] && $galleryInspirations !== [];
$initialGalleryTab = $galleryProjects !== [] ? 'projects' : 'inspirations';
$galleryLightbox = collect(['projects' => $galleryProjects, 'inspirations' => $galleryInspirations])
->map(fn (array $items) => collect($items)->map(fn (array $item) => [
'src' => theme_image_url($item['image']),
'caption' => $item['caption'] ?? '',
'badge' => $item['category'] === \App\Models\CmsBrand::GALLERY_PROJECT
? __('ui.brand_gallery_badge_project')
: __('ui.brand_gallery_badge_inspiration'),
])->values()->all())
->all();
@endphp
@if ($galleryProjects || $galleryInspirations)
<section id="galerie" class="section-padding"
x-data="{
tab: '{{ $initialGalleryTab }}',
groups: @js($galleryLightbox),
lightbox: false,
current: 0,
get items() { return this.groups[this.tab] ?? [] },
open(index) { this.current = index; this.lightbox = true },
next() { this.current = (this.current + 1) % this.items.length },
prev() { this.current = (this.current - 1 + this.items.length) % this.items.length },
}">
<div class="container-padding">
<div class="text-center max-w-2xl mx-auto">
<h2 class="text-section-title">{{ __('ui.brand_gallery_title') }}</h2>
<p class="text-muted-foreground leading-relaxed mt-4">{{ __('ui.brand_gallery_intro') }}</p>
</div>
@if ($hasBothGalleryGroups)
<div class="flex justify-center mt-10" role="tablist" aria-label="{{ __('ui.brand_gallery_title') }}">
<div class="inline-flex rounded-full bg-accent p-1 gap-1">
@foreach (['projects' => __('ui.brand_gallery_tab_projects'), 'inspirations' => __('ui.brand_gallery_tab_inspirations')] as $tabKey => $tabLabel)
<button type="button" role="tab" wire:key="gallery-tab-{{ $tabKey }}"
x-on:click="tab = '{{ $tabKey }}'"
x-bind:aria-selected="tab === '{{ $tabKey }}' ? 'true' : 'false'"
x-bind:class="tab === '{{ $tabKey }}'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'"
class="px-6 py-2.5 rounded-full text-sm font-medium transition-colors cursor-pointer">
{{ $tabLabel }}
</button>
@endforeach
</div>
</div>
@endif
@foreach (['projects' => $galleryProjects, 'inspirations' => $galleryInspirations] as $groupKey => $groupItems)
@if ($groupItems)
<div wire:key="gallery-group-{{ $groupKey }}"
@if ($hasBothGalleryGroups) x-show="tab === '{{ $groupKey }}'" x-cloak @endif
class="mt-10">
@unless ($hasBothGalleryGroups)
<h3 class="text-xl font-medium text-center mb-8">
{{ $groupKey === 'projects' ? __('ui.brand_gallery_tab_projects') : __('ui.brand_gallery_tab_inspirations') }}
</h3>
@endunless
@if ($groupKey === 'inspirations')
<p class="text-sm text-muted-foreground text-center max-w-2xl mx-auto mb-8">
{{ __('ui.brand_gallery_inspiration_note') }}
</p>
@endif
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach ($groupItems as $index => $item)
<figure wire:key="gallery-{{ $groupKey }}-{{ $index }}" class="group">
<button type="button" x-on:click="open({{ $index }})"
class="relative block w-full overflow-hidden rounded-2xl cursor-pointer">
<img src="{{ theme_image_url($item['image']) }}"
alt="{{ $item['caption'] ?: $brand['name'] }}" loading="lazy"
class="w-full aspect-[4/3] object-cover transition-transform duration-300 group-hover:scale-105" />
<span class="absolute top-3 left-3 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
{{ $groupKey === 'projects' ? __('ui.brand_gallery_badge_project') : __('ui.brand_gallery_badge_inspiration') }}
</span>
</button>
@if (!empty($item['caption']))
<figcaption class="text-sm text-muted-foreground mt-3">{{ $item['caption'] }}</figcaption>
@endif
</figure>
@endforeach
</div>
</div>
@endif
@endforeach
</div>
{{-- Lightbox: ins body geteleportet, damit keine Eltern-Constraints stören --}}
<template x-teleport="body">
<div x-show="lightbox" x-cloak class="fixed inset-0 z-50 bg-black/90"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0"
x-transition:leave="transition ease-in duration-200" x-transition:leave-end="opacity-0"
x-on:keydown.escape.window="lightbox = false"
x-on:keydown.arrow-right.window="lightbox && next()"
x-on:keydown.arrow-left.window="lightbox && prev()">
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 p-6">
<img x-bind:src="items[current]?.src" x-bind:alt="items[current]?.caption"
class="max-h-[80vh] max-w-[90vw] rounded-lg object-contain" />
<p class="text-center text-sm text-white/80">
<span class="rounded-full border border-white/30 px-3 py-1" x-text="items[current]?.badge"></span>
<span class="ml-3" x-text="items[current]?.caption"></span>
</p>
</div>
<button type="button" x-on:click="lightbox = false" aria-label="{{ __('ui.close') }}"
class="absolute top-6 right-6 flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border border-white/20 bg-white/10 text-white/90 transition-all hover:bg-white/20 hover:text-white">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button type="button" x-show="items.length > 1" x-on:click="prev()" aria-label="{{ __('ui.previous') }}"
class="absolute left-4 top-1/2 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-white/20 bg-white/10 text-white/90 transition-all hover:bg-white/20">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button type="button" x-show="items.length > 1" x-on:click="next()" aria-label="{{ __('ui.next') }}"
class="absolute right-4 top-1/2 flex h-12 w-12 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-white/20 bg-white/10 text-white/90 transition-all hover:bg-white/20">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</template>
</section>
@endif
{{-- 5. CTA --}}
<section class="section-padding bg-secondary text-secondary-foreground">
<div class="container-narrow text-center spacing-section">
<h2 class="text-section-title text-white">{{ __('ui.brand_cta_title', ['name' => $brand['name']]) }}</h2>
<p class="text-large max-w-2xl mx-auto leading-relaxed">{{ __('ui.brand_cta_text') }}</p>
<div class="pt-4 flex flex-wrap items-center justify-center gap-4">
<a href="{{ route('contact') }}" class="btn-primary-accent px-8 py-4 text-lg">
{{ __('ui.request_appointment') }}
</a>
@if (!empty($brand['website_url']))
<a href="{{ $brand['website_url'] }}" target="_blank" rel="noopener"
class="px-8 py-4 text-lg underline underline-offset-4">
{{ __('ui.brand_website') }}
</a>
@endif
</div>
{{-- Der WhatsApp-Button kommt mit AP3, sobald die Nummer bestätigt ist. --}}
</div>
</section>
{{-- Weitere Marken --}}
@if (!empty($otherBrands))
<section class="section-padding bg-accent">
<div class="container-padding">
<h2 class="text-section-title text-center mb-12">{{ __('ui.other_brands') }}</h2>
<div class="grid sm:grid-cols-2 gap-8 max-w-4xl mx-auto">
@foreach ($otherBrands as $other)
<a href="{{ route('marken.show', ['slug' => $other['slug']]) }}"
wire:key="other-{{ $other['slug'] }}"
class="card-elevated rounded-2xl p-8 block hover:shadow-lg transition-shadow duration-300">
<h3 class="text-xl font-medium">{{ $other['name'] }}</h3>
<p class="text-secondary font-medium mt-1">{{ $other['tagline'] }}</p>
<p class="text-muted-foreground leading-relaxed mt-3">{!! $other['teaser'] !!}</p>
</a>
@endforeach
</div>
</div>
</section>
@endif
</article>

View file

@ -0,0 +1,59 @@
{{-- Übersichtsseite /marken: je Marke Logo, Bild, Claim, Text, drei Merkmale und der Weg
zur Detailseite. Jede Marke steht in einem eigenen Rahmen, damit klar ist, was zusammengehört.
Abwechselnde Bildseite, damit die Liste nicht monoton wird. --}}
<section class="section-padding {{ $bg }}">
<div class="container-padding space-y-10 lg:space-y-14">
@foreach ($brands as $index => $brand)
<article class="card-elevated rounded-3xl p-6 sm:p-8 lg:p-12" wire:key="brand-{{ $brand['slug'] }}">
<div class="grid lg:grid-cols-2 gap-10 lg:gap-16 items-center">
<div class="{{ $loop->even ? '' : 'lg:order-2' }}">
@if (!empty($brand['image']))
<img src="{{ theme_image_url($brand['image']) }}" alt="{{ $brand['name'] }}"
class="w-full rounded-2xl object-cover aspect-[4/3]" loading="lazy" />
@else
{{-- Platzhalter, solange die Bildmotive fehlen (offene Zulieferung). --}}
<div class="w-full aspect-[4/3] rounded-2xl bg-muted flex items-center justify-center">
<span class="text-muted-foreground text-sm">{{ $brand['name'] }}</span>
</div>
@endif
</div>
<div class="{{ $loop->even ? '' : 'lg:order-1' }}">
@if (!empty($brand['logo']))
<img src="{{ theme_image_url($brand['logo']) }}" alt="{{ $brand['name'] }}"
class="h-9 w-auto mb-5 object-contain object-left" loading="lazy" />
@endif
<h2 class="text-2xl lg:text-3xl font-medium">{{ $brand['name'] }}</h2>
<p class="text-large text-secondary font-medium mt-1">{{ $brand['tagline'] }}</p>
@foreach (preg_split('/\R{2,}/', trim((string) ($brand['summary'] ?: $brand['teaser']))) as $i => $paragraph)
<p class="text-muted-foreground leading-relaxed mt-4">{!! nl2br(e($paragraph)) !!}</p>
@endforeach
@if (!empty($brand['highlights']))
<ul class="mt-6 space-y-2 pt-6 border-t border-border/60">
@foreach ($brand['highlights'] as $i => $highlight)
<li class="flex items-start gap-3" wire:key="hl-{{ $brand['slug'] }}-{{ $i }}">
<svg class="w-5 h-5 text-secondary shrink-0 mt-0.5" fill="none" stroke="currentColor"
stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="text-muted-foreground">{{ is_array($highlight) ? ($highlight['title'] ?? '') : $highlight }}</span>
</li>
@endforeach
</ul>
@endif
@if (!empty($brand['has_detail_page']))
<a href="{{ route('marken.show', ['slug' => $brand['slug']]) }}"
class="btn-primary-accent px-6 py-3 mt-8 inline-block">
{{ __('ui.more_about', ['name' => $brand['name']]) }}
</a>
@endif
</div>
</div>
</article>
@endforeach
</div>
</section>

View file

@ -0,0 +1,53 @@
<section class="section-padding {{ $bg }}" id="marken">
<div class="container-padding">
<div class="text-center mb-16 slide-up delay-300">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
@if (!empty($content['subtitle']))
<p class="text-large text-muted-foreground mt-4 max-w-2xl mx-auto">{{ $content['subtitle'] }}</p>
@endif
</div>
<div class="grid md:grid-cols-3 gap-8 slide-up delay-400">
@foreach ($brands as $brand)
<div class="card-elevated rounded-2xl overflow-hidden flex flex-col"
wire:key="brand-{{ $brand['slug'] }}">
@if (!empty($brand['image']))
<img src="{{ theme_image_url($brand['image']) }}" alt="{{ $brand['name'] }}"
class="w-full h-56 object-cover" loading="lazy" />
@else
{{-- Platzhalter, solange die Bildmotive fehlen (offene Zulieferung). --}}
<div class="w-full h-56 bg-muted flex items-center justify-center">
<span class="text-muted-foreground text-sm">{{ $brand['name'] }}</span>
</div>
@endif
<div class="p-8 flex flex-col grow">
@if (!empty($brand['logo']))
<img src="{{ theme_image_url($brand['logo']) }}" alt="{{ $brand['name'] }}"
class="h-8 w-auto mb-4 object-contain object-left" loading="lazy" />
@endif
<h3 class="text-xl font-medium">{{ $brand['name'] }}</h3>
<p class="text-secondary font-medium mt-1">{{ $brand['tagline'] }}</p>
<p class="text-muted-foreground leading-relaxed mt-4 grow">{!! $brand['teaser'] !!}</p>
@if (!empty($brand['has_detail_page']))
<a href="{{ route('marken.show', ['slug' => $brand['slug']]) }}"
class="text-secondary font-medium mt-6 inline-flex items-center gap-1 hover:underline">
{{ __('ui.more_about', ['name' => $brand['name']]) }}
<span aria-hidden="true">&rarr;</span>
</a>
@endif
</div>
</div>
@endforeach
</div>
{{-- Die Startseite bleibt ein Teaser – die vollstaendige Darstellung steht auf /marken. --}}
<div class="text-center mt-12 slide-up delay-500">
<a href="{{ route('marken') }}" class="btn-primary-accent px-8 py-4 text-lg inline-block">
{{ __('ui.all_brands_cta') }}
</a>
</div>
</div>
</section>

View file

@ -0,0 +1,37 @@
{{-- Livewire braucht ein Wurzelelement. Solange kein Display veröffentlicht ist, bleibt es leer –
lieber gar keine Sektion als ein Rahmen mit ewigem Ladebalken. --}}
<div>
@if ($displayId !== null)
<section class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
<div>
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{{ $content['text'] ?? '' }}</p>
@if (!empty($content['note']))
<p class="text-sm text-muted-foreground mt-4">{{ $content['note'] }}</p>
@endif
@if ($showLink && !empty($content['link_text']))
<a href="{{ $content['link_url'] ?? route('referenzen') }}"
class="btn-primary-accent px-6 py-3 mt-8 inline-block">
{{ $content['link_text'] }}
</a>
@endif
</div>
{{-- 9:16 wie das physische Display im Showroom --}}
<div class="flex justify-center">
<div class="w-full max-w-[320px] aspect-[9/16] overflow-hidden rounded-2xl shadow-elevated bg-black">
<iframe src="{{ route('display.embed', ['display' => $displayId]) }}"
title="{{ $content['iframe_title'] ?? __('ui.display_feed_title') }}"
loading="lazy" scrolling="no"
class="h-full w-full border-0"></iframe>
</div>
</div>
</div>
</div>
</section>
@endif
</div>

View file

@ -0,0 +1,17 @@
{{-- Bewusst klein und dezent: wer gezielt sucht, findet den Weg; wer wegen Einrichtung
kommt, wird nicht abgelenkt. Keine Preise, keine Renditeversprechen. --}}
<section class="py-12 {{ $bg }}">
<div class="container-narrow">
<div class="border-t border-border pt-10 text-center">
<h2 class="text-xl font-medium">{!! $content['title'] ?? '' !!}</h2>
<p class="text-muted-foreground leading-relaxed mt-3 max-w-2xl mx-auto">{{ $content['text'] ?? '' }}</p>
@if (!empty($content['link_text']))
<a href="{{ $content['link_url'] ?? '/immobilien' }}"
class="text-secondary font-medium mt-4 inline-flex items-center gap-1 hover:underline">
{{ $content['link_text'] }} <span aria-hidden="true">&rarr;</span>
</a>
@endif
</div>
</div>
</section>

View file

@ -32,7 +32,7 @@
</div>
<h3 class="text-xl lg:text-2xl font-semibold text-foreground leading-tight group-hover:text-secondary transition-colors duration-200">
<a href="/magazin/{{ $post['id'] }}" class="stretched-link">
<a href="{{ route('magazin.detail', ['slug' => $post['slug']]) }}" class="stretched-link">
{!! $post['title'] !!}
</a>
</h3>
@ -44,14 +44,14 @@
<div class="flex items-center justify-between mt-6">
<div class="flex items-center text-secondary font-medium group-hover:text-secondary-dark transition-colors duration-200">
<a href="/magazin/{{ $post['id'] }}" class="text-sm">{{ $content['read_more'] }}</a>
<a href="{{ route('magazin.detail', ['slug' => $post['slug']]) }}" class="text-sm">{{ $content['read_more'] }}</a>
<svg class="w-4 h-4 ml-2 transform group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
<div class="w-10 h-10 bg-primary rounded-full flex items-center justify-center group-hover:bg-secondary transition-colors duration-200">
<a href="/magazin/{{ $post['id'] }}" aria-label="{{ __('ui.read_article', ['title' => $post['title']]) }}">
<a href="{{ route('magazin.detail', ['slug' => $post['slug']]) }}" aria-label="{{ __('ui.read_article', ['title' => $post['title']]) }}">
<svg class="w-4 h-4 text-white transform group-hover:translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>

View file

@ -0,0 +1,22 @@
<section class="section-padding bg-secondary text-secondary-foreground">
<div class="container-narrow text-center spacing-section">
<h2 class="text-section-title text-white">{!! $content['title'] ?? '' !!}</h2>
<p class="text-large max-w-2xl mx-auto leading-relaxed">{{ $content['text'] ?? '' }}</p>
<div class="pt-4 flex flex-wrap items-center justify-center gap-4">
<a href="{{ $content['button_link'] ?? '/contact' }}" class="btn-primary-accent px-8 py-4 text-lg">
{{ $content['button_text'] ?? __('ui.request_appointment') }}
</a>
{{-- WhatsApp erscheint erst, wenn die Nummer im CMS hinterlegt ist (global.contact.whatsapp).
Das Widget selbst kommt mit AP3. --}}
@php $whatsapp = whatsapp_link(); @endphp
@if ($whatsapp)
<a href="{{ $whatsapp }}" target="_blank" rel="noopener"
class="px-8 py-4 text-lg underline underline-offset-4">
{{ $content['whatsapp_text'] ?? __('ui.whatsapp') }}
</a>
@endif
</div>
</div>
</section>

View file

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

View file

@ -0,0 +1,46 @@
@php
if (!function_exists('renderHeroIcon')) {
function renderHeroIcon($iconName, $style = 'outline', $color = 'text-secondary')
{
$iconPath = public_path("heroicons/optimized/24/{$style}/{$iconName}.svg");
$fallbackPath = public_path('heroicons/optimized/24/outline/sparkles.svg');
if (file_exists($iconPath)) {
return str_replace('<svg', '<svg class="w-10 h-10 ' . $color . '"', file_get_contents($iconPath));
}
if (file_exists($fallbackPath)) {
return str_replace('<svg', '<svg class="w-10 h-10 ' . $color . '"', file_get_contents($fallbackPath));
}
return '';
}
}
@endphp
<section class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="container-narrow text-center">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
@foreach ($content['paragraphs'] ?? [] as $i => $paragraph)
<p class="text-large text-muted-foreground leading-relaxed mt-5" wire:key="p-{{ $i }}">
{!! $paragraph !!}
</p>
@endforeach
</div>
@if (!empty($content['points']))
<div class="grid md:grid-cols-3 gap-8 mt-16">
@foreach ($content['points'] as $index => $point)
<div class="card-elevated rounded-2xl p-8 text-center" wire:key="point-{{ $index }}">
<div class="mx-auto w-20 h-20 icon-secondary-linear glow-soft rounded-2xl flex items-center justify-center mb-5">
{!! renderHeroIcon($point['icon'] ?? 'user', 'outline', 'text-secondary-foreground') !!}
</div>
<h3 class="text-lg font-medium">{{ $point['title'] ?? '' }}</h3>
<p class="text-muted-foreground leading-relaxed mt-2">{{ $point['description'] ?? '' }}</p>
</div>
@endforeach
</div>
@endif
</div>
</section>

View file

@ -0,0 +1,36 @@
{{-- Seitenkopf für Unterseiten – abgesetzte graue Fläche wie der Hero auf Startseite und
Netzwerkseite, damit er sich klar von den Blöcken darunter absetzt. --}}
<section class="section-padding flex items-center relative border-b border-border/30 {{ $bg }}">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-20 bg-hero-container rounded-[20px] w-[95%]">
{{-- Breadcrumb: Home → Marken --}}
<nav aria-label="Breadcrumb" class="mb-8">
<ol class="flex items-center gap-2 text-sm text-muted-foreground">
<li><a href="{{ route('home') }}" class="hover:text-foreground transition-colors">Home</a></li>
<li aria-hidden="true">/</li>
<li class="text-foreground" aria-current="page">{{ $breadcrumb }}</li>
</ol>
</nav>
@if (!empty($content['image']))
{{-- 3:2 statt hälftig geteilt – der Text bleibt der Hauptdarsteller, das Bild begleitet ihn nur. --}}
<div class="grid lg:grid-cols-5 gap-10 lg:gap-14 items-center">
<div class="lg:col-span-3 slide-right delay-200">
<h1 class="text-hero-alt">{!! $content['title'] ?? '' !!}</h1>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{{ $content['text'] ?? '' }}</p>
</div>
<div class="lg:col-span-2 relative rounded-2xl overflow-hidden shadow-elevated slide-left delay-300">
<img src="{{ theme_image_url($content['image']) }}" alt="{{ $content['image_alt'] ?? '' }}"
class="w-full aspect-[4/3] object-cover" />
<div class="absolute inset-0 bg-gradient-to-t from-black/15 to-transparent"></div>
</div>
</div>
@else
{{-- Ohne Motiv bleibt der Textblock allein stehen – kein leerer Platzhalterrahmen. --}}
<div class="max-w-3xl slide-right delay-200">
<h1 class="text-hero-alt">{!! $content['title'] ?? '' !!}</h1>
<p class="text-large text-muted-foreground leading-relaxed mt-6">{{ $content['text'] ?? '' }}</p>
</div>
@endif
</div>
</section>

View file

@ -0,0 +1,48 @@
<section class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="text-center max-w-2xl mx-auto">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
@if (!empty($content['text']))
<p class="text-large text-muted-foreground leading-relaxed mt-4">{{ $content['text'] }}</p>
@endif
</div>
@if ($projects)
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-12">
@foreach ($projects as $project)
<figure class="card-elevated rounded-2xl overflow-hidden group" wire:key="ref-{{ $project['key'] }}">
<div class="relative overflow-hidden">
<img src="{{ theme_image_url($project['image']) }}"
alt="{{ $project['caption'] ?: $project['brand'] }}" loading="lazy"
class="w-full aspect-[4/3] object-cover transition-transform duration-300 group-hover:scale-105" />
<span class="absolute top-3 left-3 rounded-full bg-black/60 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
{{ __('ui.brand_gallery_badge_project') }}
</span>
</div>
<figcaption class="p-5">
@if (!empty($project['caption']))
<p class="font-medium">{{ $project['caption'] }}</p>
@endif
@if ($project['has_detail_page'])
<a href="{{ route('marken.show', ['slug' => $project['brand_slug']]) }}"
class="text-sm text-secondary underline underline-offset-2 hover:no-underline mt-1 inline-block">
{{ $project['brand'] }}
</a>
@else
<p class="text-sm text-muted-foreground mt-1">{{ $project['brand'] }}</p>
@endif
</figcaption>
</figure>
@endforeach
</div>
@else
{{-- Ehrlicher Leerzustand statt einer erfundenen Referenz. --}}
<div class="mt-12 rounded-2xl border border-dashed border-border p-10 text-center">
<p class="text-muted-foreground">{{ $content['empty'] ?? __('ui.references_empty') }}</p>
<a href="{{ route('contact') }}" class="btn-primary-accent px-6 py-3 mt-6 inline-block">
{{ __('ui.request_appointment') }}
</a>
</div>
@endif
</div>
</section>

View file

@ -0,0 +1,33 @@
<section class="section-padding {{ $bg }}">
<div class="container-padding">
<div class="text-center mb-12 slide-up delay-300">
<h2 class="text-section-title">{!! $content['title'] ?? '' !!}</h2>
@if (!empty($content['subtitle']))
<p class="text-large text-muted-foreground mt-4 max-w-2xl mx-auto">{{ $content['subtitle'] }}</p>
@endif
</div>
{{-- Kachel je Verein: Logo oben auf Weiß (die Logos sind für weißen Grund gemacht),
darunter ein kurzer Text. Bewusst ohne Verlinkung – reine Nennung. --}}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-8 max-w-5xl mx-auto slide-up delay-400">
@foreach ($content['logos'] ?? [] as $index => $logo)
<div class="card-elevated rounded-2xl overflow-hidden" wire:key="sponsor-{{ $index }}">
{{-- Feste Bühnenhöhe und Deckel in beide Richtungen: die Logos haben sehr
unterschiedliche Seitenverhältnisse (1:1 bis 3,3:1) und wirken sonst
unterschiedlich groß. --}}
<div class="bg-white flex items-center justify-center h-36 px-8">
<img src="{{ theme_image_url($logo['image']) }}" alt="{{ $logo['name'] ?? '' }}"
loading="lazy" class="max-h-20 max-w-[85%] w-auto object-contain" />
</div>
<div class="p-6 border-t border-border/60">
<h3 class="font-medium">{{ $logo['name'] ?? '' }}</h3>
@if (!empty($logo['text']))
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{{ $logo['text'] }}</p>
@endif
</div>
</div>
@endforeach
</div>
</div>
</section>

View file

@ -1,5 +1,16 @@
@if (!empty($content) && ($content['enabled'] ?? false))
<div x-data="{ dismissed: localStorage.getItem('announcement_dismissed_{{ $content['id'] ?? 'default' }}') === 'true' }" x-show="!dismissed" x-transition:leave="transition ease-in duration-200"
{{-- Kein x-cloak: Livewire setzt global [x-cloak]{display:none!important}. Mit x-cloak wuerde
die Leiste dauerhaft verschwinden, sobald Alpine das Attribut nicht entfernt. Ohne Alpine
ist sie sichtbar – das ist hier der richtige Standardfall.
Der localStorage-Zugriff steht bewusst im x-data (kein Aufblitzen) und ist abgesichert,
weil er im privaten Modus oder bei blockiertem Storage wirft. --}}
<div x-data="(() => {
const key = @js($this->dismissKey);
let dismissed = false;
try { dismissed = localStorage.getItem(key) === 'true'; } catch (e) {}
return { key, dismissed };
})()"
x-show="!dismissed" x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 max-h-16" x-transition:leave-end="opacity-0 max-h-0"
class="bg-primary text-secondary-foreground overflow-hidden" id="topbar">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@ -28,7 +39,7 @@
@endif
<button
@click="dismissed = true; localStorage.setItem('announcement_dismissed_{{ $content['id'] ?? 'default' }}', 'true')"
@click="dismissed = true; try { localStorage.setItem(key, 'true') } catch (e) {}"
class="absolute right-0 top-1/2 -translate-y-1/2 p-1.5 text-white/60 hover:text-white transition-colors"
aria-label="{{ __('ui.announcement_close') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">

View file

@ -0,0 +1,175 @@
{{-- Schwebende Terminanfrage, unten rechts. Der Cookie-Button des Consent-Managers sitzt unten
links (z-40), sein Dialog auf z-50 – dieses Widget bleibt darunter, damit die Einwilligung
immer erreichbar bleibt. --}}
<div class="fixed bottom-4 right-4 z-40 flex flex-col items-end gap-3 print:hidden"
x-data="{
open: false,
whatsappBase: @js($this->whatsappUrl),
topicLabels: @js($this->topics),
greeting: @js($content['whatsapp_text'] ?? __('ui.appointment.whatsapp_text')),
/* Baut den Nachrichtentext aus dem, was schon im Formular steht. Der Besucher sieht ihn
in WhatsApp und schickt ihn selbst ab – wir verschicken nichts automatisch. */
get whatsappHref() {
const lines = [this.greeting];
const topic = this.topicLabels[$wire.topic];
if (topic) { lines.push('Anliegen: ' + topic) }
if ($wire.preferredDate) { lines.push('Wunschtermin: ' + $wire.preferredDate) }
if ($wire.name) { lines.push('Mein Name: ' + $wire.name) }
return this.whatsappBase + '?text=' + encodeURIComponent(lines.join('\n'));
},
}" x-on:keydown.escape.window="open = false">
{{-- Panel --}}
<div x-show="open" x-cloak x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2" x-transition:leave="transition ease-in duration-150"
x-transition:leave-end="opacity-0 translate-y-2" x-on:click.outside="open = false"
role="dialog" aria-modal="true" aria-labelledby="appointment-widget-title"
class="card-elevated w-[calc(100vw-2rem)] sm:w-[26rem] max-h-[80vh] overflow-y-auto rounded-2xl p-6">
<div class="flex items-start justify-between gap-4">
<h2 id="appointment-widget-title" class="text-xl font-medium">
{{ $content['title'] ?? __('ui.request_appointment') }}
</h2>
<button type="button" x-on:click="open = false" aria-label="{{ __('ui.close') }}"
class="shrink-0 text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
@if ($success)
<div class="mt-6 rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-800">
{{ $content['success'] ?? __('ui.appointment.success') }}
</div>
<button type="button" wire:click="startOver"
class="btn-secondary-accent w-full mt-4 px-6 py-3 cursor-pointer">
{{ __('ui.appointment.new_request') }}
</button>
@else
@if (!empty($content['intro']))
<p class="text-sm text-muted-foreground leading-relaxed mt-2">{{ $content['intro'] }}</p>
@endif
<form wire:submit="submit" class="space-y-4 mt-6">
<div>
<label for="appointment-name" class="text-foreground mb-2 block text-sm font-medium">
{{ __('ui.appointment.name') }} <span aria-hidden="true">*</span>
</label>
<input wire:model="name" id="appointment-name" type="text" required
class="w-full px-3 py-2 bg-gray-50 border border-border rounded-lg outline-1 -outline-offset-1 outline-gray-300 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-600 sm:text-sm/6" />
@error('name') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<div>
<label for="appointment-email" class="text-foreground mb-2 block text-sm font-medium">
{{ __('ui.appointment.email') }} <span aria-hidden="true">*</span>
</label>
<input wire:model="email" id="appointment-email" type="email" required
class="w-full px-3 py-2 bg-gray-50 border border-border rounded-lg outline-1 -outline-offset-1 outline-gray-300 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-600 sm:text-sm/6" />
@error('email') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<div>
<label for="appointment-phone" class="text-foreground mb-2 block text-sm font-medium">
{{ __('ui.appointment.phone') }}
</label>
<input wire:model="phone" id="appointment-phone" type="tel"
class="w-full px-3 py-2 bg-gray-50 border border-border rounded-lg outline-1 -outline-offset-1 outline-gray-300 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-600 sm:text-sm/6" />
@error('phone') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<div>
<label for="appointment-topic" class="text-foreground mb-2 block text-sm font-medium">
{{ __('ui.appointment.topic') }}
</label>
<select wire:model="topic" id="appointment-topic"
class="w-full px-3 py-2 bg-gray-50 border border-border rounded-lg outline-1 -outline-offset-1 outline-gray-300 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-600 sm:text-sm/6">
<option value="">{{ __('ui.appointment.topic_placeholder') }}</option>
@foreach ($this->topics as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
@error('topic') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<div>
<label for="appointment-date" class="text-foreground mb-2 block text-sm font-medium">
{{ __('ui.appointment.preferred_date') }}
</label>
<input wire:model="preferredDate" id="appointment-date" type="date"
min="{{ now()->toDateString() }}"
class="w-full px-3 py-2 bg-gray-50 border border-border rounded-lg outline-1 -outline-offset-1 outline-gray-300 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-600 sm:text-sm/6" />
@error('preferredDate') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
<div>
<label for="appointment-message" class="text-foreground mb-2 block text-sm font-medium">
{{ __('ui.appointment.message') }}
</label>
<textarea wire:model="message" id="appointment-message" rows="3"
placeholder="{{ __('ui.appointment.message_placeholder') }}"
class="w-full px-3 py-2 bg-gray-50 border border-border rounded-lg outline-1 -outline-offset-1 outline-gray-300 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-gray-600 sm:text-sm/6 resize-none"></textarea>
@error('message') <span class="text-red-500 text-xs">{{ $message }}</span> @enderror
</div>
{{-- Honeypot --}}
<div class="opacity-0 absolute top-0 left-0 h-0 w-0 -z-10 overflow-hidden" aria-hidden="true">
<label for="appointment-website">Website</label>
<input type="text" id="appointment-website" wire:model="website" tabindex="-1" autocomplete="off" />
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input wire:model="privacy" id="appointment-privacy" type="checkbox"
class="mt-1 shrink-0 rounded border-border" />
<span class="text-sm text-muted-foreground">
{{ __('ui.contact_form.privacy_prefix') }}
<a href="{{ route('privacy') }}" target="_blank"
class="text-secondary underline underline-offset-2 hover:no-underline">Datenschutzerklärung</a>
{{ __('ui.contact_form.privacy_suffix') }}
</span>
</label>
@error('privacy') <span class="text-red-500 text-xs block">{{ $message }}</span> @enderror
<button type="submit" wire:loading.attr="disabled"
class="btn-primary-accent w-full px-6 py-3 cursor-pointer disabled:opacity-70">
<span wire:loading.remove wire:target="submit">{{ __('ui.appointment.submit') }}</span>
<span wire:loading wire:target="submit">{{ __('ui.appointment.sending') }}</span>
</button>
</form>
{{-- Alternative: WhatsApp. Nur sichtbar, wenn im CMS eine Nummer hinterlegt ist.
Der Link ist ein normaler Klick-zum-Chat-Link – auf dem Handy öffnet die
installierte App, sonst WhatsApp Web. Der Text ist vorbereitet, abgeschickt
wird er vom Besucher selbst. --}}
@if ($this->whatsappUrl !== '')
<div class="mt-6 pt-6 border-t border-border/60">
<p class="text-sm text-muted-foreground">{{ __('ui.appointment.whatsapp_intro') }}</p>
<a x-bind:href="whatsappHref" target="_blank" rel="noopener"
class="mt-3 flex w-full items-center justify-center gap-2 rounded-lg bg-[#25D366] px-6 py-3 font-medium text-white transition-transform hover:scale-[1.02]">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51l-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.872.118.571-.085 1.758-.719 2.006-1.413.247-.694.247-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884a9.82 9.82 0 016.988 2.896 9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.885-9.885 9.885M20.52 3.449C18.24 1.245 15.24 0 12.045 0 5.463 0 .104 5.359.101 11.945c0 2.096.549 4.142 1.595 5.945L0 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.582 0 11.941-5.359 11.944-11.945a11.87 11.87 0 00-3.417-8.4" />
</svg>
{{ __('ui.whatsapp') }}
</a>
<p class="mt-2 text-xs text-muted-foreground">{{ __('ui.appointment.whatsapp_note') }}</p>
</div>
@endif
@endif
</div>
<div class="flex items-center gap-3">
<button type="button" x-on:click="open = !open" x-bind:aria-expanded="open.toString()"
aria-controls="appointment-widget-title"
class="btn-secondary-accent flex items-center gap-2 rounded-full px-5 py-3 shadow-lg cursor-pointer">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"
aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
<span x-show="!open">{{ $content['button_label'] ?? __('ui.request_appointment') }}</span>
<span x-show="open" x-cloak>{{ __('ui.close') }}</span>
</button>
</div>
</div>

View file

@ -5,38 +5,38 @@
<div class="spacing-section">
<div class="flex items-center justify-center">
<img src="{{ asset(\App\Helpers\ThemeHelper::getLogoPath('negative')) }}"
alt="{{ $domainName ?? 'B2in' }} Logo" class="h-14 w-auto"
width="120" height="56"
alt="{{ $domainName ?? 'B2in Interior' }} Logo" class="h-16 w-auto"
width="81" height="64"
loading="lazy" />
</div>
<div class="container-narrow spacing-content">
<h2 class="text-section-title text-dark-text leading-tight">
Connecting Design and <span class="text-secondary">Property</span>
{!! $content['claim'] ?? 'Premium-Einrichtung. <span class="text-secondary">Persönlich gemacht.</span>' !!}
</h2>
<p class="text-dark-muted text-sm mt-4">Marcel Scheibe – {{ __('ui.founder_ceo') }}</p>
<p class="text-dark-muted text-sm mt-4">{{ $content['byline'] ?? 'Marcel Scheibe' }} – {{ __('ui.founder_ceo') }}</p>
</div>
<hr class="border-t border-dark-muted/30 mt-12 pt-4">
</div>
{{-- Links --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 text-left max-w-4xl mx-auto">
<div class="spacing-small text-center">
<a href="{{ route('privacy') }}" class="block hover-text-secondary transition-colors">Privacy Policy</a>
</div>
<div class="spacing-small text-center">
<a href="{{ route('terms') }}" class="block hover-text-secondary transition-colors">Terms of Service</a>
</div>
<div class="spacing-small text-center">
<a href="{{ route('cookie-policy') }}" class="block hover-text-secondary transition-colors">Cookie Policy</a>
</div>
<div class="spacing-small text-center">
<a href="{{ route('impressum') }}" class="block hover-text-secondary transition-colors">{{ __('ui.legal_notice') }}</a>
</div>
@php
$footerLinks = $content['links'] ?? [
['label' => __('ui.faq'), 'url' => '/faq'],
['label' => __('ui.privacy_policy'), 'url' => '/privacy'],
['label' => __('ui.terms_of_service'), 'url' => '/terms'],
['label' => __('ui.cookie_policy'), 'url' => '/cookie-policy'],
['label' => __('ui.legal_notice'), 'url' => '/impressum'],
];
@endphp
<div class="grid grid-cols-2 md:grid-cols-5 gap-8 text-left max-w-4xl mx-auto">
@foreach ($footerLinks as $footerLink)
<div class="spacing-small text-center" wire:key="footer-link-{{ $loop->index }}">
<a href="{{ $footerLink['url'] }}"
class="block hover-text-secondary transition-colors">{{ $footerLink['label'] }}</a>
</div>
@endforeach
</div>
</div>

View file

@ -3,7 +3,7 @@
<div class="flex items-center justify-between h-16">
<a href="{{ $domainUrl }}" class="flex items-center">
<img src="{{ asset(\App\Helpers\ThemeHelper::getLogoPath('positive')) }}"
alt="{{ $domainName ?? 'B2IN' }} Logo" class="h-10 w-auto" />
alt="{{ $domainName ?? 'B2in Interior' }} Logo" class="h-12 w-auto" />
</a>
<nav class="hidden md:flex items-center space-x-8" aria-label="{{ __('ui.main_navigation') }}">

View file

@ -1,7 +1,7 @@
@props(['url'])
@php
// Logo-URL für E-Mails (muss absolute URL sein, PNG für volle Kompatibilität)
$logoUrl = config('app.url') . '/img/logos/b2in-logo-negative.png';
$logoUrl = config('app.url') . '/img/logos/b2in-interior-logo-negative.png';
@endphp
<tr>
<td class="header" style="background-color: #2b3f51; padding: 30px 20px; text-align: center;" bgcolor="#2b3f51" align="center">
@ -14,7 +14,7 @@
@if (trim($slot) === 'Laravel')
<img src="https://laravel.com/img/notification-logo-v2.1.png" class="logo" alt="Laravel Logo" width="50" height="50" style="display: block; height: 50px; max-height: 50px; width: auto; border: 0; outline: none;" />
@else
<img src="{{ $logoUrl }}" alt="{{ config('app.name') }}" width="140" height="40" style="display: block; height: 40px; max-height: 40px; width: auto; border: 0; outline: none; -ms-interpolation-mode: bicubic;" />
<img src="{{ $logoUrl }}" alt="{{ config('app.name') }}" width="51" height="40" style="display: block; height: 40px; max-height: 40px; width: auto; border: 0; outline: none; -ms-interpolation-mode: bicubic;" />
@endif
</a>
<!--[if mso]>

View file

@ -1,7 +1,7 @@
@extends('web.layouts.web-master')
@section('title', 'Über B2IN - Unser Team & Geschichte')
@section('meta_description', 'Lernen Sie B2in kennen – gegründet von Marcel Scheibe. Unsere Vision: Design & Immobilien intelligent verbinden.')
@section('title', 'Über B2in Interior – Haltung, Geschichte & Marcel Scheibe')
@section('meta_description', 'Wofür B2in Interior steht: ausgewählte Marken, Fachbetriebe aus der Region und ein Ansprechpartner vom ersten Aufmaß bis nach der Montage.')
@section('content')
<div class="bg-background">

View file

@ -1,7 +1,7 @@
@extends('web.layouts.web-master')
@section('title', 'Kontakt - B2in')
@section('meta_description', 'Nehmen Sie Kontakt mit B2in auf – persönliche Beratung zu Immobilien in Dubai & Einrichtungslösungen.')
@section('title', 'Kontakt & Beratungstermin | B2in Interior')
@section('meta_description', 'Beratungstermin vereinbaren oder Anfrage stellen – persönlich, unverbindlich und mit einem festen Ansprechpartner.')
@section('content')
<div class="bg-background">

View file

@ -5,6 +5,7 @@
@endphp
@section('title', $legal['meta_title'] . ' - ' . ($domainName ?? config('app.name')))
@section('meta_description', 'Cookie-Richtlinie von B2in Interior – welche Cookies wir einsetzen und warum.')
@section('content')
<div class="bg-background">

View file

@ -1,7 +1,7 @@
@extends('web.layouts.web-master')
@section('title', 'FAQ - B2in')
@section('meta_description', 'Häufig gestellte Fragen zu B2in – Immobilien in Dubai, Einrichtungsservice und mehr.')
@section('title', 'Häufige Fragen – Beratung, Maßanfertigung & Projekte | B2in Interior')
@section('meta_description', 'Antworten zu Beratung, Maßanfertigung, Montage und der Ausstattung von Projekten – und dazu, wie eine Zusammenarbeit mit B2in Interior abläuft.')
@section('content')
<div class="bg-background">

View file

@ -1,7 +1,7 @@
@extends('web.layouts.web-master')
@section('title', 'B2IN - Connecting Design and Property')
@section('meta_description', 'B2in verbindet exklusive Immobilien in Dubai mit europäischem Einrichtungsdesign. Ihr Partner für Investment & Interior.')
@section('title', 'B2in Interior – Premium-Einrichtung & Maßanfertigung | CABINET, Rotpunkt, Febrü')
@section('meta_description', 'B2in Interior verbindet starke Einrichtungsmarken mit persönlicher Beratung – von der Maßanfertigung bis zur vollständigen Ausstattung für Zuhause und Projekte.')
@section('content')
<div class="bg-background">
@ -10,9 +10,12 @@
<main class="variante-glass-flow">
<livewire:web.components.sections.hero />
<livewire:web.components.sections.founder-bar />
<livewire:web.components.sections.content-section section="synergie_section" layout="left" />
<livewire:web.components.sections.vision-section bg="bg-accent" />
<livewire:web.components.sections.ecosystem-core />
<livewire:web.components.sections.audience-switch />
<livewire:web.components.sections.core-brands bg="bg-accent" />
<livewire:web.components.sections.vision-section />
<livewire:web.components.sections.sponsoring bg="bg-accent" />
<livewire:web.components.sections.display-feed />
<livewire:web.components.sections.immobilien-teaser />
<livewire:web.components.sections.c-t-a-section />
</main>

View file

@ -5,6 +5,7 @@
@endphp
@section('title', $legal['meta_title'] . ' - ' . ($domainName ?? config('app.name')))
@section('meta_description', 'Impressum und Anbieterkennzeichnung von B2in Interior.')
@section('content')
<div class="bg-background">

Some files were not shown because too many files have changed in this diff Show more