Compare commits

..

No commits in common. "v2.0.1" and "master" have entirely different histories.

774 changed files with 3703 additions and 46097 deletions

View file

@ -80,33 +80,3 @@ 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=
# --- Version (leer lassen: kommt aus der Datei RELEASE, die dev/betrieb/deploy.sh schreibt)
APP_VERSION=
# --- GlitchTip (errors.adametz.media, Projekte server-b2in / browser-b2in) – siehe dev/betrieb/README.md
SENTRY_LARAVEL_DSN=
SENTRY_BROWSER_DSN=
SENTRY_ENVIRONMENT=production # Vorschau: preview, lokal: local
SENTRY_RELEASE=
# --- Betriebs-Alerts (Mail und ntfy-Push)
MONITORING_ALERTS_ENABLED=true
MONITORING_ALERT_EMAIL=
MONITORING_NTFY_URL=https://push.adametz.media
MONITORING_NTFY_TOPIC=b2in-ops
MONITORING_NTFY_TOKEN=
MONITORING_NTFY_PRIORITY=4
# --- Tiefer Health-Check /health (Bearer-Token für Uptime Kuma) – je Umgebung ein eigener Wert
MONITORING_HEALTH_TOKEN=
MONITORING_QUEUE_BACKLOG_SECONDS=300
MONITORING_DISK_MIN_FREE_PERCENT=10
# --- Nur lokal: Lesezugriff auf die GlitchTip-API (project:read, event:read) – nie committen
GLITCHTIP_API_TOKEN=

29
.gitignore vendored
View file

@ -1,17 +1,12 @@
# Laravel
/.phpunit.cache
/node_modules
# public/build ist versioniert (Git-Deploy: auf dem Server gibt es kein Node). Nur die
# Ausgabe des alten Standard-Builds (`npm run build`) liegt direkt darunter und wird nicht gebraucht.
/public/build/assets/
/public/build/manifest.json
/public/build
/public/hot
/public/storage
/public/vendor
/public/flux
/storage/*.key
# schreibt dev/betrieb/deploy.sh beim Deploy (Version des ausgelieferten Tags)
/RELEASE
/storage/app
/storage/framework
/storage/language
@ -55,8 +50,7 @@ yarn-error.log
Network Trash Folder
Temporary Items
.apdisk
# macOS-Ordnersymbol heißt „Icon\r“ – ohne [\r] träfe das Muster bei core.ignorecase auch resources/views/flux/icon/
Icon[\r]
Icon
# Project specific
@ -64,22 +58,3 @@ _static/
_work/
_storage/
dev/immobilien 07-05-2026/AZIZI SharePoint
# Deployment-Archive werden bei Bedarf neu erzeugt
dev/**/deploy/*.tar.gz
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
/_b2in_interior_shop/
/docs/
# Original-Bildzulieferungen (mehrere MB je Datei) bleiben ausserhalb der Versionierung;
# die optimierten WebP-Fassungen liegen unter public/img/assets/marken/.
/dev/2026-08-28/images/
# Display-Player (public/_cabinet): Laufzeitdateien und Dateien, die nur auf dem Server liegen.
# Sie dürfen beim Git-Deploy weder überschrieben noch als Abweichung gemeldet werden.
/public/_cabinet/clicks.log
/public/_cabinet/logs/*.log
/public/_cabinet/offers/player.html
/public/_cabinet/offers/logo-cabinet-300.png
# Statisch veröffentlichte Flux-Skripte (alter Pfad), nur auf dem Server
/public/_flux/

View file

@ -128,7 +128,7 @@ Domains are configured in `config/domains.php` with environment variables:
Domain-specific configurations include:
- **portal.b2in.test**: Admin backend with Flux UI theme
- **b2in.test**: two color worlds on one theme – Interior (default: Tanne `#202c2b`, Creme `#f7f4ef`, Sand `#d9cbbf`, Ocker `#d39e46`, Clay `#a38f82`, Anthrazit `#2d2926`; Inter/EB Garamond) and Immobilien (`body.world-immobilien`: Anthracite/Dynamic Blue, Inter/IBM Plex Sans). The world comes from `b2in_world()` (app/helpers.php), the logo per world from `ThemeHelper::getLogoPath()`
- **b2in.test**: Anthracite/Dynamic Blue color scheme, Inter/IBM Plex Sans fonts
- **b2a.test**: Azur Blue/Liberty Red color scheme, Inter/Merriweather fonts
- **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts
- **style2own.test**: Imperial Blue/Sand Gold color scheme, Inter/EB Garamond fonts
@ -142,11 +142,6 @@ For local development, add domains to your hosts file:
127.0.0.1 style2own.test
```
## Deployment & Monitoring
- Deploys run via Git tags only: `dev/betrieb/deploy.sh vX.Y.Z` on the server; remotes: `origin` (forge.adametz.media, deploy source), `NAS` (backup only). Runbook: `dev/betrieb/README.md`.
- `public/build` is versioned: build locally (`npm run build:web && npm run build:portal`) and commit before tagging.
- Errors go to GlitchTip (`errors.adametz.media`, org `adametz-media`, projects `server-b2in` = 8, `browser-b2in` = 7); read-only API token as `GLITCHTIP_API_TOKEN` in `.env`. `/health` (token) for Uptime Kuma, alerts via ntfy topic `b2in-ops`.
## Important Files
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide
@ -165,7 +160,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.26
- php - 8.4.18
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0

View file

@ -1,25 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Console\Command;
/**
* Minütlicher Herzschlag des Schedulers. Bleibt er aus,
* melden `/health` und der Systemstatus den Scheduler als ausgefallen —
* `onFailure` der einzelnen Tasks greift in diesem Fall nicht mehr.
*/
class MonitoringHeartbeat extends Command
{
protected $signature = 'monitoring:heartbeat';
protected $description = 'Schreibt den Herzschlag des Schedulers (Grundlage für /health und den Systemstatus).';
public function handle(HealthProbe $probe): int
{
$probe->recordHeartbeat();
return self::SUCCESS;
}
}

View file

@ -1,50 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Monitoring\QueueProbe;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
/**
* Prüft, ob ein Queue-Worker läuft: stellt einen Test-Job ein und wartet,
* bis er abgeholt ist (Aufruf am Ende von dev/betrieb/deploy.sh).
*
* Hintergrund: `queue:restart` beendet den Worker, neu starten muss ihn
* Supervisor. Läuft Supervisor nicht, bleibt die Queue still stehen.
*/
class MonitoringQueueProbe extends Command
{
protected $signature = 'monitoring:queue-probe
{--wait=60 : Sekunden, die auf den Worker gewartet wird}';
protected $description = 'Prüft mit einem Test-Job, ob ein Queue-Worker läuft.';
public function handle(): int
{
$token = Str::random(16);
$wait = max(0, (int) $this->option('wait'));
QueueProbe::dispatch($token);
for ($second = 0; $second <= $wait; $second++) {
if (Cache::has(QueueProbe::cacheKey($token))) {
Cache::forget(QueueProbe::cacheKey($token));
$this->info(sprintf('Queue-Worker läuft (Test-Job nach %d s abgeholt).', $second));
return self::SUCCESS;
}
if ($second < $wait) {
Sleep::for(1)->second();
}
}
$this->error(sprintf('Kein Queue-Worker hat den Test-Job innerhalb von %d s abgeholt.', $wait));
$this->line('Worker prüfen: läuft Supervisor (bzw. der Worker im Server-Panel)? Notfalls: nohup php artisan queue:work --tries=3 --max-time=3600 &');
return self::FAILURE;
}
}

View file

@ -5,20 +5,11 @@ namespace App\Helpers;
class ThemeHelper
{
/**
* Logo-Pfad für das aktuelle Theme.
*
* Für b2in hängt die Datei zusätzlich an der Farbwelt: die Interior-Welt trägt das Logo
* mit dem Zusatz „Interior“ und ockerfarbenem Bogen, die Immobilien-Welt das Logo ohne
* Zusatz mit blauem Bogen. Ohne Angabe wird die Welt aus der aktuellen Route gelesen.
* Get the logo path based on the current theme
*/
public static function getLogoPath(string $type = 'positive', ?string $world = null): string
public static function getLogoPath(string $type = 'positive'): string
{
$theme = config('app.theme', 'b2in');
if ($theme === 'b2in' && ($world ?? b2in_world()) === 'immobilien') {
return self::getLogoPathForBrand('b2in-immobilien', $type);
}
return self::getLogoPathForBrand($theme, $type);
}
@ -31,25 +22,21 @@ class ThemeHelper
$logoMap = [
'b2in' => [
'positive' => 'img/logos/b2in-interior-logo-positive.svg',
'negative' => 'img/logos/b2in-interior-logo-negative.svg',
],
'b2in-immobilien' => [
'positive' => 'img/logos/b2in-logo-positive.svg',
'negative' => 'img/logos/b2in-logo-negative.svg',
'negative' => 'img/logos/b2in-logo-negative.svg'
],
'b2a' => [
'positive' => 'img/logos/b2a-logo-positiv.svg',
'negative' => 'img/logos/b2a-logo-negativ.svg',
'negative' => 'img/logos/b2a-logo-negativ.svg'
],
'stileigentum' => [
'positive' => 'img/logos/stileigentum-logo-positiv.svg',
'negative' => 'img/logos/stileigentum-logo-negativ.svg',
'negative' => 'img/logos/stileigentum-logo-negativ.svg'
],
'style2own' => [
'positive' => 'img/logos/style2own-logo-positiv.svg',
'negative' => 'img/logos/style2own-logo-negativ.svg',
],
'negative' => 'img/logos/style2own-logo-negativ.svg'
]
];
return $logoMap[$brand][$type] ?? $logoMap['b2in'][$type];
@ -61,7 +48,6 @@ class ThemeHelper
public static function getFaviconPath(): string
{
$theme = config('app.theme', 'b2in');
return "img/favicons/{$theme}-favicon.ico";
}
@ -71,7 +57,6 @@ class ThemeHelper
public static function getThemeCssPath(): string
{
$theme = config('app.theme', 'b2in');
return "resources/css/web/theme-{$theme}.css";
}
@ -81,7 +66,6 @@ class ThemeHelper
public static function getDomainConfig(): array
{
$theme = config('app.theme', 'b2in');
return config("domains.domains.{$theme}", []);
}
@ -91,7 +75,6 @@ class ThemeHelper
public static function getPrimaryColor(): string
{
$config = self::getDomainConfig();
return $config['color_scheme']['primary'] ?? '#2b3f51';
}
@ -101,7 +84,6 @@ class ThemeHelper
public static function getSecondaryColor(): string
{
$config = self::getDomainConfig();
return $config['color_scheme']['secondary'] ?? '#20a0da';
}
@ -111,7 +93,6 @@ class ThemeHelper
public static function getPrimaryFont(): string
{
$config = self::getDomainConfig();
return $config['font_family']['primary'] ?? 'Inter';
}
@ -121,7 +102,6 @@ class ThemeHelper
public static function getSecondaryFont(): string
{
$config = self::getDomainConfig();
return $config['font_family']['secondary'] ?? 'IBM Plex Sans';
}
@ -131,14 +111,12 @@ 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');
}
@ -180,7 +158,6 @@ class ThemeHelper
public static function getBrandConfig(?string $brand = null): array
{
$brand = $brand ?? config('app.theme', 'b2in');
return config("domains.domains.{$brand}", []);
}
}

View file

@ -1,34 +0,0 @@
<?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

@ -1,39 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Tiefer Health-Check für den externen Monitor.
*
* `/up` zeigt nur, dass die App bootet. `/health` prüft zusätzlich DB,
* Cache, Queue-Rückstau, Scheduler-Herzschlag und Plattenplatz: 200 solange
* nichts kritisch ist, sonst 503. Zugang nur mit MONITORING_HEALTH_TOKEN
* (Bearer oder `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht.
*/
class HealthController extends Controller
{
public function __invoke(Request $request, HealthProbe $probe): JsonResponse
{
$token = (string) config('monitoring.health.token');
$given = (string) ($request->bearerToken() ?? $request->query('token', ''));
abort_if($token === '' || ! hash_equals($token, $given), 404);
$checks = $probe->checks();
$status = HealthProbe::worstStatus($checks);
return response()->json([
'status' => $status,
'version' => config('app.version'),
'checked_at' => now()->toIso8601String(),
'checks' => collect($checks)->mapWithKeys(fn (array $check): array => [
$check['key'] => ['status' => $check['status'], 'detail' => $check['detail']],
]),
], $status === HealthProbe::StatusCritical ? 503 : 200)
->header('Cache-Control', 'no-store');
}
}

View file

@ -32,12 +32,6 @@ class BasicAuthMiddleware
return $next($request);
}
// Skip Basic Auth für den Health-Check: Uptime Kuma schickt seinen eigenen
// Authorization-Header (Bearer-Token), /health prüft ihn selbst.
if ($path === 'health' || $path === 'up') {
return $next($request);
}
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
if (str_starts_with($path, 'flux/')) {
return $next($request);

View file

@ -1,96 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\ErrorSignal;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use function Illuminate\Support\defer;
/**
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
* obwohl keine Exception gemeldet wird:
*
* - 404 mit Referrer der eigenen Domain — irgendwo bei uns steht ein toter Link
* - 403/429 für angemeldete Nutzer — die Oberfläche führt sie dorthin
* - 419 — Formular oder Livewire-Komponente abgelaufen, Eingaben sind verloren
*
* Gesendet wird nach der Antwort (`defer()`), der Nutzer wartet nicht auf GlitchTip.
*/
class ReportErrorSignals
{
public function __construct(private readonly ErrorSignal $errorSignal) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$signal = $this->signalFor($request, $response->getStatusCode());
if ($signal !== null) {
$location = $this->locationOf($request);
$context = [
'status' => $response->getStatusCode(),
'method' => $request->method(),
'path' => '/'.ltrim($request->path(), '/'),
'referer' => $this->refererPath($request),
'livewire' => $request->hasHeader('X-Livewire'),
];
defer(fn () => $this->errorSignal->report($signal, $location, $context));
}
return $response;
}
private function signalFor(Request $request, int $status): ?string
{
return match (true) {
$status === 404 && $this->hasInternalReferer($request) => ErrorSignal::DeadInternalLink,
$status === 403 && $request->user() !== null => ErrorSignal::AccessDenied,
$status === 419 => ErrorSignal::PageExpired,
$status === 429 && $request->user() !== null => ErrorSignal::Throttled,
default => null,
};
}
/**
* Livewire-Requests laufen alle über denselben Update-Endpunkt — der Ort
* ist dort die Seite, auf der der Nutzer stand.
*/
private function locationOf(Request $request): string
{
if ($request->hasHeader('X-Livewire')) {
return 'livewire '.($this->refererPath($request) ?? '/');
}
$routeName = $request->route()?->getName();
return $routeName !== null
? $routeName
: '/'.ltrim($request->path(), '/');
}
private function hasInternalReferer(Request $request): bool
{
$referer = $request->headers->get('referer');
return is_string($referer) && parse_url($referer, PHP_URL_HOST) === $request->getHost();
}
private function refererPath(Request $request): ?string
{
$referer = $request->headers->get('referer');
if (! is_string($referer) || $referer === '') {
return null;
}
$path = parse_url($referer, PHP_URL_PATH);
return is_string($path) ? ErrorReportScrubber::scrubPath($path) : null;
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Jobs\Monitoring;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
/**
* Test-Job für `monitoring:queue-probe`: tut nichts außer zu quittieren,
* dass ein Worker ihn abgeholt hat. Läuft auf der Standard-Queue —
* der Worker muss sie bedienen.
*/
class QueueProbe implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public string $token) {}
public static function cacheKey(string $token): string
{
return 'monitoring:queue-probe:'.$token;
}
public function handle(): void
{
Cache::put(self::cacheKey($this->token), now()->toIso8601String(), now()->addMinutes(10));
}
}

View file

@ -98,35 +98,13 @@ class CabinetInfoTablet extends Component
}
/**
* 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
* @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
*/
public function save(array $hours = []): void
{
foreach ($this->hourProperties() as $prop) {
if (array_key_exists($prop, $hours)) {
$this->{$prop} = is_string($hours[$prop]) ? $hours[$prop] : '';
foreach ($hours as $prop => $value) {
if (property_exists($this, $prop)) {
$this->{$prop} = $value ?? '';
}
}

View file

@ -53,8 +53,7 @@ class DisplayVersionEditor extends Component
public string $mediaSubline = '';
/** Anzeigedauer eines Bildes in Sekunden als Eingabetext, Komma oder Punkt erlaubt („1,5“). */
public string $mediaDuration = '10';
public int $mediaDuration = 10;
public bool $mediaIsActive = true;
@ -248,12 +247,6 @@ class DisplayVersionEditor extends Component
public function saveItem(): void
{
if ($this->itemType === 'media' && $this->mediaType === 'image' && self::parseDuration($this->mediaDuration) === null) {
$this->addError('mediaDuration', 'Bitte eine Dauer zwischen 0,5 und 120 Sekunden angeben, z. B. 1,5.');
return;
}
$content = $this->buildItemContent();
$isActive = $this->getActiveFlag();
@ -286,41 +279,6 @@ class DisplayVersionEditor extends Component
$this->refreshModulePreview();
}
/**
* Sekunden aus der Eingabe lesen: „1,5“, „1.5“ und „10“ sind gültig, gerundet auf Zehntel.
* Erlaubt sind 0,5 bis 120 Sekunden; alles andere ergibt null.
*/
public static function parseDuration(string|int|float $input): ?float
{
$normalized = str_replace(',', '.', trim((string) $input));
if ($normalized === '' || ! is_numeric($normalized)) {
return null;
}
$seconds = round((float) $normalized, 1);
if ($seconds < 0.5 || $seconds > 120) {
return null;
}
return $seconds;
}
/**
* Sekunden für die Anzeige: ganze Zahlen ohne Nachkommastelle, sonst mit Komma („1,5“).
*/
public static function formatDuration(int|float|string|null $seconds): string
{
$seconds = (float) str_replace(',', '.', (string) ($seconds ?? 10));
if (floor($seconds) === $seconds) {
return (string) (int) $seconds;
}
return str_replace('.', ',', (string) round($seconds, 1));
}
public function deleteItem(int $id): void
{
DisplayVersionItem::findOrFail($id)->delete();
@ -479,7 +437,7 @@ class DisplayVersionEditor extends Component
$this->mediaUrl = $content['media_url'] ?? '';
$this->mediaHeadline = $content['headline'] ?? '';
$this->mediaSubline = $content['subline'] ?? '';
$this->mediaDuration = self::formatDuration($content['duration_seconds'] ?? 10);
$this->mediaDuration = $content['duration_seconds'] ?? 10;
$this->mediaIsActive = $isActive;
}
@ -543,7 +501,7 @@ class DisplayVersionEditor extends Component
'media_url' => $this->mediaUrl,
'headline' => $this->mediaHeadline,
'subline' => $this->mediaSubline,
'duration_seconds' => self::parseDuration($this->mediaDuration) ?? 10,
'duration_seconds' => $this->mediaDuration,
],
'slide' => [
'type' => 'detail',
@ -705,7 +663,7 @@ class DisplayVersionEditor extends Component
$this->mediaUrl = '';
$this->mediaHeadline = '';
$this->mediaSubline = '';
$this->mediaDuration = '10';
$this->mediaDuration = 10;
$this->mediaIsActive = true;
$this->slideShowLogo = true;
$this->slideLogoUrl = '';

View file

@ -8,13 +8,9 @@ class AboutHero extends Component
{
public $content = [];
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_about_hero). */
public string $section = 'about_hero';
public function mount(string $section = 'about_hero')
public function mount()
{
$this->section = $section;
$this->content = cms_theme_section($this->section);
$this->content = cms_theme_section('about_hero');
}
public function render()

View file

@ -1,25 +0,0 @@
<?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

@ -1,89 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use Livewire\Component;
class BrandDetail extends Component
{
public string $slug = '';
/** @var array<string, mixed> */
public array $brand = [];
/** @var array<int, array<string, mixed>> */
public array $otherBrands = [];
/**
* Galerie getrennt nach realisierten Projekten und Inspirationen.
*
* @var array{projects: array<int, array<string, mixed>>, inspirations: array<int, array<string, mixed>>}
*/
public array $gallery = ['projects' => [], 'inspirations' => []];
public function mount(string $slug): void
{
$brands = CmsBrand::forFrontend();
abort_unless(isset($brands[$slug]), 404);
$this->slug = $slug;
$this->brand = $brands[$slug];
$this->gallery = CmsBrand::groupGallery($this->brand['gallery'] ?? []);
$this->otherBrands = collect($brands)
->reject(fn (array $brand, string $key) => $key === $slug)
->values()
->all();
$craft = $this->craftSection($slug, $brands);
if ($craft !== null) {
$this->brand['sections'][] = $craft;
}
}
/**
* Gemeinsamer Handwerk-Abschluss (Lang-Sektion `brand_craft`, Nachtrag 15): steht als letzter
* Abschnitt auf jeder Markenseite – außer auf der Seite der Handwerksmarke selbst. Der Link
* dorthin erscheint nur, wenn diese Marke veröffentlicht ist und eine Detailseite hat.
*
* @param array<string, array<string, mixed>> $brands
* @return array<string, mixed>|null
*/
private function craftSection(string $slug, array $brands): ?array
{
$content = cms_theme_section('brand_craft');
if (! is_array($content)) {
return null;
}
$craftSlug = (string) ($content['brand_slug'] ?? '');
if ($craftSlug !== '' && $craftSlug === $slug) {
return null;
}
$sections = CmsBrand::normalizeSections([$content]);
if ($sections === []) {
return null;
}
$section = $sections[0];
$section['anchor'] = 'handwerk';
if ($craftSlug !== '' && ! empty($brands[$craftSlug]['has_detail_page'])) {
$section['link'] = route('marken.show', ['slug' => $craftSlug]);
$section['link_text'] = (string) ($content['link_text'] ?? $brands[$craftSlug]['name'] ?? '');
}
return $section;
}
public function render()
{
return view('livewire.web.components.sections.brand-detail');
}
}

View file

@ -1,25 +0,0 @@
<?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

@ -12,13 +12,8 @@ class CTASection extends Component
public $section = '';
public function mount($bg = 'cta-band', $section = 'cta_section')
public function mount($bg = 'bg-secondary', $section = 'cta_section')
{
// Der Standard-Abschluss hat je Welt eigene Texte und fuehrt zur Kontaktseite der Welt.
if ($section === 'cta_section' && b2in_world() === 'immobilien') {
$section = 'cta_section_immobilien';
}
$this->section = $section;
$this->content = cms_theme_section($this->section);
$this->bg = $bg;

View file

@ -1,30 +0,0 @@
<?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

@ -1,84 +0,0 @@
<?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->linkTargetVisible($content);
$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');
}
/**
* Der Button führt standardmäßig auf /referenzen – ist die Seite im CMS ausgeblendet, entfällt er.
*
* @param array<string, mixed> $content
*/
private function linkTargetVisible(array $content): bool
{
$path = rtrim(parse_url((string) ($content['link_url'] ?? '/referenzen'), PHP_URL_PATH) ?? '', '/');
return $path !== '/referenzen' || references_page_enabled();
}
}

View file

@ -8,12 +8,8 @@ class FounderBar extends Component
{
public array $content = [];
/** Hintergrundklasse der Sektion; unter den Interior-Heroes (Startseite, Marken, Über uns) `bg-accent`. */
public string $bg = 'bg-background';
public function mount(string $bg = 'bg-background'): void
public function mount(): void
{
$this->bg = $bg;
$this->content = cms_theme_section('founder_bar');
}

View file

@ -4,86 +4,16 @@ namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
/**
* Hero der Startseite: Text links, rechts eine ruhige Slideshow als Hauptelement des Rahmens
* (Abstimmung 07.10.2026, Vorbild dev/2026-08-28/Farbwelt und Stil.png).
*
* Die Bilder kommen aus der Liste `slides` der Sektion `home/hero` (je `image`, `label`, `caption`,
* `alt`, `active`) und laufen in dieser Reihenfolge. Die Infokarte über dem Bild zeigt je Slide
* Marke (`label`) und Motiv (`caption`), ohne Angaben die festen Texte `card_title`/`card_text`.
* Ist die Liste leer oder alles deaktiviert, steht das einzelne Bild `image` da wie früher.
*/
class Hero extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public $content = [];
/** @var array<int, array{image: string, alt: string, label: string, caption: string}> */
public array $slides = [];
public function mount(): void
public function mount()
{
$section = cms_theme_section('hero');
$this->content = is_array($section) ? $section : [];
$this->slides = self::activeSlides($this->content['slides'] ?? []) ?: $this->singleImage();
$this->content = cms_theme_section('hero');
}
/**
* Gepflegte Slides ohne Bild oder mit `active` = aus fallen weg.
*
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
*/
public static function activeSlides(mixed $list): array
{
$slides = [];
foreach ((array) $list as $slide) {
if (! is_array($slide) || trim((string) ($slide['image'] ?? '')) === '') {
continue;
}
if (array_key_exists('active', $slide) && ! filter_var($slide['active'], FILTER_VALIDATE_BOOLEAN)) {
continue;
}
$label = trim((string) ($slide['label'] ?? $slide['title'] ?? ''));
$caption = trim((string) ($slide['caption'] ?? ''));
$slides[] = [
'image' => (string) $slide['image'],
'alt' => trim((string) ($slide['alt'] ?? '')) ?: implode(' – ', array_filter([$label, $caption])),
'label' => $label,
'caption' => $caption,
];
}
return $slides;
}
/**
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
*/
private function singleImage(): array
{
$image = trim((string) ($this->content['image'] ?? ''));
return $image === '' ? [] : [[
'image' => $image,
'alt' => (string) ($this->content['image_alt'] ?? ''),
'label' => '',
'caption' => '',
]];
}
/** Wechsel alle `interval` Sekunden, begrenzt auf 3–30 s (Standard 7 s). */
public function getIntervalMsProperty(): int
{
$seconds = (float) ($this->content['interval'] ?? 7);
return (int) round(max(3, min(30, $seconds)) * 1000);
}
public function render(): \Illuminate\View\View
public function render()
{
return view('livewire.web.components.sections.hero');
}

View file

@ -4,10 +4,6 @@ namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
/**
* Hero der Themes b2a, Stileigentum und Style2own (Stand vor dem B2in-Entwurf vom 24.09.2026).
* Ohne gepflegte Sektion `hero_slider` bleibt der Bereich leer.
*/
class HeroSlider extends Component
{
public $content = [];
@ -16,8 +12,7 @@ class HeroSlider extends Component
public function mount()
{
$section = cms_theme_section('hero_slider');
$this->content = is_array($section) ? $section : [];
$this->content = cms_theme_section('hero_slider');
}
public function nextSlide()

View file

@ -1,25 +0,0 @@
<?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,12 +2,11 @@
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsArticle;
use Livewire\Component;
class MagazinDetail extends Component
{
public $slug;
public $articleId;
public $article;
@ -15,41 +14,25 @@ class MagazinDetail extends Component
public $content;
/** Rueckweg zur Magazinliste der Welt, zu der der Beitrag gehoert. */
public string $listUrl = '';
public function mount($slug)
public function mount($id = 1)
{
$this->slug = (string) $slug;
$this->articleId = (int) $id;
$this->loadArticle();
$this->listUrl = $this->articleWorld() === CmsArticle::WORLD_IMMOBILIEN
? route('immobilien.magazin')
: route('magazin');
$this->loadRelatedArticles();
$this->loadThemeContent();
}
private function articleWorld(): string
{
return (string) ($this->article['world'] ?? CmsArticle::WORLD_INTERIOR);
}
private function loadArticle()
{
$articles = $this->getArticlesData();
// 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];
$this->article = $articles[$this->articleId] ?? $articles[1];
}
private function loadRelatedArticles()
{
$this->relatedArticles = collect($this->getArticlesData())
->filter(fn (array $article) => ($article['world'] ?? CmsArticle::WORLD_INTERIOR) === $this->articleWorld())
->reject(fn (array $article, string $slug) => $slug === $this->slug)
$articles = $this->getArticlesData();
$this->relatedArticles = collect($articles)
->filter(fn ($article, $key) => $key != $this->articleId)
->take(2)
->values()
->toArray();
@ -60,18 +43,9 @@ class MagazinDetail extends Component
$this->content = cms_theme_section('magazin_detail');
}
/** @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
private function getArticlesData()
{
return $this->articlesCache ??= CmsArticle::forFrontend();
return trans('b2in.articles');
}
public function render()

View file

@ -2,7 +2,6 @@
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsArticle;
use Livewire\Component;
class MagazinList extends Component
@ -11,41 +10,34 @@ class MagazinList extends Component
public $content = [];
/** Farbwelt der Liste: `interior` (/magazin) oder `immobilien` (/immobilien/magazin). */
public string $world = CmsArticle::WORLD_INTERIOR;
public function mount(string $world = CmsArticle::WORLD_INTERIOR)
public function mount()
{
$this->world = in_array($world, CmsArticle::WORLDS, true) ? $world : CmsArticle::WORLD_INTERIOR;
$this->loadPosts();
$this->loadThemeContent();
}
private function loadPosts()
{
$this->posts = collect(CmsArticle::forFrontend($this->world))->map(function (array $article) {
$articles = trans('b2in.articles');
$this->posts = collect($articles)->map(function ($article) {
return [
'slug' => $article['slug'],
'id' => $article['id'],
'title' => $article['title'],
'excerpt' => $article['content']['intro'] ?? '',
'excerpt' => $article['content']['intro'], // Using intro as excerpt
'image' => $article['image'],
'date' => $article['date'],
'readTime' => $article['readTime'],
];
})->values()->all();
})->all();
}
private function loadThemeContent()
{
$this->content = cms_theme_section(
$this->world === CmsArticle::WORLD_IMMOBILIEN ? 'magazin_list_immobilien' : 'magazin_list'
);
$this->content = cms_theme_section('magazin_list');
}
public function render()
{
return view('livewire.web.components.sections.magazin-list', [
'posts' => $this->posts,
]);
return view('livewire.web.components.sections.magazin-list');
}
}

View file

@ -1,25 +0,0 @@
<?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

@ -1,25 +0,0 @@
<?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

@ -1,25 +0,0 @@
<?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

@ -1,83 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use Livewire\Component;
/**
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
* (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
*
* Überschrift, Karte, Drehscheibe und Ablauf-Block kommen aus dem CMS (Fallback Lang-Datei).
* Die Standorte kommen aus den veröffentlichten Marken: Ort, Entfernung und Rolle pflegt
* Marcel in der Markenverwaltung. Die Positionen in der schematischen Karte gehören zur
* Zeichnung und bleiben hier im Code.
*/
class OriginSection extends Component
{
/**
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
*
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
*
* @var array<string, array{x: int, y: int, label: string}>
*/
public const MAP_POSITIONS = [
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
];
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('origin_section');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
/**
* Veröffentlichte Marken mit gepflegtem Fertigungsort, in der Reihenfolge der Marken.
*
* Eine unveröffentlichte Marke fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
*
* @return list<array{slug: string, city: string, brand: string, role: string, distance: string, link: ?string, map: ?array{x: int, y: int, label: string}}>
*/
public function getLocationsProperty(): array
{
$out = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
$city = trim((string) ($brand['origin_city'] ?? ''));
$name = trim((string) ($brand['name'] ?? ''));
if ($city === '' || $name === '') {
continue;
}
$out[] = [
'slug' => (string) $slug,
'city' => $city,
'brand' => $name,
'role' => trim((string) ($brand['origin_role'] ?? '')),
'distance' => trim((string) ($brand['origin_distance'] ?? '')),
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
'map' => self::MAP_POSITIONS[$slug] ?? null,
];
}
return $out;
}
public function render()
{
return view('livewire.web.components.sections.origin-section');
}
}

View file

@ -8,13 +8,9 @@ class OurStory extends Component
{
public $content = [];
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_story). */
public string $section = 'our_story';
public function mount(string $section = 'our_story')
public function mount()
{
$this->section = $section;
$this->content = cms_theme_section($this->section);
$this->content = cms_theme_section('our_story');
}
public function render()

View file

@ -8,13 +8,9 @@ class OurValues extends Component
{
public $content = [];
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_values). */
public string $section = 'our_values';
public function mount(string $section = 'our_values')
public function mount()
{
$this->section = $section;
$this->content = cms_theme_section($this->section);
$this->content = cms_theme_section('our_values');
}
public function render()

View file

@ -1,32 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
/**
* Seitenkopf für Unterseiten: Zeile über der Überschrift, Überschrift, Text und optional ein Bild
* daneben (Breadcrumb seit 07.10.2026 entfernt).
*
* 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 function mount(string $section = 'marken_hero', string $bg = ''): void
{
$content = cms_theme_section($section);
$this->content = is_array($content) ? $content : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.page-hero');
}
}

View file

@ -1,113 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use App\Models\CmsReference;
use Livewire\Component;
/**
* Realisierte Projekte auf /referenzen.
*
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
* die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien, über alle Marken
* hinweg gesammelt. Visualisierungen und Entwürfe bleiben in beiden Fällen draußen.
*/
class ReferenceProjects extends Component
{
/** @var array<string, mixed> */
public array $content = [];
/** @var array<int, array<string, mixed>> */
public array $projects = [];
public string $bg = '';
public int $limit = 0;
public function mount(string $bg = '', int $limit = 0): void
{
$section = cms_theme_section('reference_projects');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
$this->limit = $limit;
$projects = $this->fromReferences();
if ($projects === []) {
$projects = $this->fromBrandGalleries();
}
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
}
/**
* @return array<int, array<string, mixed>>
*/
private function fromReferences(): array
{
$references = CmsReference::query()->published()->ordered()->get();
if ($references->isEmpty()) {
return [];
}
$brands = CmsBrand::forFrontend();
$projects = [];
foreach ($references as $reference) {
$gallery = CmsReference::normalizeGallery($reference->gallery);
$image = (string) ($reference->image ?: ($gallery[0]['image'] ?? ''));
if ($image === '') {
continue;
}
$projects[] = [
'key' => 'ref-'.$reference->id,
'image' => $image,
'caption' => (string) $reference->title,
'meta' => implode(' · ', array_filter([$reference->location, $reference->year])),
'description' => (string) $reference->description,
'gallery' => array_values(array_filter($gallery, fn (array $item) => $item['image'] !== $image)),
'brands' => $reference->resolveBrands($brands),
];
}
return $projects;
}
/**
* @return array<int, array<string, mixed>>
*/
private function fromBrandGalleries(): array
{
$projects = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
foreach (CmsBrand::groupGallery($brand['gallery'] ?? [])['projects'] as $index => $item) {
$projects[] = [
'key' => $slug.'-'.$index,
'image' => $item['image'],
'caption' => $item['caption'],
'meta' => '',
'description' => '',
'gallery' => [],
'brands' => [[
'slug' => $slug,
'name' => (string) ($brand['name'] ?? ''),
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
]],
];
}
}
return $projects;
}
public function render()
{
return view('livewire.web.components.sections.reference-projects');
}
}

View file

@ -1,25 +0,0 @@
<?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

@ -10,13 +10,9 @@ class VisionSection extends Component
public $bg = 'bg-background';
/** Inhalt in weißer Box mit kleinerem Bild (Startseite, 07.10.2026); die anderen Themes bleiben offen. */
public bool $boxed = false;
public function mount($bg = 'bg-background', bool $boxed = false)
public function mount($bg = 'bg-background')
{
$this->bg = $bg;
$this->boxed = $boxed;
$this->content = cms_theme_section('vision_section');
}

View file

@ -8,35 +8,9 @@ class AnnouncementBar extends Component
{
public array $content = [];
/** Farbwelt der Seite – jede Welt hat ihre eigene Meldung. */
public string $world = 'interior';
public function mount(): void
{
$this->world = b2in_world();
// Interior: global.announcement_bar, Immobilien: global.announcement_bar_immobilien.
$section = cms_theme_section($this->world === 'immobilien' ? 'announcement_bar_immobilien' : 'announcement_bar');
$this->content = is_array($section) ? $section : [];
}
/**
* Schluessel, unter dem sich der Browser das Wegklicken merkt.
*
* Leitet sich aus dem Inhalt ab, nicht nur aus der gepflegten `id`: Wird die Leiste im CMS
* neu belegt, ohne die `id` zu aendern, bekaemen Wiederkehrer die neue Meldung sonst nie
* zu sehen, weil ihr alter Dismiss-Eintrag im localStorage weitergilt.
*/
public function getDismissKeyProperty(): string
{
$fingerprint = implode('|', [
$this->content['id'] ?? 'default',
$this->content['badge'] ?? '',
$this->content['text'] ?? '',
$this->content['link_url'] ?? '',
]);
return 'announcement_dismissed_'.substr(md5($fingerprint), 0, 12);
$this->content = cms_theme_section('announcement_bar');
}
public function render()

View file

@ -1,166 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use App\Models\CmsBrand;
use Livewire\Component;
/**
* Schwebende Terminanfrage – auf allen Seiten unten rechts.
*
* Setzt wie die übrigen Formulare der Seite auf {@see ContactFormService} und {@see SpamDetector}
* auf, statt auf Acme\ContactForm\Livewire\ContactForm: dessen mitgelieferte View bringt ein
* eigenes (blaues) Layout mit, das nicht zum Auftritt passt. Honeypot, Rate-Limit und
* Spam-Erkennung kommen so trotzdem aus dem Paket.
*/
class AppointmentWidget extends Component
{
/** Feste Anliegen neben den gepflegten Marken. */
public const EXTRA_TOPICS = [
'projektausstattung' => 'Projektausstattung',
'immobilien' => 'Immobilien',
'sonstiges' => 'Sonstiges',
];
/** Farbwelt der Seite – in der Immobilien-Welt ist das Anliegen vorbelegt. */
public string $world = 'interior';
public string $name = '';
public string $email = '';
public string $phone = '';
public string $topic = '';
public string $preferredDate = '';
public string $message = '';
public bool $privacy = false;
public bool $success = false;
/** @var string Honeypot – muss leer bleiben. */
public string $website = '';
public ?int $formLoadedAt = null;
/** @var array<string, mixed> */
public array $content = [];
public function mount(): void
{
$section = cms_theme_section('appointment_widget');
$this->content = is_array($section) ? $section : [];
$this->formLoadedAt = time();
$this->world = b2in_world();
if ($this->world === 'immobilien') {
$this->topic = 'immobilien';
}
$this->applyBrandContext();
}
/**
* Auf einer Markenseite trägt der Button die Beschriftung dieser Marke.
*
* Die Zulieferung sieht ausdrücklich keinen zweiten CTA-Button vor, sondern eine
* kontextbezogene Beschriftung des vorhandenen Widgets. Da das Widget im Layout steckt und
* nicht je Seite eingebunden wird, liest es den Kontext aus der Route.
*/
private function applyBrandContext(): void
{
$route = request()->route();
if ($route?->getName() !== 'marken.show') {
return;
}
$slug = (string) $route->parameter('slug');
$brand = CmsBrand::forFrontend()[$slug] ?? null;
$label = is_array($brand) ? trim((string) ($brand['widget_label'] ?? '')) : '';
if ($label !== '') {
$this->content['button_label'] = $label;
}
}
/**
* Auswahl „Anliegen": gepflegte Kernmarken zuerst, danach die festen Einträge.
*
* @return array<string, string>
*/
public function getTopicsProperty(): array
{
$brands = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
$brands[$slug] = (string) ($brand['name'] ?? $slug);
}
return $brands + self::EXTRA_TOPICS;
}
public function getWhatsappUrlProperty(): string
{
return whatsapp_link();
}
public function submit(ContactFormService $service): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email:rfc', 'max:255'],
'phone' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:255'],
'preferredDate' => ['nullable', 'date', 'after_or_equal:today'],
'message' => ['nullable', 'string', 'max:2000'],
'privacy' => ['accepted'],
'website' => ['nullable', 'string', 'max:0'],
], [
'preferredDate.after_or_equal' => __('ui.appointment.date_in_past'),
]);
$topicLabel = $this->topics[$this->topic] ?? '';
$payload = [
'name' => $this->name,
'email' => $this->email,
'phone' => $this->phone,
'topic' => $topicLabel,
'preferred_date' => $this->preferredDate,
'message' => $this->message,
'website' => $this->website,
'source' => request()->path(),
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => false,
];
$payload['is_spam'] = SpamDetector::fromConfig()->detect($payload, $this->formLoadedAt);
$subject = ($this->world === 'immobilien' ? 'Terminanfrage B2in Immobilien' : 'Terminanfrage B2in Interior')
.($topicLabel !== '' ? ': '.$topicLabel : '');
$service->handle($payload, $subject, 'appointment-widget');
$this->success = true;
$this->reset(['name', 'email', 'phone', 'topic', 'preferredDate', 'message', 'privacy', 'website']);
$this->topic = $this->world === 'immobilien' ? 'immobilien' : '';
$this->formLoadedAt = time();
}
public function startOver(): void
{
$this->success = false;
$this->formLoadedAt = time();
}
public function render(): \Illuminate\View\View
{
return view('livewire.web.components.ui.appointment-widget');
}
}

View file

@ -4,8 +4,6 @@ namespace App\Livewire\Web\Components\Ui;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use App\Models\CmsBrand;
use Illuminate\Validation\Rule;
use Livewire\Component;
class ContactForm extends Component
@ -36,109 +34,16 @@ class ContactForm extends Component
/** @var array<string, mixed> */
public array $content = [];
/** Farbwelt der Seite (`interior` | `immobilien`). */
public string $world = 'interior';
public function mount(string $world = 'interior'): void
public function mount(): void
{
$this->world = $world === 'immobilien' ? 'immobilien' : 'interior';
$this->content = $this->loadContent();
$this->content = cms_theme_section('contact_form');
$this->formLoadedAt = time();
}
/**
* Inhalte der Welt. Die Immobilien-Welt liegt als Overlay über der Interior-Sektion:
* `contact_form_immobilien` trägt nur, was abweicht (Einstieg, Betreffe) – Adresse,
* Beschriftungen und Social Media erbt sie von `contact_form`.
*
* @return array<string, mixed>
*/
private function loadContent(): array
{
$base = cms_theme_section('contact_form');
$base = is_array($base) ? $base : [];
if ($this->world !== 'immobilien') {
return $base;
}
$overlay = cms_theme_section('contact_form_immobilien');
if (! is_array($overlay)) {
return $base;
}
return array_replace_recursive($base, $overlay);
}
/**
* Betreffe in Gruppen: Interior (mit den gepflegten Kernmarken vorneweg), Immobilien, B2in.
* Die Welt der Seite bestimmt die Reihenfolge – ihre Gruppe steht oben.
*
* Ohne `subject_groups` (aeltere CMS-Staende) faellt das Formular auf die flache Liste zurueck.
*
* @return array<string, array{label: string, subjects: array<string, string>}>
*/
public function getSubjectGroupsProperty(): array
{
$groups = $this->content['form']['subject_groups'] ?? null;
if (! is_array($groups) || $groups === []) {
$flat = $this->content['form']['subjects'] ?? [];
unset($flat['']);
return $flat === [] ? [] : ['all' => ['label' => '', 'subjects' => $flat]];
}
$brands = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
$brands[$slug] = (string) ($brand['name'] ?? $slug);
}
$out = [];
foreach ($groups as $key => $group) {
$subjects = is_array($group['subjects'] ?? null) ? $group['subjects'] : [];
if ($key === 'interior') {
$subjects = $brands + $subjects;
}
$out[$key] = [
'label' => (string) ($group['label'] ?? ''),
'subjects' => $subjects,
];
}
$first = $this->world === 'immobilien' ? 'immobilien' : 'interior';
if (isset($out[$first])) {
$out = [$first => $out[$first]] + $out;
}
return $out;
}
/**
* Alle waehlbaren Betreffe als flache Liste (Schluessel → Beschriftung).
*
* @return array<string, string>
*/
/** @return array<string, string> */
public function getSubjectsProperty(): array
{
$flat = [];
foreach ($this->getSubjectGroupsProperty() as $group) {
$flat += $group['subjects'];
}
return $flat;
}
public function getSubjectPlaceholderProperty(): string
{
return (string) ($this->content['form']['subjects'][''] ?? $this->content['form']['subject_placeholder'] ?? __('ui.contact_form.subject_placeholder'));
return $this->content['form']['subjects'] ?? [];
}
/** @return array<int, array<string, mixed>> */
@ -147,13 +52,19 @@ class ContactForm extends Component
return $this->content['contact_info'] ?? [];
}
/** @return array<int, array<string, mixed>> */
public function getSocialMediaProperty(): array
{
return $this->content['social_media']['platforms'] ?? [];
}
public function submit(ContactFormService $service): void
{
$this->validate([
'firstName' => ['required', 'string', 'max:255'],
'lastName' => ['required', 'string', 'max:255'],
'email' => ['required', 'email:rfc', 'max:255'],
'subject' => ['required', 'string', Rule::in(array_keys($this->subjects))],
'subject' => ['required', 'string'],
'message' => ['required', 'string', 'max:2000'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:255'],
@ -179,9 +90,8 @@ class ContactForm extends Component
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
$prefix = $this->world === 'immobilien' ? 'Kontaktanfrage B2in Immobilien' : 'Kontaktanfrage B2in';
$subjectLine = $subjectLabel
? $prefix.': '.$subjectLabel
? 'Kontaktanfrage B2in: ' . $subjectLabel
: __('contact-form::contact-form.default_subject');
$service->handle($payload, $subjectLine, 'contact-form');

View file

@ -6,62 +6,6 @@ use Livewire\Component;
class Footer extends Component
{
/** @var array<string, mixed> */
public array $content = [];
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
public string $world = 'interior';
/** @var array<string, array{label: string, url: string, active: bool}> */
public array $worlds = [];
public string $logoPath = '';
/**
* Negatives Logo für die Farbvariante „Modern“ (Nachtrag 18): dort liegt der Interior-Footer
* dunkel in Tanne. Die Palette schaltet im Browser, deshalb liegen beide Logos im Markup und
* das Stylesheet blendet das passende ein. Leer in der Immobilien-Welt, die ohnehin das
* negative Logo trägt.
*/
public string $logoPathDark = '';
/**
* Fuer die Immobilien-Welt zeigen die gemeinsamen Footer-Links auf die Seiten dieser Welt.
*
* @var array<string, string>
*/
private const IMMOBILIEN_LINKS = [
'/faq' => '/immobilien/faq',
'/about' => '/immobilien/ueber-b2in',
'/magazin' => '/immobilien/magazin',
];
public function mount(): void
{
$section = cms_theme_section('footer');
$this->content = is_array($section) ? $section : [];
$this->world = b2in_world();
if ($this->world === 'immobilien' && is_array($this->content['links'] ?? null)) {
$this->content['links'] = array_map(function ($link) {
if (is_array($link) && isset(self::IMMOBILIEN_LINKS[$link['url'] ?? ''])) {
$link['url'] = self::IMMOBILIEN_LINKS[$link['url']];
}
return $link;
}, $this->content['links']);
}
$this->worlds = b2in_worlds($this->world);
// Der Interior-Footer liegt auf Sand (helle Fläche) und trägt deshalb das positive
// Logo; der Immobilien-Footer bleibt dunkel und nutzt die negative Variante.
$variant = $this->world === 'immobilien' ? 'negative' : 'positive';
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath($variant, $this->world);
$this->logoPathDark = $this->world === 'immobilien'
? ''
: \App\Helpers\ThemeHelper::getLogoPath('negative', $this->world);
}
public function render()
{
return view('livewire.web.components.ui.footer');

View file

@ -16,33 +16,13 @@ class Header extends Component
'en' => 'EN',
];
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
public string $world = 'interior';
/** @var array<string, array{label: string, url: string, active: bool}> */
public array $worlds = [];
public string $logoPath = '';
/** Pfad der Seite, in mount() festgehalten – ein Livewire-Request traegt ihn nicht mehr. */
public string $currentPath = '/';
/** Ziel des Kontakt-Buttons: /contact (Interior) bzw. /immobilien/kontakt (Immobilien). */
public string $contactUrl = '';
public function mount(): void
{
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
$this->currentLocale = app()->getLocale();
$this->world = b2in_world();
$this->worlds = b2in_worlds($this->world);
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath('positive', $this->world);
$this->currentPath = '/'.ltrim(request()->path(), '/');
$this->contactUrl = route($this->world === 'immobilien' ? 'immobilien.contact' : 'contact');
// Jede Welt hat ihre eigene Navigation (CMS: global.header bzw. global.header_immobilien).
$this->content = cms_theme_section($this->world === 'immobilien' ? 'header_immobilien' : 'header');
$this->content = cms_theme_section('header');
// Ensure required keys exist
if (!isset($this->content['portal_login'])) {
@ -51,14 +31,6 @@ class Header extends Component
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
$this->content['navigation'] = [];
}
// Referenzen lassen sich im CMS ausblenden; dann verschwindet auch der Menüpunkt.
if (! references_page_enabled()) {
$this->content['navigation'] = array_values(array_filter(
$this->content['navigation'],
fn ($item) => rtrim(parse_url((string) ($item['url'] ?? ''), PHP_URL_PATH) ?? '', '/') !== '/referenzen',
));
}
}
public function switchLanguage(string $locale): mixed
@ -85,43 +57,27 @@ class Header extends Component
{
$this->isMobileMenuOpen = false;
}
/**
* Aktiver Navigationspunkt: exakter Treffer, sonst der laengste Eintrag, dessen Pfad ein
* Praefix der Seite ist. So markiert /immobilien/magazin nur „Magazin“, nicht auch
* „Immobilien“. Anker (#…) zaehlen nicht zum Pfad.
*/
public function isActiveRoute($url): bool
public function isActiveRoute($url)
{
$path = $this->navPath((string) $url);
$currentPath = request()->path();
$currentPath = '/' . ltrim($currentPath, '/');
if ($this->currentPath === $path) {
// Exact match
if ($currentPath === $url) {
return true;
}
if ($path === '/' || ! str_starts_with($this->currentPath, rtrim($path, '/').'/')) {
return false;
// Special handling for home route
if ($url === '/' && ($currentPath === '/' || $currentPath === '')) {
return true;
}
foreach ($this->content['navigation'] ?? [] as $navItem) {
$other = $this->navPath((string) ($navItem['url'] ?? ''));
if ($other === $this->currentPath) {
return false;
}
if (strlen($other) > strlen($path) && str_starts_with($this->currentPath, rtrim($other, '/').'/')) {
return false;
}
// Check if current path starts with the navigation URL (for sub-pages)
if ($url !== '/' && str_starts_with($currentPath, rtrim($url, '/'))) {
return true;
}
return true;
}
private function navPath(string $url): string
{
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
return '/'.ltrim($path, '/');
return false;
}
public function render()
{

View file

@ -13,47 +13,12 @@ class CmsArticle extends Model
protected $table = 'cms_articles';
/**
* Alte numerische Artikel-IDs (resources/lang/{locale}/b2in.php) → Slug.
*
* Wird an drei Stellen gebraucht: beim Anlegen durch den CmsArticleSeeder, für den
* Lang-Fallback und für die 301-Weiterleitung der alten /magazin/{id}-URLs.
*
* @var array<int, string>
*/
public const LEGACY_ID_TO_SLUG = [
1 => 'escrow-system-dubai-investoren',
2 => 'spotlight-al-jaddaf-hotspot',
3 => 'turnkey-investments-moeblierung-rendite',
4 => 'supply-chain-management-vertragssicherheit',
5 => 'local-for-local-regionaler-moebelhandel',
];
public const WORLD_INTERIOR = 'interior';
public const WORLD_IMMOBILIEN = 'immobilien';
/** @var array<int, string> */
public const WORLDS = [self::WORLD_INTERIOR, self::WORLD_IMMOBILIEN];
/**
* Beiträge aus dem Bestand, die zur Immobilien-Welt gehören (Dubai-Themen).
*
* @var array<int, string>
*/
public const LEGACY_IMMOBILIEN_SLUGS = [
'escrow-system-dubai-investoren',
'spotlight-al-jaddaf-hotspot',
'turnkey-investments-moeblierung-rendite',
];
protected $fillable = [
'slug',
'title',
'subtitle',
'image',
'category',
'world',
'date_label',
'read_time',
'author',
@ -88,104 +53,6 @@ class CmsArticle extends Model
return $query->orderBy('order')->orderByDesc('created_at');
}
public function scopeForWorld(Builder $query, string $world): Builder
{
return $query->where('world', $world);
}
/**
* Veröffentlichte Artikel fürs Frontend, nach Slug indiziert.
*
* Das CMS gewinnt. Solange keine Artikel gepflegt sind, greift die Lang-Datei als Fallback –
* dieselbe Reihenfolge wie bei cms_theme_section(), damit das Magazin auf einer frischen
* Installation nicht leer ist.
*
* Mit `$world` nur die Beiträge dieser Farbwelt (Interior oder Immobilien) – die beiden
* Magazinlisten sind getrennt, die Detailseite rendert in der Welt ihres Beitrags.
*
* @return array<string, array<string, mixed>>
*/
public static function forFrontend(?string $world = null): array
{
$maintained = static::query()->published()->exists();
if ($maintained) {
return static::query()
->published()
->when($world !== null, fn (Builder $query) => $query->forWorld($world))
->ordered()
->get()
->mapWithKeys(fn (self $article) => [$article->slug => $article->toFrontendArray()])
->all();
}
$articles = static::fallbackFromLang();
if ($world === null) {
return $articles;
}
return array_filter($articles, fn (array $article) => ($article['world'] ?? self::WORLD_INTERIOR) === $world);
}
/**
* Slug zu einer alten numerischen Magazin-ID, falls bekannt.
*/
public static function slugForLegacyId(int|string $id): ?string
{
return self::LEGACY_ID_TO_SLUG[(int) $id] ?? null;
}
/**
* Slug eines Artikels aus der Lang-Datei.
*
* Neue Artikel bringen ihren Slug selbst mit (`'slug' => …`). Die fünf ursprünglichen
* Artikel hatten keinen und werden weiter über {@see LEGACY_ID_TO_SLUG} aufgelöst – die
* Konstante bleibt damit das, was sie ist: die Karte für die alten `/magazin/{id}`-URLs.
*
* @param array<string, mixed> $article
*/
public static function slugForLangArticle(int|string $id, array $article): ?string
{
$slug = $article['slug'] ?? null;
if (is_string($slug) && $slug !== '') {
return $slug;
}
return self::slugForLegacyId($id);
}
/**
* @return array<string, array<string, mixed>>
*/
private static function fallbackFromLang(): array
{
$articles = trans('b2in.articles');
if (! is_array($articles)) {
return [];
}
$out = [];
foreach ($articles as $id => $article) {
if (! is_array($article) || ($article['published'] ?? true) === false) {
continue;
}
$slug = self::slugForLangArticle($id, $article);
if ($slug === null) {
continue;
}
$out[$slug] = $article + ['slug' => $slug, 'world' => self::WORLD_INTERIOR];
}
return $out;
}
/**
* @return array<string, mixed>
*/
@ -198,7 +65,6 @@ class CmsArticle extends Model
'subtitle' => $this->subtitle,
'image' => $this->image,
'category' => $this->category,
'world' => $this->world ?? self::WORLD_INTERIOR,
'date' => $this->date_label,
'readTime' => $this->read_time,
'author' => $this->author ?? [],

View file

@ -1,389 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* Kernmarke mit eigener Detailseite (CABINET, Rotpunkt Küchen, Febrü …).
*
* Nicht zu verwechseln mit {@see Brand} – das ist die Produkt-/Partner-Marke.
*/
class CmsBrand extends Model
{
use HasFactory, HasTranslations;
/** Tatsaechlich ausgefuehrtes Projekt – ein Foto aus einem echten Auftrag. */
public const GALLERY_PROJECT = 'project';
/** Entwurf, Rendering oder KI-gestuetzte Darstellung – nicht realisiert. */
public const GALLERY_INSPIRATION = 'inspiration';
/** Einblick in Werkstatt, Fertigung oder Montage des Herstellers – weder Projekt noch Entwurf (Nachtrag 19). */
public const GALLERY_COMPANY = 'company';
/** @var list<string> */
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION, self::GALLERY_COMPANY];
protected $table = 'cms_brands';
protected $fillable = [
'slug',
'name',
'tagline',
'teaser',
'summary',
'highlights',
'description',
'logo',
'image',
'services',
'sections',
'widget_label',
'cta_title',
'cta_text',
'gallery',
'website_url',
'showroom',
'origin_city',
'origin_distance',
'origin_role',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = [
'name',
'tagline',
'teaser',
'summary',
'description',
'widget_label',
'cta_title',
'cta_text',
'origin_city',
'origin_distance',
'origin_role',
];
protected function casts(): array
{
return [
'highlights' => 'array',
'services' => 'array',
'sections' => 'array',
'gallery' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('order')->orderBy('id');
}
/**
* Veröffentlichte Kernmarken fürs Frontend, nach Slug indiziert.
*
* Das CMS gewinnt. Solange keine Marke gepflegt ist, greift die Liste `core_brands.brands`
* der Lang-Datei als Fallback, 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
{
// Direkt aus der Lang-Datei: Im CMS-Eintrag `home.core_brands` steht die Liste nicht
// (mehr), dort pflegt man nur Überschrift und Untertitel – die Marken in der Markenverwaltung.
$brands = trans('b2in.themes.'.config('app.theme', 'b2in').'.core_brands.brands');
$brands = is_array($brands) ? $brands : [];
$out = [];
foreach ($brands as $brand) {
if (! is_array($brand) || empty($brand['name'])) {
continue;
}
$slug = $brand['slug'] ?? \Illuminate\Support\Str::slug((string) $brand['name']);
$entry = array_merge([
'slug' => $slug,
'tagline' => '',
'teaser' => '',
'summary' => '',
'highlights' => [],
'description' => '',
'logo' => '',
'image' => '',
'services' => [],
'sections' => [],
'widget_label' => '',
'cta_title' => '',
'cta_text' => '',
'gallery' => [],
'website_url' => '',
'showroom' => '',
'origin_city' => '',
'origin_distance' => '',
'origin_role' => '',
'has_detail_page' => false,
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
$entry['sections'] = self::normalizeSections($entry['sections']);
$out[$slug] = $entry;
}
return $out;
}
/**
* Seitenabschnitte in eine feste Form bringen.
*
* Jeder Abschnitt hat einen Titel und Absätze. Optional dazu:
* - `claim` hervorgehobene Schlusszeile
* - `quote` Zitat mit `quote_author`
* - `detail` Vertiefung, die im Frontend aufklappbar dargestellt wird
*
* @param mixed $sections
* @return list<array{title: string, paragraphs: list<string>, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list<string>}|null}>
*/
public static function normalizeSections($sections): array
{
if (! is_array($sections)) {
return [];
}
$out = [];
foreach ($sections as $section) {
if (! is_array($section)) {
continue;
}
$paragraphs = self::paragraphList($section['paragraphs'] ?? []);
$title = trim((string) ($section['title'] ?? ''));
if ($title === '' && $paragraphs === []) {
continue;
}
$detail = null;
if (is_array($section['detail'] ?? null)) {
$detailParagraphs = self::paragraphList($section['detail']['paragraphs'] ?? []);
$detailTitle = trim((string) ($section['detail']['title'] ?? ''));
if ($detailTitle !== '' || $detailParagraphs !== []) {
$detail = ['title' => $detailTitle, 'paragraphs' => $detailParagraphs];
}
}
$out[] = [
'title' => $title,
'paragraphs' => $paragraphs,
'claim' => trim((string) ($section['claim'] ?? '')),
'quote' => trim((string) ($section['quote'] ?? '')),
'quote_author' => trim((string) ($section['quote_author'] ?? '')),
'detail' => $detail,
];
}
return $out;
}
/**
* Absätze eines Abschnitts für die Ausgabe gruppieren.
*
* Ein Absatz, der mit „- “ beginnt, ist ein Listenpunkt; aufeinanderfolgende Punkte bilden
* eine Liste. Steht im Punkt ein Doppelpunkt, wird der Teil davor als fette Einleitung
* ausgegeben („- Sichere Prüfung & Terminierung: Wir vereinbaren …“). So lässt sich eine
* Aufzählung im Backend im normalen Textfeld pflegen.
*
* @param list<string> $paragraphs
* @return list<array{type: 'paragraph', text: string}|array{type: 'list', items: list<array{lead: string, text: string}>}>
*/
public static function paragraphBlocks(array $paragraphs): array
{
$blocks = [];
foreach ($paragraphs as $paragraph) {
if (! preg_match('/^[-–•]\s+(.+)$/su', $paragraph, $match)) {
$blocks[] = ['type' => 'paragraph', 'text' => $paragraph];
continue;
}
[$lead, $text] = str_contains($match[1], ': ')
? array_map('trim', explode(': ', $match[1], 2))
: ['', trim($match[1])];
$last = array_key_last($blocks);
if ($last === null || $blocks[$last]['type'] !== 'list') {
$blocks[] = ['type' => 'list', 'items' => []];
$last = array_key_last($blocks);
}
$blocks[$last]['items'][] = ['lead' => $lead, 'text' => $text];
}
return $blocks;
}
/**
* @param mixed $paragraphs
* @return list<string>
*/
private static function paragraphList($paragraphs): array
{
if (is_string($paragraphs)) {
$paragraphs = preg_split('/\R{2,}/', $paragraphs) ?: [];
}
if (! is_array($paragraphs)) {
return [];
}
return array_values(array_filter(array_map(
fn ($p) => trim((string) (is_array($p) ? ($p['text'] ?? '') : $p)),
$paragraphs,
)));
}
/**
* Galerie-Eintraege in eine feste Form bringen.
*
* Ein Eintrag ohne (oder mit unbekannter) Kategorie gilt als Inspiration, nicht als Projekt:
* Ein Entwurf, der versehentlich als Referenz durchgeht, waere die schaedlichere Verwechslung.
*
* @param mixed $gallery
* @return list<array{image: string, category: string, caption: string}>
*/
public static function normalizeGallery($gallery): array
{
if (! is_array($gallery)) {
return [];
}
$out = [];
foreach ($gallery as $item) {
$image = is_array($item) ? (string) ($item['image'] ?? '') : (string) $item;
$image = trim($image);
if ($image === '') {
continue;
}
$category = is_array($item) ? (string) ($item['category'] ?? '') : '';
$out[] = [
'image' => $image,
'category' => in_array($category, self::GALLERY_CATEGORIES, true)
? $category
: self::GALLERY_INSPIRATION,
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
];
}
return $out;
}
/**
* Galerie nach realisierten Projekten, Einblicken in die Werkstatt und Inspirationen getrennt.
*
* @param mixed $gallery
* @return array{projects: list<array{image: string, category: string, caption: string}>, company: list<array{image: string, category: string, caption: string}>, inspirations: list<array{image: string, category: string, caption: string}>}
*/
public static function groupGallery($gallery): array
{
$items = self::normalizeGallery($gallery);
return [
'projects' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_PROJECT,
)),
'company' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_COMPANY,
)),
'inspirations' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION,
)),
];
}
/**
* Beschriftung des Badges auf dem Galeriebild („Realisiertes Projekt“, „Aus der Werkstatt“, „Visualisierung“).
*/
public static function galleryBadge(string $category): string
{
return match ($category) {
self::GALLERY_PROJECT => __('ui.brand_gallery_badge_project'),
self::GALLERY_COMPANY => __('ui.brand_gallery_badge_company'),
default => __('ui.brand_gallery_badge_inspiration'),
};
}
/**
* @return array<string, mixed>
*/
public function toFrontendArray(): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'name' => $this->name,
'tagline' => $this->tagline,
'teaser' => $this->teaser,
'summary' => $this->summary,
'highlights' => $this->highlights ?? [],
'description' => $this->description,
'logo' => $this->logo,
'image' => $this->image,
'services' => $this->services ?? [],
'sections' => self::normalizeSections($this->sections),
'widget_label' => (string) $this->widget_label,
'cta_title' => (string) $this->cta_title,
'cta_text' => (string) $this->cta_text,
'gallery' => self::normalizeGallery($this->gallery),
'website_url' => $this->website_url,
'showroom' => $this->showroom,
'origin_city' => (string) $this->origin_city,
'origin_distance' => (string) $this->origin_distance,
'origin_role' => (string) $this->origin_role,
'has_detail_page' => true,
];
}
}

View file

@ -1,115 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* Referenzprojekt der Interior-Welt: ein realisierter Auftrag mit Titelbild, weiteren Bildern
* und den beteiligten Kernmarken (Nachtrag 13). Erscheint auf /referenzen.
*
* Nicht zu verwechseln mit {@see CmsProject} – das sind die Immobilienprojekte.
*/
class CmsReference extends Model
{
use HasFactory, HasTranslations;
protected $table = 'cms_references';
protected $fillable = [
'title',
'location',
'year',
'description',
'image',
'gallery',
'brand_slugs',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = ['title', 'description'];
protected function casts(): array
{
return [
'gallery' => 'array',
'brand_slugs' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('order')->orderBy('id');
}
/**
* Galerie in eine feste Form bringen: nur Einträge mit Bild, Bildunterschrift getrimmt.
*
* @param mixed $gallery
* @return list<array{image: string, caption: string}>
*/
public static function normalizeGallery($gallery): array
{
if (! is_array($gallery)) {
return [];
}
$out = [];
foreach ($gallery as $item) {
$image = trim((string) (is_array($item) ? ($item['image'] ?? '') : $item));
if ($image === '') {
continue;
}
$out[] = [
'image' => $image,
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
];
}
return $out;
}
/**
* Beteiligte Marken als Liste mit Name und Link, aufgelöst gegen die veröffentlichten Marken.
*
* Eine unveröffentlichte oder gelöschte Marke fällt heraus.
*
* @param array<string, array<string, mixed>> $brands Ergebnis von CmsBrand::forFrontend()
* @return list<array{slug: string, name: string, link: ?string}>
*/
public function resolveBrands(array $brands): array
{
$out = [];
foreach ($this->brand_slugs ?? [] as $slug) {
$slug = (string) $slug;
if (! isset($brands[$slug])) {
continue;
}
$out[] = [
'slug' => $slug,
'name' => (string) ($brands[$slug]['name'] ?? $slug),
'link' => ! empty($brands[$slug]['has_detail_page']) ? '/marken/'.$slug : null,
];
}
return $out;
}
}

View file

@ -2,10 +2,6 @@
namespace App\Providers;
use App\Support\OperationalAlert;
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
@ -33,17 +29,5 @@ class AppServiceProvider extends ServiceProvider
if ($scheme === 'https') {
URL::forceScheme('https');
}
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
Queue::failing(function (JobFailed $event): void {
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
// Ausgelieferte Version in `php artisan about` (ABOUT_SECTION in dev/betrieb/deploy.sh)
AboutCommand::add('B2in', fn (): array => ['Version' => config('app.version') ?? '—']);
}
}

View file

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

@ -1,187 +0,0 @@
<?php
namespace App\Services;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Support\Str;
/**
* Seitenbaum für den Inhalte-Editor (config/cms_pages.php, Nachtrag 12).
*
* Verbindet die Registry mit dem Datenbestand: je Sektion, ob sie in flux_cms_contents liegt
* (dann mit `content_id`), und was sonst noch in der Datenbank steht, aber in keiner Seite
* eingetragen ist („Nicht mehr verwendet“).
*/
class CmsPageTree
{
/** Schlüssel des Sammelpunkts für nicht eingetragene Sektionen. */
public const UNLISTED = 'unlisted';
/**
* Alle Seiten mit ihren Sektionen in Website-Reihenfolge.
*
* @return array<string, array{title: string, path: ?string, sections: list<array<string, mixed>>}>
*/
public function pages(): array
{
$rows = CmsContent::query()
->get(['id', 'group', 'key'])
->keyBy(fn (CmsContent $row) => $row->group.'/'.$row->key);
$pages = [];
foreach (config('cms_pages.pages', []) as $pageKey => $page) {
$sections = [];
foreach ($page['sections'] ?? [] as $section) {
$id = $section['group'].'/'.$section['key'];
$sections[] = array_merge($section, [
'page' => $pageKey,
'path' => $section['path'] ?? $page['path'] ?? null,
'anchor' => $section['anchor'] ?? null,
'content_id' => $rows[$id]->id ?? null,
'theme_key' => $this->themeKey($section['group'], $section['key']),
]);
}
$pages[$pageKey] = [
'title' => $page['title'],
'path' => $page['path'] ?? null,
'sections' => $sections,
];
}
$unlisted = $this->unlistedSections($rows->keys()->all(), $rows);
if ($unlisted !== []) {
$pages[self::UNLISTED] = [
'title' => config('cms_pages.unlisted.title', 'Nicht mehr verwendet'),
'path' => null,
'sections' => $unlisted,
];
}
return $pages;
}
/**
* Eine Sektion aus der Registry samt Datenbankbezug, oder null.
*
* @return array<string, mixed>|null
*/
public function section(string $group, string $key): ?array
{
foreach ($this->pages() as $page) {
foreach ($page['sections'] as $section) {
if ($section['group'] === $group && $section['key'] === $key) {
return $section;
}
}
}
return null;
}
/**
* Klartext-Beschriftung eines Feldes: Sektions-Override, dann Wörterbuch, sonst lesbar gemacht.
*/
public function fieldLabel(string $field, ?string $group = null, ?string $key = null): string
{
if ($group !== null && $key !== null) {
foreach (config('cms_pages.pages', []) as $page) {
foreach ($page['sections'] ?? [] as $section) {
if ($section['group'] === $group && $section['key'] === $key && isset($section['fields'][$field])) {
return $section['fields'][$field];
}
}
}
}
$labels = config('cms_pages.field_labels', []);
if (isset($labels[$field])) {
return $labels[$field];
}
return Str::ucfirst(str_replace('_', ' ', $field));
}
/**
* Adresse der Vorschau für eine Sektion: Frontend-Domain, Pfad, Cache-Brecher und Sprungmarke.
*/
public function previewUrl(?array $section, int $version = 0): ?string
{
$path = $section['path'] ?? null;
if ($path === null) {
return null;
}
$url = rtrim(config('domains.protocol', 'https://').config('domains.domain_b2in'), '/')
.'/'.ltrim($path, '/')
.'?cms-preview='.$version;
if (! empty($section['anchor'])) {
$url .= '#'.$section['anchor'];
}
return $url;
}
/**
* Theme-Section-Schlüssel (Lang-Datei) zu Gruppe/Key, für „Aus Vorlage anlegen“.
*/
public function themeKey(string $group, string $key): ?string
{
foreach (config('cms_section_map.sections', []) as $themeKey => $target) {
if ($target === [$group, $key]) {
return $themeKey;
}
}
return null;
}
/**
* @param list<string> $ids
* @param \Illuminate\Support\Collection<string, CmsContent> $rows
* @return list<array<string, mixed>>
*/
private function unlistedSections(array $ids, $rows): array
{
$listed = [];
foreach (config('cms_pages.pages', []) as $page) {
foreach ($page['sections'] ?? [] as $section) {
$listed[$section['group'].'/'.$section['key']] = true;
}
}
$out = [];
foreach ($ids as $id) {
if (isset($listed[$id])) {
continue;
}
$row = $rows[$id];
$out[] = [
'group' => $row->group,
'key' => $row->key,
'page' => self::UNLISTED,
'path' => null,
'anchor' => null,
'title' => $row->group.' / '.$row->key,
'description' => null,
'content_id' => $row->id,
'theme_key' => null,
];
}
usort($out, fn (array $a, array $b) => [$a['group'], $a['key']] <=> [$b['group'], $b['key']]);
return $out;
}
}

View file

@ -1,198 +0,0 @@
<?php
namespace App\Services\Monitoring;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
/**
* Die Prüfungen, die ein externer Monitor jede Minute abfragt
*: Datenbank, Cache, Queue-Rückstau,
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
* große Tabellen, damit `/health` selbst keine Last erzeugt.
*
* Ein Admin-Systemstatus kann `scheduler()` und `disk()` mitbenutzen.
*/
class HealthProbe
{
public const StatusOk = 'ok';
public const StatusWarn = 'warn';
public const StatusCritical = 'critical';
/**
* Datei statt Datenbank/Cache: übersteht `cache:clear`, braucht keine
* Migration. Bei mehreren App-Servern stattdessen eine DB-Zeile nehmen.
*/
public static function heartbeatPath(): string
{
return (string) (config('monitoring.scheduler.heartbeat_path') ?: storage_path('framework/scheduler-heartbeat'));
}
public function recordHeartbeat(): void
{
file_put_contents(self::heartbeatPath(), now()->toIso8601String(), LOCK_EX);
}
public function lastHeartbeat(): ?Carbon
{
$value = @file_get_contents(self::heartbeatPath());
return is_string($value) && trim($value) !== '' ? Carbon::parse(trim($value)) : null;
}
/**
* @return list<array{key: string, label: string, status: string, detail: string}>
*/
public function checks(): array
{
return [
$this->database(),
$this->cache(),
$this->queue(),
$this->scheduler(),
$this->disk(),
];
}
/**
* @param list<array{status: string}> $checks
*/
public static function worstStatus(array $checks): string
{
$statuses = array_column($checks, 'status');
return match (true) {
in_array(self::StatusCritical, $statuses, true) => self::StatusCritical,
in_array(self::StatusWarn, $statuses, true) => self::StatusWarn,
default => self::StatusOk,
};
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
public function scheduler(): array
{
$lastBeat = $this->lastHeartbeat();
$warnMinutes = (int) config('monitoring.scheduler.heartbeat_warn_minutes');
$criticalMinutes = (int) config('monitoring.scheduler.heartbeat_critical_minutes');
$minutesAgo = $lastBeat !== null ? (int) $lastBeat->diffInMinutes(now(), absolute: true) : null;
return $this->check(
'scheduler',
'Scheduler',
match (true) {
$minutesAgo === null, $minutesAgo >= $criticalMinutes => self::StatusCritical,
$minutesAgo >= $warnMinutes => self::StatusWarn,
default => self::StatusOk,
},
$lastBeat === null
? 'Noch kein Herzschlag — läuft `schedule:run` per Cron (bzw. `schedule:work`)?'
: 'Letzter Herzschlag '.$lastBeat->format('d.m.Y H:i:s').' (vor '.$minutesAgo.' Min.).',
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
public function disk(): array
{
$path = storage_path();
$free = @disk_free_space($path);
$total = @disk_total_space($path);
if ($free === false || $total === false || $total <= 0) {
return $this->check('disk', 'Plattenplatz', self::StatusWarn, 'Plattenplatz nicht ermittelbar.');
}
$freePercent = round($free / $total * 100, 1);
$minPercent = (float) config('monitoring.health.disk_min_free_percent');
return $this->check(
'disk',
'Plattenplatz',
match (true) {
$freePercent < $minPercent => self::StatusCritical,
$freePercent < $minPercent * 2 => self::StatusWarn,
default => self::StatusOk,
},
sprintf('%s %% frei (%s GB von %s GB).', $freePercent, round($free / 1024 ** 3, 1), round($total / 1024 ** 3, 1)),
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function database(): array
{
try {
DB::select('select 1');
return $this->check('database', 'Datenbank', self::StatusOk, 'Verbindung steht.');
} catch (Throwable $exception) {
return $this->check('database', 'Datenbank', self::StatusCritical, 'Keine Verbindung: '.Str::limit($exception->getMessage(), 120));
}
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function cache(): array
{
try {
$value = Str::random(8);
Cache::put('monitoring:health-probe', $value, 60);
return Cache::get('monitoring:health-probe') === $value
? $this->check('cache', 'Cache', self::StatusOk, 'Schreiben und Lesen funktionieren.')
: $this->check('cache', 'Cache', self::StatusCritical, 'Geschriebener Wert kommt nicht zurück.');
} catch (Throwable $exception) {
return $this->check('cache', 'Cache', self::StatusCritical, 'Nicht erreichbar: '.Str::limit($exception->getMessage(), 120));
}
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function queue(): array
{
if (config('queue.default') !== 'database') {
return $this->check('queue', 'Queue', self::StatusOk, 'Treiber '.config('queue.default').' — Rückstau hier nicht prüfbar.');
}
try {
$now = now()->getTimestamp();
$oldestAvailable = DB::table('jobs')
->whereNull('reserved_at')
->where('available_at', '<=', $now)
->min('available_at');
} catch (Throwable $exception) {
return $this->check('queue', 'Queue', self::StatusCritical, 'Jobs-Tabelle nicht lesbar: '.Str::limit($exception->getMessage(), 120));
}
$waitingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : 0;
$criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds');
return $this->check(
'queue',
'Queue',
$waitingSeconds >= $criticalSeconds ? self::StatusCritical : self::StatusOk,
$oldestAvailable === null
? 'Kein wartender Job.'
: 'Ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''),
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function check(string $key, string $label, string $status, string $detail): array
{
return ['key' => $key, 'label' => $label, 'status' => $status, 'detail' => $detail];
}
}

View file

@ -27,10 +27,8 @@ class DisplayModuleSettings
],
DisplayVersionType::B2in->value => [
'theme' => 'dark',
// 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.',
'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
'header_claim' => 'Connecting Design & Property',
'logo_position' => 'top-left',
'claim_position' => 'top-right',
'show_logo' => true,

View file

@ -1,159 +0,0 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\UserDataBag;
use Throwable;
/**
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
*
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
* in Query-Strings (signierte Links) und in Pfaden
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
* QueryException mit eingesetzten Bindings) ebenso.
*/
class ErrorReportScrubber
{
public const Filtered = '[Filtered]';
/**
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
* Je Projekt anpassen — dieselbe Liste steht in error-reporting.js.
*
* @var list<string>
*/
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort'];
private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i';
/** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */
private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#';
public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event
{
$userId = self::authenticatedUserId();
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
$event->setRequest(self::scrubRequest($event->getRequest()));
foreach ($event->getExceptions() as $exception) {
$exception->setValue(self::scrubText($exception->getValue()));
}
if ($event->getMessage() !== null) {
$formatted = $event->getMessageFormatted();
$event->setMessage(
self::scrubText($event->getMessage()),
$event->getMessageParams(),
$formatted !== null ? self::scrubText($formatted) : null,
);
}
return $event;
}
/**
* @param array<string, mixed> $request
* @return array<string, mixed>
*/
public static function scrubRequest(array $request): array
{
if (isset($request['url']) && is_string($request['url'])) {
$request['url'] = self::scrubUrl($request['url']);
}
if (isset($request['query_string']) && is_string($request['query_string'])) {
$request['query_string'] = self::scrubQuery($request['query_string']);
}
unset($request['data'], $request['cookies']);
foreach ($request['headers'] ?? [] as $name => $values) {
if (strtolower((string) $name) === 'referer') {
$request['headers'][$name] = array_map(
static fn (mixed $value): string => self::scrubUrl((string) $value),
(array) $values,
);
}
}
return $request;
}
public static function scrubUrl(string $url): string
{
$query = parse_url($url, PHP_URL_QUERY);
$withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url;
$scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery));
return $query !== null && $query !== false && $query !== ''
? $scrubbedPath.'?'.self::scrubQuery($query)
: $scrubbedPath;
}
public static function scrubQuery(string $query): string
{
parse_str($query, $parameters);
$scrubbed = [];
foreach ($parameters as $name => $value) {
$isAllowed = in_array((string) $name, self::AllowedQueryParameters, true)
|| str_starts_with((string) $name, 'utm_');
$scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered;
}
return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986);
}
public static function scrubPath(string $path): string
{
return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path));
}
public static function scrubText(string $text): string
{
return (string) preg_replace(self::EmailPattern, '[email]', $text);
}
/**
* Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet
* (`token`, `hash`, `signature`), im Pfad der aktuellen Route.
*/
private static function scrubPathTokens(string $url): string
{
try {
$route = request()->route();
} catch (Throwable) {
return $url;
}
if ($route === null) {
return $url;
}
foreach ($route->parameters() as $name => $value) {
if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) {
$url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url));
}
}
return $url;
}
private static function authenticatedUserId(): int|string|null
{
try {
return Auth::id();
} catch (Throwable) {
return null;
}
}
}

View file

@ -1,68 +0,0 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Sentry\Severity;
use Sentry\State\Scope;
use function Sentry\captureMessage;
use function Sentry\withScope;
/**
* Fehler ohne Exception als GlitchTip-Warnung: tote
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
*/
class ErrorSignal
{
public const DeadInternalLink = 'dead_internal_link';
public const AccessDenied = 'access_denied';
public const PageExpired = 'page_expired';
public const Throttled = 'throttled';
private const Labels = [
self::DeadInternalLink => 'Toter interner Link',
self::AccessDenied => 'Zugriff verweigert (angemeldet)',
self::PageExpired => 'Seite abgelaufen (419)',
self::Throttled => 'Drosselung trifft angemeldeten Nutzer',
];
private const MaxReportsPerWindow = 5;
private const WindowSeconds = 600;
/**
* @param array<string, mixed> $context
*/
public function report(string $signal, string $location, array $context = []): bool
{
$location = Str::limit($location, 200, '');
$reported = RateLimiter::attempt(
'error-signal:'.sha1($signal.'|'.$location),
self::MaxReportsPerWindow,
static fn (): bool => true,
self::WindowSeconds,
);
if ($reported === false) {
return false;
}
withScope(function (Scope $scope) use ($signal, $location, $context): void {
$scope->setFingerprint(['error-signal', $signal, $location]);
$scope->setTag('signal', $signal);
$scope->setContext('signal', $context);
captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning());
});
return true;
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace App\Support\Monitoring;
/**
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
* beim Deploy entsteht (`git describe --tags --always --dirty > RELEASE`) —
* der Live-Server hat kein Git, solange per SFTP deployt wird.
*
* Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere,
* Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren
* Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“.
*/
class Release
{
public static function current(?string $configured, ?string $basePath = null): ?string
{
$configured = trim((string) $configured);
if ($configured !== '') {
return $configured;
}
$file = rtrim($basePath ?? base_path(), '/').'/RELEASE';
if (! is_file($file)) {
return null;
}
$release = trim((string) file_get_contents($file));
return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null;
}
}

View file

@ -1,96 +0,0 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Throwable;
/**
* Zentrale Betriebs-Alerts: meldet fehlgeschlagene Scheduler-Tasks und
* Queue-Jobs (Verdrahtung: siehe 03-betriebsueberwachung.md). Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
* immer; Mail und ntfy-Push sind Best-Effort, unabhängig
* voneinander, und dürfen den auslösenden Prozess nie mit in den Abgrund
* reißen (Mailserver down, ntfy nicht erreichbar o. ä.).
*/
class OperationalAlert
{
/**
* @param array<string, mixed> $context
*/
public static function send(string $summary, array $context = []): void
{
Log::channel('monitoring')->critical('[monitoring] '.$summary, $context);
if (! config('monitoring.alerts.enabled')) {
return;
}
self::mail($summary, $context);
self::push($summary, $context);
}
/**
* @param array<string, mixed> $context
*/
private static function mail(string $summary, array $context): void
{
$recipient = config('monitoring.alerts.mail_to');
if (empty($recipient)) {
return;
}
try {
Mail::raw(
$summary."\n\n".json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
fn ($message) => $message->to($recipient)->subject('['.config('app.name').'] Betriebs-Alert: '.Str::limit($summary, 80)),
);
} catch (Throwable $e) {
Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);
}
}
/**
* JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header).
*
* @param array<string, mixed> $context
*/
private static function push(string $summary, array $context): void
{
$url = (string) config('monitoring.alerts.ntfy.url');
$topic = (string) config('monitoring.alerts.ntfy.topic');
if ($url === '' || $topic === '') {
return;
}
$details = collect($context)
->map(fn (mixed $value, string $key): string => $key.': '.(is_scalar($value) || $value === null ? (string) $value : json_encode($value)))
->implode("\n");
try {
Http::timeout(5)
->connectTimeout(3)
->when(filled(config('monitoring.alerts.ntfy.token')), fn ($request) => $request->withToken((string) config('monitoring.alerts.ntfy.token')))
->post(rtrim($url, '/'), [
'topic' => $topic,
'title' => config('app.name').' ('.app()->environment().'): Betriebs-Alert',
'message' => Str::limit(trim($summary."\n\n".$details), 3500),
'priority' => (int) config('monitoring.alerts.ntfy.priority'),
'tags' => ['rotating_light'],
])
->throw();
} catch (Throwable $e) {
Log::channel('monitoring')->error('[monitoring] ntfy-Push konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -1,6 +1,5 @@
<?php
use App\Models\Setting;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\CmsContentService;
@ -49,6 +48,8 @@ if (! function_exists('cms_theme_section')) {
/** @var array<string, array{0: string, 1: string}> $sections */
$sections = config('cms_section_map.sections', []);
/** @var array<string, string> $langKeys */
$langKeys = config('cms_section_map.lang_keys', []);
if (isset($sections[$sectionKey])) {
[$group, $key] = $sections[$sectionKey];
@ -59,47 +60,9 @@ if (! function_exists('cms_theme_section')) {
}
}
return trans("b2in.themes.{$theme}.{$sectionKey}");
}
}
$langKey = $langKeys[$sectionKey] ?? $sectionKey;
if (! function_exists('references_page_enabled')) {
/**
* Ob die Seite /referenzen samt Menüpunkt sichtbar ist – Schalter im CMS-Modul „Referenzen“
* (Einstellung pages.referenzen_enabled). Ohne Eintrag ist die Seite sichtbar.
*/
function references_page_enabled(): bool
{
return (bool) Setting::getValue('pages', 'referenzen_enabled', true);
}
}
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;
return trans("b2in.themes.{$theme}.{$langKey}");
}
}
@ -131,13 +94,6 @@ if (! function_exists('theme_image_url')) {
return asset('storage/'.$trimmed);
}
// Liegt die Datei unter public/img/assets/, kommt sie von dort – auch wenn sie inzwischen in
// der Mediathek steht (AssetMediaImportSeeder). Nur so findet <x-web-picture> die WebP-Fassung
// neben JPG/PNG.
if (str_contains($trimmed, '/') && is_file(public_path('img/assets/'.$trimmed))) {
return asset('img/assets/'.$trimmed);
}
$candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)])));
foreach ($candidates as $candidate) {
if (CmsMedia::query()->where('filename', $candidate)->exists()) {
@ -167,14 +123,29 @@ if (! function_exists('tcms')) {
}
}
if (! function_exists('cms_media_url')) {
/**
* Resolve a CMS content key (type=image) to a full media URL.
* Falls back to asset('assets/images/...') if not found in media library.
*/
function cms_media_url(string $key, string $profile = ''): string
{
$filename = cms($key);
if (! $filename || ! is_string($filename) || $filename === $key) {
$fallback = str_replace('.', '/', $key);
return asset('assets/images/'.basename($fallback));
}
return media_url($filename, $profile);
}
}
if (! function_exists('media_url')) {
/**
* Resolve a CmsMedia filename to its full storage URL.
* Cached per request to avoid repeated DB queries.
*
* Ohne Eintrag in der Mediathek zeigt die URL auf public/img/assets/ – dort legen die Seeder
* Marken-, Magazin- und Slideshow-Bilder ab (wie theme_image_url() im Frontend). Der frühere
* Rückfall assets/images/ existierte nicht und lieferte im Admin 404 (07.10.2026).
* Uses in-memory cache to avoid repeated DB queries.
*/
function media_url(?string $filename, string $profile = ''): string
{
@ -182,80 +153,27 @@ if (! function_exists('media_url')) {
return '';
}
// once(): je Request (und je Test) zwischengespeichert, nicht über die ganze Prozesslaufzeit.
return once(function () use ($filename, $profile): string {
$media = CmsMedia::where('filename', $filename)->first();
static $resolved = [];
$cacheKey = $filename.'|'.$profile;
if (! $media) {
return asset('img/assets/'.ltrim($filename, '/'));
}
return $profile && $media->hasConversion($profile)
? $media->getConversionUrl($profile)
: $media->getUrl();
});
}
}
if (! function_exists('b2in_world')) {
/**
* Farbwelt der aktuellen Seite: `interior` (Standard) oder `immobilien`.
*
* Die Immobilien-Welt umfasst die Immobilien-Routen samt Detailseiten und den
* Entwicklungs-Vorschauen. Das Layout setzt den Wert als Body-Klasse
* (`world-interior` / `world-immobilien`), theme-b2in.css schaltet daran die Farben um.
* Livewire-Komponenten merken sich die Welt in mount(), weil ein späterer
* Livewire-Request keine Seitenroute mehr trägt.
*/
function b2in_world(): string
{
if (app()->bound('b2in.world')) {
return (string) app('b2in.world');
if (isset($resolved[$cacheKey])) {
return $resolved[$cacheKey];
}
$routeName = request()->route()?->getName();
$media = CmsMedia::where('filename', $filename)->first();
if (is_string($routeName) && (str_starts_with($routeName, 'immobilien') || str_starts_with($routeName, 'dev.immobilien'))) {
return 'immobilien';
if (! $media) {
$resolved[$cacheKey] = asset('assets/images/'.$filename);
return $resolved[$cacheKey];
}
return 'interior';
}
}
if (! function_exists('b2in_worlds')) {
/**
* Die beiden Welten für den Welten-Wechsler in Kopf- und Fußzeile.
*
* @return array<string, array{label: string, url: string, active: bool}>
*/
function b2in_worlds(?string $current = null): array
{
$current = $current ?? b2in_world();
return [
'interior' => [
'label' => __('ui.world_interior'),
'url' => url('/'),
'active' => $current === 'interior',
],
'immobilien' => [
'label' => __('ui.world_immobilien'),
'url' => route('immobilien'),
'active' => $current === 'immobilien',
],
];
}
}
if (! function_exists('b2in_world_set')) {
/**
* Welt für den laufenden Request festlegen, wenn sie nicht an der Route hängt –
* etwa auf der Magazin-Detailseite, die in der Welt ihres Beitrags rendert.
* Muss vor dem Rendern von Kopf- und Fußzeile geschehen (beide halten die Welt in mount() fest).
*/
function b2in_world_set(string $world): void
{
app()->instance('b2in.world', $world);
if ($profile && $media->hasConversion($profile)) {
$resolved[$cacheKey] = $media->getConversionUrl($profile);
} else {
$resolved[$cacheKey] = $media->getUrl();
}
return $resolved[$cacheKey];
}
}

View file

@ -1,25 +1,14 @@
<?php
use App\Http\Controllers\HealthController;
use App\Http\Middleware\ReportErrorSignals;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route;
use Sentry\Laravel\Integration;
use Symfony\Component\Console\Exception\CommandNotFoundException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/domains.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
// Tiefer Health-Check für den externen Monitor (Uptime Kuma), auf jeder Domain
Route::get('/health', HealthController::class)
->middleware('throttle:30,1')
->name('health.deep');
},
)
->withMiddleware(function (Middleware $middleware) {
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
@ -38,20 +27,11 @@ return Application::configure(basePath: dirname(__DIR__))
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
]);
// Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) –
// global, weil 404 vor dem Routing entsteht
$middleware->append(ReportErrorSignals::class);
// BasicAuth ganz am Ende, nach Session-Middleware
if (env('BASIC_AUTH_ENABLED', true)) {
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
}
})
->withExceptions(function (Exceptions $exceptions) {
// Gemeldete Exceptions an GlitchTip (ohne DSN ein No-op);
// Laravels dontReport (404, Validierung, Auth) gilt weiter
Integration::handles($exceptions);
// Vertippte artisan-Befehle sind kein Anwendungsfehler
$exceptions->dontReport(CommandNotFoundException::class);
//
})->create();

View file

@ -22,7 +22,6 @@
"livewire/flux": "^2.1.1",
"livewire/flux-pro": "^2.6",
"livewire/volt": "^1.7.0",
"sentry/sentry-laravel": "^4.29",
"spatie/laravel-permission": "^6.17"
},
"require-dev": {

553
composer.lock generated
View file

@ -4,11 +4,11 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f2a8196dfd6ef735f3e827805a8a1f94",
"content-hash": "9e07d437a063526b426b920d400db8b8",
"packages": [
{
"name": "acme/contact-form",
"version": "dev-feature/b2in-interior",
"version": "dev-master",
"dist": {
"type": "path",
"url": "packages/acme/contact-form",
@ -55,7 +55,7 @@
},
{
"name": "acme/cookie-consent",
"version": "dev-feature/b2in-interior",
"version": "dev-master",
"dist": {
"type": "path",
"url": "packages/acme/CookieConsent",
@ -860,7 +860,7 @@
},
{
"name": "flux-cms/core",
"version": "dev-feature/b2in-interior",
"version": "dev-master",
"dist": {
"type": "path",
"url": "packages/flux-cms/core",
@ -1603,66 +1603,6 @@
],
"time": "2026-02-19T13:11:17+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.35.0",
@ -3553,84 +3493,6 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/Nyholm/psr7.git",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0",
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"http-interop/http-factory-tests": "^0.9",
"php-http/message-factory": "^1.0",
"php-http/psr7-integration-tests": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
"symfony/error-handler": "^4.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.8-dev"
}
},
"autoload": {
"psr-4": {
"Nyholm\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Martijn van der Ven",
"email": "martijn@vanderven.se"
}
],
"description": "A fast PHP7 implementation of PSR-7",
"homepage": "https://tnyholm.se",
"keywords": [
"psr-17",
"psr-7"
],
"support": {
"issues": "https://github.com/Nyholm/psr7/issues",
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/Zegnat",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2024-09-09T07:06:30+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
@ -4516,195 +4378,6 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sentry/sentry",
"version": "4.34.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/3d8f4e56becb10ede64e2ac6f8077f6434715461",
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"carthage-software/mago": "1.30.0",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/guzzle": "^7.0|^8.0",
"guzzlehttp/promises": "^2.0.3|^3.0.0",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
"open-telemetry/api": "^1.1",
"open-telemetry/context": "^1.1",
"open-telemetry/exporter-otlp": "^1.1",
"open-telemetry/gen-otlp-protobuf": "^1.2.1",
"open-telemetry/sdk": "^1.1",
"open-telemetry/sem-conv": "^1.27",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.34.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-10-08T11:25:58+00:00"
},
{
"name": "sentry/sentry-laravel",
"version": "4.29.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b3deab3d8818490220e1da8e2d7cca971f74410",
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410",
"shasum": ""
},
"require": {
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"nyholm/psr7": "^1.0",
"php": "^7.2 | ^8.0",
"sentry/sentry": "^4.34.0",
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.11",
"guzzlehttp/guzzle": "^7.2",
"laravel/ai": "^0.1.5 | ^1.0",
"laravel/folio": "^1.1",
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"laravel/octane": "^2.15",
"laravel/pennant": "^1.0",
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
"mockery/mockery": "^1.3",
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpstan/phpstan": "^1.10 | ^2.2",
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Sentry": "Sentry\\Laravel\\Facade"
},
"providers": [
"Sentry\\Laravel\\ServiceProvider",
"Sentry\\Laravel\\Tracing\\ServiceProvider"
]
}
},
"autoload": {
"psr-0": {
"Sentry\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Laravel SDK for Sentry (https://sentry.io)",
"homepage": "https://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"laravel",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
"source": "https://github.com/getsentry/sentry-laravel/tree/4.29.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-10-08T12:31:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.0",
@ -5928,77 +5601,6 @@
],
"time": "2026-02-05T15:57:06+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "88f9c561f678a02d54b897014049fa839e33ff82"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
"reference": "88f9c561f678a02d54b897014049fa839e33ff82",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
@ -6893,93 +6495,6 @@
],
"time": "2026-01-26T15:07:59+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c",
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^7.4|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/routing",
"version": "v7.4.6",
@ -8452,6 +7967,66 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/boost",
"version": "v1.8.11",

View file

@ -15,18 +15,6 @@ return [
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Ausgelieferte Version
|--------------------------------------------------------------------------
|
| Git-Tag des Deploys (Datei RELEASE, schreibt dev/betrieb/deploy.sh) oder
| APP_VERSION. Erscheint in /health, `php artisan about` und GlitchTip.
|
*/
'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')),
/*
|--------------------------------------------------------------------------
| Application Environment
@ -53,34 +41,6 @@ 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'),
/*
|--------------------------------------------------------------------------
| Obere Hinweisleiste (Ankündigungsleiste)
|--------------------------------------------------------------------------
|
| Seit der Abstimmung vom 01.10.2026 ausgeblendet – die Seite wirkt ohne die
| Leiste ruhiger. Inhalt und CMS-Pflege bleiben erhalten; mit
| B2IN_ANNOUNCEMENT_BAR=true ist sie auf allen Seiten wieder da.
|
*/
'announcement_bar' => (bool) env('B2IN_ANNOUNCEMENT_BAR', false),
/*
|--------------------------------------------------------------------------
| Application URL

View file

@ -1,298 +0,0 @@
<?php
declare(strict_types=1);
/**
* Seitenbaum für den Inhalte-Editor im Portal (Nachtrag 12).
*
* Der Editor zeigt die CMS-Sektionen nicht mehr nach Datenbank-Gruppe, sondern so, wie sie auf
* der Website stehen: je Seite in der Reihenfolge von oben nach unten, mit Klartext-Titel und
* einem Satz, der sagt, was die Sektion ist. `anchor` ist die Sprungmarke in der Vorschau
* (das `id` des Abschnitts im Frontend).
*
* Eine Sektion, die auf mehreren Seiten steht, ist einmal eingetragen – dort, wo sie zuerst
* auftaucht – und nennt die anderen Seiten in der Beschreibung.
*
* `group`/`key` sind Gruppe und Schlüssel in flux_cms_contents (siehe cms_section_map.php).
* Sektionen, die in der Datenbank liegen, aber hier nicht eingetragen sind, sammelt der Editor
* unter „Nicht mehr verwendet“ – sie gehen also nicht verloren, stehen aber auch nicht im Weg.
*
* `fields` überschreibt Feldbeschriftungen je Sektion; alles andere kommt aus `field_labels`.
*/
return [
/*
|--------------------------------------------------------------------------
| Feldbeschriftungen
|--------------------------------------------------------------------------
| Gilt für Felder auf Sektionsebene und für Felder in Listeneinträgen.
| Unbekannte Schlüssel werden lesbar gemacht (button_text → „Button text“).
*/
'field_labels' => [
'title' => 'Überschrift',
'subtitle' => 'Untertitel',
'headline' => 'Überschrift',
'tagline' => 'Claim',
'text' => 'Text',
'intro' => 'Einleitung',
'description' => 'Beschreibung',
'content' => 'Inhalt',
'lead' => 'Vorspann',
'note' => 'Hinweis',
'label' => 'Beschriftung',
'badge' => 'Kennzeichen',
'eyebrow' => 'Überzeile',
'quote' => 'Zitat',
'author' => 'Zitatgeber',
'name' => 'Name',
'role' => 'Rolle / Funktion',
'city' => 'Ort',
'value' => 'Wert',
'number' => 'Zahl',
'suffix' => 'Zusatz',
'icon' => 'Icon',
'image' => 'Bild',
'image_alt' => 'Bildbeschreibung',
'alt' => 'Bildbeschreibung',
'logo' => 'Logo',
'link' => 'Link',
'url' => 'Adresse (URL)',
'href' => 'Link',
'link_text' => 'Linktext',
'button_text' => 'Button-Beschriftung',
'button_link' => 'Button-Link',
'primary_button' => 'Erster Button',
'secondary_button' => 'Zweiter Button',
'cta_title' => 'Überschrift Abschluss',
'cta_text' => 'Text Abschluss',
'cta_link' => 'Link Abschluss',
'items' => 'Einträge',
'steps' => 'Schritte',
'cards' => 'Karten',
'stats' => 'Kennzahlen',
'features' => 'Merkmale',
'benefits' => 'Vorteile',
'values' => 'Werte',
'links' => 'Links',
'columns' => 'Spalten',
'menu' => 'Menüpunkte',
'navigation' => 'Navigation',
'topics' => 'Themen',
'options' => 'Auswahlmöglichkeiten',
'placeholder' => 'Platzhaltertext',
'meta_title' => 'Seitentitel (Browser, Google)',
'meta_description' => 'Beschreibung für Google',
'email' => 'E-Mail',
'phone' => 'Telefon',
'whatsapp' => 'WhatsApp-Nummer',
'address' => 'Adresse',
'opening_hours' => 'Öffnungszeiten',
'copyright' => 'Copyright-Zeile',
'map_alt' => 'Beschreibung der Karte',
'region_label' => 'Beschriftung der Region',
'distance_suffix' => 'Zusatz hinter der Entfernung',
'hub' => 'Drehscheibe (Bielefeld)',
'steps_title' => 'Überschrift Ablauf-Block',
'steps_text' => 'Einleitung Ablauf-Block',
'x' => 'Position waagerecht (Entwickler)',
'y' => 'Position senkrecht (Entwickler)',
'id' => 'Sprungmarke (Entwickler)',
'slug' => 'Adresskürzel (Entwickler)',
'teaser' => 'Kurztext',
'summary' => 'Zusammenfassung',
'brands' => 'Marken',
'projects' => 'Projekte',
'partners' => 'Partner',
'questions' => 'Fragen',
'question' => 'Frage',
'answer' => 'Antwort',
'read_more' => 'Weiterlesen-Text',
'empty_text' => 'Text, wenn nichts vorhanden ist',
'success_text' => 'Text nach dem Absenden',
'submit_text' => 'Beschriftung Absenden-Button',
'privacy_text' => 'Datenschutz-Hinweis',
'subjects' => 'Betreffe',
'subject' => 'Betreff',
],
/*
|--------------------------------------------------------------------------
| Seiten in der Reihenfolge des Menüs
|--------------------------------------------------------------------------
*/
'pages' => [
'home' => [
'title' => 'Startseite',
'path' => '/',
'sections' => [
['group' => 'home', 'key' => 'hero', 'anchor' => 'hero', 'title' => 'Hero',
'description' => 'Der große Einstieg ganz oben: Zeile über der Überschrift (eyebrow), Überschrift, Text, Button, Häkchen-Punkte (stats) und die Slideshow. Unter „slides“ je Bild: image, label (Marke) und caption (Motiv) – beides erscheint in der Karte über dem Bild und wechselt mit –, alt (Bildbeschreibung für Screenreader) und active (aus = ausgeblendet); die Reihenfolge der Liste ist die Reihenfolge der Slideshow. „interval“ = Sekunden pro Bild (3–30). card_title/card_text stehen in der Karte, wenn eine Slide weder label noch caption hat; image/image_alt sind das Ersatzbild, wenn keine Slide aktiv ist.'],
['group' => 'home', 'key' => 'founder_bar', 'anchor' => 'gruender', 'title' => 'Gründer-Leiste',
'description' => 'Schmale Zeile mit Marcel Scheibe und Zitat. Steht auch auf /marken, „Über uns“ und in der Immobilien-Welt.'],
['group' => 'home', 'key' => 'audience_switch', 'anchor' => 'wege', 'title' => 'Zielgruppen-Wahl',
'description' => 'Zwei Wege: Privat einrichten oder Projekt ausstatten.'],
['group' => 'home', 'key' => 'core_brands', 'anchor' => 'marken', 'title' => 'Kernmarken',
'description' => 'Überschrift und Untertitel des Marken-Rasters. Die Kacheln selbst kommen aus der Markenverwaltung.'],
['group' => 'home', 'key' => 'origin_section', 'anchor' => 'herkunft', 'title' => 'Aus der Region gefertigt',
'description' => 'Karte mit Bielefeld als Drehscheibe und der Ablauf-Block am Beispiel CABINET. Die Standorte pflegst du bei den Marken. Steht auch auf /marken.'],
['group' => 'home', 'key' => 'vision_section', 'anchor' => 'vision', 'title' => 'Gebaut auf Expertise',
'description' => 'Weiße Box auf Ocker: Überschrift, Absätze (paragraphs) und Porträt mit Bildunterschrift (image_caption).'],
['group' => 'home', 'key' => 'sponsoring', 'anchor' => 'engagement', 'title' => 'Regional verbunden',
'description' => 'Vereine, die B2in unterstützt: je Verein name, image (Logo), text und optional url – mit url öffnet die Kachel die Vereinsseite in einem neuen Tab.'],
['group' => 'referenzen', 'key' => 'display_feed', 'anchor' => 'displays', 'title' => 'Aktuelles aus dem Showroom',
'description' => '„Aktuelles aus unserem Showroom“: Überschrift, Text, Hinweis (note) und Link in der weißen Box. Das Display rechts kommt aus den Store Displays (display_id leer = das erste veröffentlichte), Besucher können es über „Größer ansehen“ vergrößern. Steht auch auf /referenzen.'],
['group' => 'home', 'key' => 'immobilien_teaser', 'anchor' => 'immobilien', 'title' => 'Immobilien-Teaser',
'description' => 'Der Übergang in die Immobilien-Welt am Ende der Startseite.'],
['group' => 'global', 'key' => 'cta_section', 'anchor' => 'cta', 'title' => 'Abschluss: Termin anfragen',
'description' => 'Der letzte Block vor dem Fuß auf der Startseite und auf /referenzen. Marken und „Über uns“ haben eigene Abschlüsse.'],
],
],
'marken' => [
'title' => 'Marken',
'path' => '/marken',
'sections' => [
['group' => 'marken', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Einleitung (text), Bild und die Häkchen-Punkte (stats) oben auf /marken. Die Markenblöcke darunter kommen aus der Markenverwaltung.'],
['group' => 'marken', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
'description' => 'Großes Bild zwischen den Markenblöcken und „Aus der Region gefertigt“, damit sich die beiden hellen Sektionen absetzen. Mit Zitat (quote) und Autor (author); ohne Zitat steht nur das Bild.'],
['group' => 'marken', 'key' => 'network', 'anchor' => 'netzwerk', 'title' => 'Mehr als Marken: unser Netzwerk',
'description' => 'Partner und Zulieferer jenseits der Kernmarken.'],
['group' => 'marken', 'key' => 'growth', 'anchor' => 'wachstum', 'title' => 'Die Markenwelt wächst',
'description' => 'Hinweis, dass weitere Marken folgen.'],
['group' => 'marken', 'key' => 'cta', 'anchor' => 'marken-cta', 'title' => 'Abschluss auf /marken',
'description' => 'Der Abschlussblock „Lernen Sie unsere Marken kennen“ mit Terminanfrage und WhatsApp.'],
['group' => 'marken', 'key' => 'craft', 'anchor' => 'handwerk', 'title' => 'Handwerk-Abschluss auf jeder Markenseite',
'description' => 'Der letzte Abschnitt auf den Seiten von CABINET, Rotpunkt und Febrü: „Planung plus Handwerk“ mit Link zu Otterpohl. Einmal gepflegt, überall gleich.', 'path' => '/marken/cabinet'],
],
],
'referenzen' => [
'title' => 'Referenzen',
'path' => '/referenzen',
'sections' => [
['group' => 'referenzen', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Überschrift, Einleitung und Bild der Referenzen-Seite. Ob die Seite (samt Menüpunkt) erscheint, schaltest du unter „Referenzen“.'],
['group' => 'referenzen', 'key' => 'projects', 'anchor' => 'projekte', 'title' => 'Realisierte Projekte',
'description' => 'Überschrift, Text und Leerzustand über den Projekten. Die Projekte selbst pflegst du unter „Referenzen“.'],
],
],
'magazin' => [
'title' => 'Magazin',
'path' => '/magazin',
'sections' => [
['group' => 'magazin', 'key' => 'list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht',
'description' => 'Überschrift und Einleitung der Übersicht. Die Beiträge pflegst du unter „Magazin“.'],
['group' => 'magazin', 'key' => 'detail', 'anchor' => null, 'title' => 'Beitragsseite',
'description' => 'Feste Texte auf jeder Beitragsseite, etwa „Zurück zur Übersicht“ und „Weitere Beiträge“.'],
],
],
'about' => [
'title' => 'Über uns',
'path' => '/about',
'sections' => [
['group' => 'about', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte (card_title, card_text).'],
['group' => 'about', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Unsere Geschichte',
'description' => 'Überschrift, Text (summary) und die Zeitleiste (timeline) zur Entstehung von B2in.'],
['group' => 'about', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
'description' => 'Großes Bild zwischen Geschichte und Werten, mit Zitat (quote) und Autor (author).'],
['group' => 'about', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Unsere Werte',
'description' => 'Die Werte-Kacheln.'],
['group' => 'about', 'key' => 'partner_cta', 'anchor' => 'cta', 'title' => 'Abschluss auf „Über uns“',
'description' => 'Der Abschlussblock am Ende von „Über uns“.'],
],
],
'contact' => [
'title' => 'Kontakt',
'path' => '/contact',
'sections' => [
['group' => 'contact', 'key' => 'contact_form', 'anchor' => 'kontakt', 'title' => 'Kontaktformular',
'description' => 'Kopf des Formulars (hero: eyebrow, title, subtitle), Betreffe, Feldbeschriftungen und Datenschutz-Hinweis.'],
['group' => 'global', 'key' => 'contact', 'anchor' => 'kontakt', 'title' => 'Kontaktdaten',
'description' => 'Telefon, E-Mail, Adresse und die WhatsApp-Nummer. Ohne WhatsApp-Nummer erscheint kein WhatsApp-Button.'],
],
],
'faq' => [
'title' => 'FAQ',
'path' => '/faq',
'sections' => [
['group' => 'faq', 'key' => 'faq', 'anchor' => 'faq', 'title' => 'Fragen und Antworten',
'description' => 'Überschrift und die Liste der Fragen der Interior-Welt.'],
],
],
'global' => [
'title' => 'Auf jeder Seite',
'path' => '/',
'sections' => [
['group' => 'global', 'key' => 'announcement_bar', 'anchor' => 'header', 'title' => 'Hinweisleiste',
'description' => 'Schmale Zeile ganz oben über dem Menü (text, badge, Link). „enabled“ schaltet sie ein. Zurzeit ist die Leiste für die ganze Website über B2IN_ANNOUNCEMENT_BAR in der .env aus – Änderungen hier erscheinen erst, wenn sie dort eingeschaltet ist.'],
['group' => 'global', 'key' => 'header', 'anchor' => 'header', 'title' => 'Hauptmenü',
'description' => 'Menüpunkte und Buttons im Kopf der Interior-Welt. Der Punkt „Referenzen“ erscheint nur, solange die Referenzen-Seite eingeschaltet ist.'],
['group' => 'global', 'key' => 'appointment_widget', 'anchor' => null, 'title' => 'Terminanfrage-Widget',
'description' => 'Das schwebende Widget rechts unten: Texte, Themen und Zeitfenster.'],
['group' => 'global', 'key' => 'footer', 'anchor' => 'footer', 'title' => 'Fußbereich',
'description' => 'Claim, Zeile unter dem Claim (byline) und die Linkliste (links) im Fuß.'],
],
],
'immobilien' => [
'title' => 'Immobilien-Welt',
'path' => '/immobilien',
'sections' => [
['group' => 'global', 'key' => 'announcement_bar_immobilien', 'anchor' => 'header', 'title' => 'Hinweisleiste (Immobilien)',
'description' => 'Hinweisleiste über dem Menü der Immobilien-Welt. Wie die Interior-Leiste zurzeit über B2IN_ANNOUNCEMENT_BAR ausgeschaltet.'],
['group' => 'global', 'key' => 'header_immobilien', 'anchor' => 'header', 'title' => 'Hauptmenü (Immobilien)',
'description' => 'Menüpunkte und Buttons im Kopf der Immobilien-Welt.'],
['group' => 'global', 'key' => 'cta_section_immobilien', 'anchor' => 'cta', 'title' => 'Abschluss (Immobilien)',
'description' => 'Der letzte Block vor dem Fuß auf allen Immobilien-Seiten.'],
['group' => 'immobilien', 'key' => 'magazin_list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht (Immobilien)',
'description' => 'Überschrift und Einleitung unter /immobilien/magazin.', 'path' => '/immobilien/magazin'],
['group' => 'immobilien', 'key' => 'about_hero', 'anchor' => 'seitenkopf', 'title' => 'Über B2in: Seitenkopf',
'description' => 'Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte unter /immobilien/ueber-b2in.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Über B2in: Geschichte',
'description' => 'Der Geschichte-Block der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'about_image_break', 'anchor' => 'bild', 'title' => 'Über B2in: Bildpause',
'description' => 'Bild zwischen Geschichte und Werten.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Über B2in: Werte',
'description' => 'Die Werte-Kacheln der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'faq', 'key' => 'faq_immobilien', 'anchor' => 'faq', 'title' => 'FAQ (Immobilien)',
'description' => 'Fragen und Antworten unter /immobilien/faq.', 'path' => '/immobilien/faq'],
['group' => 'contact', 'key' => 'contact_form_immobilien', 'anchor' => 'kontakt', 'title' => 'Kontaktformular (Immobilien)',
'description' => 'Abweichende Texte und Betreffe unter /immobilien/kontakt. Leere Felder übernehmen den Interior-Wert.', 'path' => '/immobilien/kontakt'],
],
],
'legal' => [
'title' => 'Rechtliches',
'path' => '/impressum',
'sections' => [
['group' => 'legal', 'key' => 'impressum', 'anchor' => null, 'title' => 'Impressum',
'description' => 'Pflichtangaben. Der Inhalt ist HTML.', 'path' => '/impressum'],
['group' => 'legal', 'key' => 'privacy', 'anchor' => null, 'title' => 'Datenschutz',
'description' => 'Datenschutzerklärung. Der Inhalt ist HTML.', 'path' => '/privacy'],
['group' => 'legal', 'key' => 'terms', 'anchor' => null, 'title' => 'Nutzungsbedingungen',
'description' => 'Nutzungsbedingungen der Website. Der Inhalt ist HTML.', 'path' => '/terms'],
['group' => 'legal', 'key' => 'cookie_policy', 'anchor' => null, 'title' => 'Cookie-Hinweise',
'description' => 'Cookie-Richtlinie. Der Inhalt ist HTML.', 'path' => '/cookie-policy'],
],
],
],
/*
|--------------------------------------------------------------------------
| Sammelpunkt für alles, was in der Datenbank liegt, aber oben nicht steht
|--------------------------------------------------------------------------
*/
'unlisted' => [
'title' => 'Nicht mehr verwendet',
'description' => 'Einträge in der Datenbank, die keine Seite anzeigt. Normalerweise leer – der UnusedCmsContentCleanupSeeder räumt solche Einträge ab.',
],
];

View file

@ -8,55 +8,84 @@ declare(strict_types=1);
* Wird von cms_theme_section() und CmsContentSeeder verwendet.
*/
return [
'lang_keys' => [
'card_section' => 'partner_card_section',
],
'sections' => [
'announcement_bar' => ['global', 'announcement_bar'],
'announcement_bar_immobilien' => ['global', 'announcement_bar_immobilien'],
'header' => ['global', 'header'],
'footer' => ['global', 'footer'],
'cta_section' => ['global', 'cta_section'],
'cta_section_immobilien' => ['global', 'cta_section_immobilien'],
'appointment_widget' => ['global', 'appointment_widget'],
'cta_section_portfolio' => ['global', 'cta_section_portfolio'],
'hero' => ['home', 'hero'],
'audience_switch' => ['home', 'audience_switch'],
'core_brands' => ['home', 'core_brands'],
'origin_section' => ['home', 'origin_section'],
'marken_hero' => ['marken', 'hero'],
'marken_image_break' => ['marken', 'image_break'],
'marken_network' => ['marken', 'network'],
'marken_growth' => ['marken', 'growth'],
'marken_cta' => ['marken', 'cta'],
'brand_craft' => ['marken', 'craft'],
'referenzen_hero' => ['referenzen', 'hero'],
'reference_projects' => ['referenzen', 'projects'],
'display_feed' => ['referenzen', 'display_feed'],
'sponsoring' => ['home', 'sponsoring'],
'immobilien_teaser' => ['home', 'immobilien_teaser'],
'founder_bar' => ['home', 'founder_bar'],
'synergie_section' => ['home', 'synergie_section'],
'ecosystem_core' => ['home', 'ecosystem_core'],
'vision_section' => ['home', 'vision_section'],
'brand_worlds' => ['home', 'brand_worlds'],
'integriertes_modell_b2in' => ['home', 'integriertes_modell_b2in'],
'immobilien_hero' => ['immobilien', 'hero'],
'immobilien_hero_v2' => ['immobilien', 'hero_v2'],
'immobilien_projects' => ['immobilien', 'projects_meta'],
'immobilien_moebel_vorteil' => ['immobilien', 'moebel_vorteil'],
'immobilien_trust' => ['immobilien', 'trust'],
'immobilien_warum_dubai' => ['immobilien', 'warum_dubai'],
'immobilien_image_break' => ['immobilien', 'image_break'],
'immobilien_kaufprozess' => ['immobilien', 'kaufprozess'],
'immobilien_bruecke' => ['immobilien', 'bruecke'],
'immobilien_mindset' => ['immobilien', 'mindset'],
'portfolio' => ['immobilien', 'portfolio'],
'netzwerk_hero' => ['netzwerk', 'hero'],
'netzwerk_image_break' => ['netzwerk', 'image_break'],
'netzwerk_teasers' => ['netzwerk', 'teasers'],
'netzwerk_cta' => ['netzwerk', 'cta'],
'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'],
'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'],
'interior_hero' => ['netzwerk', 'interior_hero'],
'interior_concept' => ['netzwerk', 'interior_concept'],
'interior_brands' => ['netzwerk', 'interior_brands'],
'interior_zielgruppen' => ['netzwerk', 'interior_zielgruppen'],
'interior_process' => ['netzwerk', 'interior_process'],
'interior_trust' => ['netzwerk', 'interior_trust'],
'faq' => ['faq', 'faq'],
'faq_immobilien' => ['faq', 'faq_immobilien'],
// Immobilien-Welt: eigene Navigation, eigenes Magazin, eigene Ueber-uns-Seite
'header_immobilien' => ['global', 'header_immobilien'],
'magazin_list_immobilien' => ['immobilien', 'magazin_list'],
'immobilien_about_hero' => ['immobilien', 'about_hero'],
'immobilien_our_story' => ['immobilien', 'our_story'],
'immobilien_about_image_break' => ['immobilien', 'about_image_break'],
'immobilien_our_values' => ['immobilien', 'our_values'],
'contact_form' => ['contact', 'contact_form'],
'contact_form_immobilien' => ['contact', 'contact_form_immobilien'],
'about_hero' => ['about', 'hero'],
'our_story' => ['about', 'our_story'],
'about_image_break' => ['about', 'image_break'],
'our_values' => ['about', 'our_values'],
'leadership_team' => ['about', 'leadership_team'],
'partner_cta' => ['about', 'partner_cta'],
'broker_section' => ['netzwerk', 'broker_section'],
'commitment_section' => ['netzwerk', 'commitment_section'],
'dark_stats_section' => ['netzwerk', 'dark_stats_section'],
'ecosystem_hero' => ['netzwerk', 'ecosystem_hero'],
'ecosystem_stats' => ['netzwerk', 'ecosystem_stats'],
'ecosystem_start' => ['netzwerk', 'ecosystem_start'],
'ecosystem_hub' => ['netzwerk', 'ecosystem_hub'],
'ecosystem_result' => ['netzwerk', 'ecosystem_result'],
'end_customer_section' => ['netzwerk', 'end_customer_section'],
'final_commitment' => ['netzwerk', 'final_commitment'],
'digital_core' => ['netzwerk', 'digital_core'],
'supply_chain_intro' => ['netzwerk', 'supply_chain_intro'],
'partner_hero' => ['netzwerk', 'partner_hero'],
'partner_benefits' => ['netzwerk', 'partner_benefits'],
'partner_cta' => ['netzwerk', 'partner_cta'],
'partner_card_section' => ['netzwerk', 'partner_card_section'],
'card_section' => ['netzwerk', 'partner_card_section'],
'partner_benefits_developer' => ['netzwerk', 'partner_benefits_developer'],
'partner_benefits_retailer' => ['netzwerk', 'partner_benefits_retailer'],
'partner_benefits_supplier' => ['netzwerk', 'partner_benefits_supplier'],
'partner_benefits_broker' => ['netzwerk', 'partner_benefits_broker'],
'partner_process' => ['netzwerk', 'partner_process'],
'supplier_section' => ['netzwerk', 'supplier_section'],
'magazin_detail' => ['magazin', 'detail'],
'magazin_list' => ['magazin', 'list'],

View file

@ -71,29 +71,13 @@ return [
'view_prefix' => 'b2in',
'assets_dir' => 'build/b2in',
'description' => 'Haupt-Landingpage mit Standard-Theme',
// Interior-Welt (Standard). Die Immobilien-Welt behält das bisherige Blau,
// siehe `worlds` und resources/css/web/theme-b2in.css.
'color_scheme' => [
'primary' => '#202c2b', // Tanne
'secondary' => '#a8741f', // Ocker
'primary' => '#2b3f51', // Anthracite
'secondary' => '#20a0da', // Dynamic Blue
],
'font_family' => [
'primary' => 'Inter',
'secondary' => 'EB Garamond',
],
'worlds' => [
'interior' => [
'primary' => '#202c2b', // Tanne – Hauptton dunkel, Wortmarke
'background' => '#f7f4ef', // Creme
'surface' => '#d9cbbf', // Sand – Flächen, Footer
'accent' => '#a8741f', // Ocker – Akzent, Logo-Bogen
'secondary' => '#a38f82', // Clay – Sekundär, Produkte
'text' => '#2d2926', // Anthrazit
],
'immobilien' => [
'primary' => '#2b3f51', // Anthracite
'accent' => '#20a0da', // Dynamic Blue
],
'secondary' => 'IBM Plex Sans',
],
],

View file

@ -52,13 +52,6 @@ return [
'channels' => [
'monitoring' => [
'driver' => 'daily',
'path' => storage_path('logs/monitoring.log'),
'level' => 'debug',
'days' => 14,
],
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),

View file

@ -1,75 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Betriebs-Alerts
|--------------------------------------------------------------------------
|
| Fehlgeschlagene Scheduler-Tasks und Queue-Jobs werden immer als
| `critical` ins Log geschrieben (rotierter `daily`-Channel) und – wenn
| aktiviert und eine Adresse gesetzt ist – zusätzlich per Best-Effort-Mail
| gemeldet. Die Mail ist bewusst nur die zweite Ebene: schlägt der Versand
| fehl, bleibt der Log-Eintrag als verlässliche Spur bestehen.
|
*/
'alerts' => [
'enabled' => (bool) env('MONITORING_ALERTS_ENABLED', true),
// Ops-Postfach. Leer = keine Alert-Mail (Log und ntfy laufen trotzdem).
'mail_to' => env('MONITORING_ALERT_EMAIL'),
// Zweiter Alarmweg: Push über ntfy, JSON-POST
// an die Basis-URL. Ohne URL oder Topic aus.
'ntfy' => [
'url' => env('MONITORING_NTFY_URL'),
'topic' => env('MONITORING_NTFY_TOPIC'),
'token' => env('MONITORING_NTFY_TOKEN'),
'priority' => (int) env('MONITORING_NTFY_PRIORITY', 4),
],
],
/*
|--------------------------------------------------------------------------
| Browserfehler
|--------------------------------------------------------------------------
|
| Eigenes GlitchTip-Projekt für JavaScript-Fehler. Die DSN ist öffentlich
| (steht im HTML), sie erlaubt nur das Einliefern von Ereignissen. Ohne
| DSN wird kein Skript eingebunden.
|
*/
'browser' => [
'dsn' => env('SENTRY_BROWSER_DSN'),
'sample_rate' => (float) env('SENTRY_BROWSER_SAMPLE_RATE', 1.0),
],
/*
|--------------------------------------------------------------------------
| Health-Endpunkt und Scheduler-Herzschlag
|--------------------------------------------------------------------------
|
| `/health` antwortet nur mit gültigem Token (Bearer-Header oder
| `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht (404).
| Der Scheduler schreibt jede Minute einen Herzschlag; bleibt er aus, ist
| der Cron-Eintrag weg oder der Server hängt.
|
*/
'health' => [
'token' => env('MONITORING_HEALTH_TOKEN'),
'queue_backlog_critical_seconds' => (int) env('MONITORING_QUEUE_BACKLOG_SECONDS', 300),
'disk_min_free_percent' => (float) env('MONITORING_DISK_MIN_FREE_PERCENT', 10),
],
'scheduler' => [
// Leer = storage/framework/scheduler-heartbeat. Die Tests setzen je Prozess eine eigene Datei.
'heartbeat_path' => env('MONITORING_HEARTBEAT_PATH'),
'heartbeat_warn_minutes' => 3,
'heartbeat_critical_minutes' => 10,
],
];

View file

@ -1,21 +0,0 @@
<?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

@ -1,180 +0,0 @@
<?php
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\Release;
/**
* Sentry Laravel SDK configuration file.
*
* Ziel ist das selbst gehostete GlitchTip, das das
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
* Paket-Standard: kein Personenbezug außer der User-ID, keine
* Request-Bodys, keine Log-/Cache-Breadcrumbs (Lognachrichten tragen bei uns
* E-Mail-Adressen), keine Metriken (versteht GlitchTip nicht).
*
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
*/
return [
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
// @see https://spotlightjs.com/
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
// The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
// Gleiche Kennung wie config('app.version'): SENTRY_RELEASE bzw.
// APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE.
'release' => Release::current(env('SENTRY_RELEASE', env('APP_VERSION'))),
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
'environment' => env('SENTRY_ENVIRONMENT'),
// Override the organization ID used for trace continuation checks.
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics
'enable_metrics' => env('SENTRY_ENABLE_METRICS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
'send_default_pii' => false,
// Formular- und JSON-Inhalte verlassen den Server nie.
'max_request_body_size' => 'never',
// User-ID statt Person, Tokens aus URL/Query/Referer, E-Mails aus Meldungen.
'before_send' => [ErrorReportScrubber::class, 'beforeSend'],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
// 'ignore_exceptions' => [],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
'ignore_transactions' => [
// Ignore Laravel's default health URL
'/up',
'/health',
],
// Breadcrumb specific configuration
'breadcrumbs' => [
// Capture Laravel logs as breadcrumbs
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', false),
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', false),
// Capture Livewire components like routes as breadcrumbs
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
// Capture SQL queries as breadcrumbs
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
// Capture queue job information as breadcrumbs
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
// Capture command information as breadcrumbs
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
// Capture HTTP client request information as breadcrumbs
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture send notifications as breadcrumbs
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
],
// Performance monitoring specific configuration
'tracing' => [
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
// Capture queue jobs as spans when executed on the sync driver
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
// Capture SQL queries as spans
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query spans
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
// Capture where the SQL query originated from on the SQL query spans
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
// Define a threshold in milliseconds for SQL queries to resolve their origin
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
// Capture views rendered as spans
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
// Capture Livewire components as spans
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
// Capture HTTP client requests as spans
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as spans
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
// Capture Redis operations as spans (this enables Redis events in Laravel)
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
// Capture where the Redis command originated from on the Redis command spans
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
// Capture send notifications as spans
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
// Enable tracing for requests without a matching route (404's)
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
// Capture AI agent interactions as spans (requires laravel/ai)
'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true),
// Capture AI invoke_agent spans
'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true),
// Capture AI chat spans
'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true),
// Capture AI execute_tool spans
'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true),
// Capture AI embeddings spans
'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true),
// Enable the tracing integrations supplied by Sentry (recommended)
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
],
];

View file

@ -23,7 +23,6 @@ class CmsArticleFactory extends Factory
'subtitle' => fake()->sentence(12),
'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg',
'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']),
'world' => CmsArticle::WORLD_INTERIOR,
'date_label' => fake()->date('F j, Y'),
'read_time' => fake()->numberBetween(3, 10).' min read',
'author' => [

View file

@ -1,70 +0,0 @@
<?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

@ -1,62 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\CmsReference;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CmsReference>
*/
class CmsReferenceFactory extends Factory
{
protected $model = CmsReference::class;
public function definition(): array
{
$title = fake()->unique()->words(3, true);
return [
'title' => ['de' => ucfirst($title), 'en' => ucfirst($title)],
'location' => fake()->city(),
'year' => (string) fake()->numberBetween(2022, 2026),
'description' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
'image' => 'referenzen/'.fake()->unique()->slug(2).'.webp',
'gallery' => [],
'brand_slugs' => [],
'is_published' => true,
'order' => fake()->numberBetween(1, 20),
];
}
public function published(): static
{
return $this->state(fn () => ['is_published' => true]);
}
public function unpublished(): static
{
return $this->state(fn () => ['is_published' => false]);
}
/**
* @param list<string> $slugs
*/
public function forBrands(array $slugs): static
{
return $this->state(fn () => ['brand_slugs' => $slugs]);
}
public function withGallery(int $count = 2): static
{
return $this->state(function () use ($count) {
$items = [];
for ($i = 1; $i <= $count; $i++) {
$items[] = ['image' => 'referenzen/bild-'.$i.'.webp', 'caption' => 'Bild '.$i];
}
return ['gallery' => $items];
});
}
}

View file

@ -1,40 +0,0 @@
<?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

@ -1,33 +0,0 @@
<?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

@ -1,42 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ausführliche Markenseiten nach den Zulieferungen vom 31.08.2026.
*
* Die gelieferten Texte (Rotpunkt, Febrü) bestehen aus mehreren Abschnitten, von denen einige
* eine Vertiefung mitbringen, die laut Umsetzungshinweis aufklappbar dargestellt werden soll.
* Dafür reichen `description` (Einleitung) und `services` (Kacheln) nicht aus.
*
* Ergänzt werden:
* - `sections` Abschnitte der Seite, je mit Titel, Absätzen und optionaler Vertiefung
* - `widget_label` Beschriftung des schwebenden Terminanfrage-Widgets auf dieser Markenseite
* - `cta_title` Überschrift des Abschlussblocks
* - `cta_text` Text des Abschlussblocks
*
* `sections` ist – wie `services`, `highlights` und `gallery` – bewusst nicht übersetzbar:
* Die Texte liegen nur auf Deutsch vor. Die drei Textfelder folgen den übrigen Textfeldern
* und sind übersetzbar.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->json('sections')->nullable()->after('services');
$table->json('widget_label')->nullable()->after('sections');
$table->json('cta_title')->nullable()->after('widget_label');
$table->json('cta_text')->nullable()->after('cta_title');
});
}
public function down(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->dropColumn(['sections', 'widget_label', 'cta_title', 'cta_text']);
});
}
};

View file

@ -1,34 +0,0 @@
<?php
use App\Models\CmsArticle;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Magazinbeiträge gehören zu einer Farbwelt (Interior oder Immobilien).
*
* Die drei Dubai-Beiträge aus dem Bestand werden direkt der Immobilien-Welt zugeordnet,
* damit sie nach der Trennung nicht in der Interior-Liste stehen.
*/
public function up(): void
{
Schema::table('cms_articles', function (Blueprint $table) {
$table->string('world', 20)->default(CmsArticle::WORLD_INTERIOR)->index()->after('category');
});
CmsArticle::query()
->whereIn('slug', CmsArticle::LEGACY_IMMOBILIEN_SLUGS)
->update(['world' => CmsArticle::WORLD_IMMOBILIEN]);
}
public function down(): void
{
Schema::table('cms_articles', function (Blueprint $table) {
$table->dropIndex(['world']);
$table->dropColumn('world');
});
}
};

View file

@ -1,37 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Fertigungsort je Kernmarke (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
*
* Die Herkunfts-Sektion „Aus der Region gefertigt“ holt ihre Standorte nicht mehr aus einem
* JSON-Eintrag im generischen Inhalte-Editor, sondern aus der Markenverwaltung: Marcel pflegt
* Ort, Entfernung und Rolle direkt bei der Marke. Die Kartenpositionen bleiben im Code.
*
* - `origin_city` Fertigungsort, z. B. „Bünde“ – leer bedeutet: Marke erscheint nicht in der Sektion
* - `origin_distance` Entfernung ab Bielefeld als Text, z. B. „rund 30 km“
* - `origin_role` Ein Satz zur Rolle des Standorts
*
* Alle drei sind übersetzbar wie die übrigen Textfelder der Marke.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->json('origin_city')->nullable()->after('showroom');
$table->json('origin_distance')->nullable()->after('origin_city');
$table->json('origin_role')->nullable()->after('origin_distance');
});
}
public function down(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->dropColumn(['origin_city', 'origin_distance', 'origin_role']);
});
}
};

View file

@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Referenzprojekte der Interior-Welt (Nachtrag 13).
*
* AP5 hatte die Referenzen aus den Markengalerien zusammengesammelt, um keine zweite Pflegestelle
* zu schaffen. Abstimmung 15.09.: Marcel soll Referenzen als eigene Einträge pflegen – ein
* realisiertes Projekt hat mehr als ein Bild und mehr als eine Marke. Solange kein Eintrag
* existiert, greift das Frontend weiter auf die Markengalerien zurück.
*
* - `title`, `description` übersetzbar
* - `location`, `year` Ort und Jahr als Text („Bielefeld“, „2025“)
* - `image` Titelbild (Dateiname aus der Medienbibliothek)
* - `gallery` weitere Bilder mit Bildunterschrift
* - `brand_slugs` beteiligte Kernmarken (Slugs aus cms_brands), verlinkt auf die Markenseiten
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('cms_references', function (Blueprint $table) {
$table->id();
$table->json('title');
$table->string('location')->nullable();
$table->string('year', 20)->nullable();
$table->json('description')->nullable();
$table->string('image')->nullable();
$table->json('gallery')->nullable();
$table->json('brand_slugs')->nullable();
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('cms_references');
}
};

View file

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

View file

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

View file

@ -1,84 +0,0 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Projekttext auf der Startseite (Briefing Marcel 24.09.2026, Punkt 7; Nachtrag 19).
*
* In der Karte „Ausstattung für Ihr Projekt“ (home/audience_switch) wird „Mit deutscher
* Vertragssicherheit …“ durch „Mit einem zentralen deutschen Vertragspartner …“ ersetzt.
* Abstimmung 01.10.2026: Nach „Projektentwicklungen“ steht ein Punkt statt des Gedankenstrichs.
* Der Seeder tauscht nur genau diesen Satz, egal wo er im Sektions-JSON steht; alles andere
* bleibt. Steht der Satz nicht mehr drin, passiert nichts. Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=AudienceSwitchProjectTextSeeder
*/
class AudienceSwitchProjectTextSeeder extends Seeder
{
/** @var array<string, string> alter Satz → neuer Satz */
public const REPLACEMENTS = [
'Mit deutscher Vertragssicherheit und einem eingespielten Herstellernetzwerk.' => 'Mit einem zentralen deutschen Vertragspartner und einem eingespielten Herstellernetzwerk.',
'With German contractual reliability and an established manufacturer network.' => 'With a single German contract partner and an established manufacturer network.',
'Projektentwicklungen – koordiniert aus einer Hand' => 'Projektentwicklungen. Koordiniert aus einer Hand',
'developments – coordinated from a single source' => 'developments. Coordinated from a single source',
];
public function run(): void
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'audience_switch')->first();
if (! $row) {
$this->command?->info('Projekttext: Sektion home/audience_switch nicht im CMS – die Lang-Datei gilt.');
return;
}
$value = $row->getTranslations('value');
$count = 0;
$value = $this->replaceDeep($value, $count);
if ($count === 0) {
$this->command?->info('Projekttext: bereits aktuell, nichts geändert.');
return;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache('home');
$this->command?->info("Projekttext ersetzt ({$count} Stelle(n)).");
}
/**
* @param array<mixed> $data
* @return array<mixed>
*/
private function replaceDeep(array $data, int &$count): array
{
foreach ($data as $key => $item) {
if (is_array($item)) {
$data[$key] = $this->replaceDeep($item, $count);
continue;
}
if (! is_string($item)) {
continue;
}
$replaced = strtr($item, self::REPLACEMENTS);
if ($replaced !== $item) {
$data[$key] = $replaced;
$count++;
}
}
return $data;
}
}

View file

@ -1,64 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Entfernt den Vermerk „BEISPIEL – “ aus vorhandenen Galerie-Bildunterschriften.
*
* Der Vermerk stammt aus {@see DemoBrandGallerySeeder} und ist auf Kundenwunsch entfallen. Weil
* die Bilder auf Vorschau- und Livesystem bereits in der Datenbank stehen, reicht die Änderung am
* Demo-Seeder nicht aus – der wuerde beim erneuten Ausfuehren die gesamte Galerie ueberschreiben
* und damit gepflegte Bilder verlieren.
*
* Dieser Seeder fasst deshalb ausschliesslich den Text der Bildunterschrift an. Bilder, Kategorien
* und Reihenfolge bleiben unveraendert. Mehrfaches Ausfuehren ist unbedenklich.
*
* php artisan db:seed --class=BrandGalleryCaptionNormalizerSeeder
*/
class BrandGalleryCaptionNormalizerSeeder extends Seeder
{
/** Praefixe, die vorne aus der Bildunterschrift entfernt werden. */
private const PREFIXES = ['BEISPIEL – ', 'BEISPIEL - ', 'BEISPIEL: ', 'BEISPIEL '];
public function run(): void
{
$cleaned = 0;
foreach (CmsBrand::query()->whereNotNull('gallery')->get() as $brand) {
$gallery = $brand->gallery ?? [];
$changed = false;
foreach ($gallery as $index => $item) {
$caption = (string) ($item['caption'] ?? '');
$stripped = $this->stripPrefix($caption);
if ($stripped !== $caption) {
$gallery[$index]['caption'] = $stripped;
$changed = true;
$cleaned++;
}
}
if ($changed) {
$brand->gallery = CmsBrand::normalizeGallery($gallery);
$brand->save();
}
}
$this->command?->info("Bildunterschriften bereinigt: {$cleaned}.");
}
private function stripPrefix(string $caption): string
{
foreach (self::PREFIXES as $prefix) {
if (str_starts_with($caption, $prefix)) {
return trim(substr($caption, strlen($prefix)));
}
}
return $caption;
}
}

View file

@ -1,162 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Trägt Titelbild und Galerie für CABINET, Rotpunkt, Febrü (Nachtrag 16) und Otterpohl (Nachtrag 19) nach.
*
* Grundlage: dev/2026-08-28/images/ (Zulieferungen vom 22.–24.09.2026). Die Originale sind 1–8 MB
* große JPEGs; unter public/img/assets/marken/{cabinet,rotpunkt,februe,otterpohl}/ liegen sie als
* WebP mit maximal 1920 px Kantenlänge (Imagick, Qualität 82, ohne Metadaten). Die mit `_title_`
* markierte Datei ist das Titelbild (Seitenkopf der Markenseite, Kachel auf der Startseite,
* Markenblock auf /marken). Die Galeriebilder der drei Hersteller stehen als **Visualisierung**:
* es sind Herstellerdarstellungen, keine von uns ausgeführten Projekte – unter „Referenzen“
* erscheinen sie damit nicht. Bei Otterpohl sind die Bilder 2–5 Einblicke in Werkstatt und
* Montage (**Aus der Werkstatt**, dazu das frühere Titelbild), alle übrigen ausgeführte Aufträge (**Realisiertes Projekt**);
* letztere erscheinen unter „Referenzen“, solange dort keine eigenen Einträge gepflegt sind.
*
* Verhalten: Das Titelbild wird nur gesetzt, wenn keins gepflegt ist. Galeriebilder werden
* nur ergänzt, wenn die Datei noch nicht in der Galerie steht; vorhandene Einträge bleiben
* samt Kategorie und Bildunterschrift, wie Marcel sie gepflegt hat. Mehrfaches Ausführen ist
* unbedenklich.
*
* php artisan db:seed --class=BrandImageSeeder
*/
class BrandImageSeeder extends Seeder
{
/**
* Galerie: Dateiname → Bildunterschrift (Kategorie Visualisierung) oder
* Dateiname → ['caption' => …, 'category' => …] für eine andere Kategorie.
*
* @var array<string, array{image: string, gallery: array<string, string|array{caption: string, category: string}>}>
*/
private const IMAGES = [
'cabinet' => [
'image' => 'marken/cabinet/ankleide-solitaer-eiche-tano.webp',
'gallery' => [
'marken/cabinet/dachschraegenschrank-filo-cashmere.webp' => 'Dachschrägenschrank in Filo Cashmere',
'marken/cabinet/schrank-gegen-schraege-marmor.webp' => 'Schrank gegen die Schräge in Marmoroptik',
'marken/cabinet/schlafzimmer-eiche-tano.webp' => 'Schlafzimmerschrank in Eiche Tano',
'marken/cabinet/schlafzimmer-velluto-fango.webp' => 'Schlafzimmerschrank in Velluto Fango',
'marken/cabinet/schlafzimmer-wandschrank-filo-cashmere.webp' => 'Wandschrank im Schlafzimmer in Filo Cashmere',
'marken/cabinet/garderobe-seite-offen.webp' => 'Garderobe mit offener Seite',
'marken/cabinet/garderobe-sitznische-filo-cashmere.webp' => 'Garderobe mit Sitznische in Filo Cashmere',
'marken/cabinet/drehtuerenschrank-marmor.webp' => 'Drehtürenschrank in Marmoroptik',
'marken/cabinet/flur-schrank-eiche-tano.webp' => 'Flurschrank in Eiche Tano',
'marken/cabinet/dachschraege-velluto-fango.webp' => 'Schrank unter der Dachschräge in Velluto Fango',
'marken/cabinet/ankleide-solitaer-eiche-tano-seite.webp' => 'Ankleide Solitär in Eiche Tano, Seitenansicht',
],
],
'rotpunkt' => [
'image' => 'marken/rotpunkt/kueche-01.webp',
'gallery' => [
'marken/rotpunkt/kueche-01-b.webp' => 'Küche 01, zweite Ansicht',
'marken/rotpunkt/kueche-02.webp' => 'Küche 02',
'marken/rotpunkt/kueche-03.webp' => 'Küche 03',
'marken/rotpunkt/kueche-04.webp' => 'Küche 04',
'marken/rotpunkt/kueche-05.webp' => 'Küche 05',
'marken/rotpunkt/kueche-06.webp' => 'Küche 06',
'marken/rotpunkt/kueche-06-b.webp' => 'Küche 06, zweite Ansicht',
'marken/rotpunkt/kueche-07.webp' => 'Küche 07',
'marken/rotpunkt/kueche-08.webp' => 'Küche 08',
'marken/rotpunkt/kueche-09.webp' => 'Küche 09',
'marken/rotpunkt/kueche-10.webp' => 'Küche 10',
'marken/rotpunkt/kueche-11.webp' => 'Küche 11',
'marken/rotpunkt/kueche-12.webp' => 'Küche 12',
],
],
// Bildunterschriften nach den Febrü-Produktlinien aus den Dateinamen.
'februe' => [
'image' => 'marken/februe/base-thekenloesung.webp',
'gallery' => [
'marken/februe/active-2-0-bureo.webp' => 'Active 2.0 mit Bureo',
'marken/februe/active-2-0-soul-grossraum.webp' => 'Großraum mit Active 2.0 und Soul',
'marken/februe/adesso.webp' => 'Adesso',
'marken/februe/arena-stage.webp' => 'Arena Stage',
'marken/februe/base.webp' => 'Base',
'marken/februe/base-sideboard.webp' => 'Base Sideboard',
'marken/februe/bobb-bionics-coworker-container-cube.webp' => 'Bobb, Bionics, CoWorker, Container und Cube',
'marken/februe/bobb.webp' => 'Bobb',
'marken/februe/brandon.webp' => 'Brandon',
'marken/februe/coworker.webp' => 'CoWorker',
'marken/februe/corporate.webp' => 'Corporate',
'marken/februe/curved-ben-basic.webp' => 'Curved, Ben und Basic',
'marken/februe/eline-one-silent-garderobenpaneel.webp' => 'E-Line, One, Silent und Garderobenpaneel',
'marken/februe/elements-soul-grossraum.webp' => 'Großraum mit Elements und Soul',
'marken/februe/vio.webp' => 'Vio',
],
],
// Otterpohl (Zulieferung 24.09.): Werkstattbilder und Referenzen. Seit 01.10. ist die Treppe mit
// Lamellenwand (tittle_otter.png) das Titelbild, das frühere Werkstatt-Titelbild steht in der
// Galerie – {@see OtterpohlBrandSeeder::swapTitleImage()} tauscht bestehende Installationen.
// Unterschriften ohne Kundennamen – die stehen nur in den Dateinamen der Zulieferung.
'tischlerei' => [
'image' => OtterpohlBrandSeeder::TITLE_IMAGE,
'gallery' => [
'marken/otterpohl/referenz-dziuballe-treppe.webp' => ['caption' => 'Treppe mit Holzstufen, Stufenbeleuchtung und Eichenhandlauf', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-luecke-12.webp' => ['caption' => 'Tür und Kommode vor Lamellenwand', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-neugebauer.webp' => ['caption' => 'Gewendelte Treppe mit Eichenstufen', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-luecke-15.webp' => ['caption' => 'Beleuchtete Wandregale über Eck', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-berkemeier-treppe.webp' => ['caption' => 'Treppe mit umlaufender Sockelleiste und Lichtdetail', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-objektbau-luecke-technik.webp' => ['caption' => 'Objektbau: Verkaufsraum mit Theke und Regalwand', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-otterpohl-11.webp' => ['caption' => 'Sideboard mit Eichenplatte im Büro', 'category' => CmsBrand::GALLERY_PROJECT],
OtterpohlBrandSeeder::FORMER_TITLE_IMAGE => ['caption' => OtterpohlBrandSeeder::FORMER_TITLE_CAPTION, 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-02.webp' => ['caption' => 'CNC-Bearbeitungszentrum in der Fertigung', 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-03.webp' => ['caption' => 'Einbau einer Tür vor Ort', 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-04.webp' => ['caption' => 'Handwerkzeug in der Werkstatt', 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-05-lift.webp' => ['caption' => 'Anlieferung per Möbellift – Montage inklusive', 'category' => CmsBrand::GALLERY_COMPANY],
],
],
];
public function run(): void
{
foreach (self::IMAGES as $slug => $data) {
$brand = CmsBrand::query()->where('slug', $slug)->first();
if (! $brand) {
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
continue;
}
$titleSet = false;
if (trim((string) $brand->image) === '') {
$brand->image = $data['image'];
$titleSet = true;
}
$gallery = CmsBrand::normalizeGallery($brand->gallery);
$known = array_column($gallery, 'image');
$added = 0;
foreach ($data['gallery'] as $image => $entry) {
if (in_array($image, $known, true)) {
continue;
}
$gallery[] = [
'image' => $image,
'category' => is_array($entry) ? $entry['category'] : CmsBrand::GALLERY_INSPIRATION,
'caption' => is_array($entry) ? $entry['caption'] : $entry,
];
$added++;
}
$brand->gallery = $gallery;
$brand->save();
$this->command?->info(sprintf(
'Marke „%s“: Titelbild %s, %d Galeriebilder ergänzt, %d vorhanden.',
$slug,
$titleSet ? 'gesetzt' : 'behalten',
$added,
count($gallery) - $added,
));
}
}
}

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