Compare commits

..

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

800 changed files with 4893 additions and 48469 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. # 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 # Hinweis: Migrationen müssen mindestens einmal für die Test-DB ausgeführt werden: DB_DATABASE=testing sail artisan migrate
# TESTING_PRESERVE_DATABASE=false # 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=

31
.gitignore vendored
View file

@ -1,17 +1,12 @@
# Laravel # Laravel
/.phpunit.cache /.phpunit.cache
/node_modules /node_modules
# public/build ist versioniert (Git-Deploy: auf dem Server gibt es kein Node). Nur die /public/build
# Ausgabe des alten Standard-Builds (`npm run build`) liegt direkt darunter und wird nicht gebraucht.
/public/build/assets/
/public/build/manifest.json
/public/hot /public/hot
/public/storage /public/storage
/public/vendor /public/vendor
/public/flux /public/flux
/storage/*.key /storage/*.key
# schreibt dev/betrieb/deploy.sh beim Deploy (Version des ausgelieferten Tags)
/RELEASE
/storage/app /storage/app
/storage/framework /storage/framework
/storage/language /storage/language
@ -55,31 +50,11 @@ yarn-error.log
Network Trash Folder Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
# macOS-Ordnersymbol heißt „Icon\r“ – ohne [\r] träfe das Muster bei core.ignorecase auch resources/views/flux/icon/ Icon
Icon[\r]
# Project specific # Project specific
_static/ _static/
_work/ _work/
_storage/ _storage/
dev/immobilien 07-05-2026/AZIZI SharePoint dev/immobilien 07-05-2026/AZIZI SharePoint
# Deployment-Archive werden bei Bedarf neu erzeugt
dev/**/deploy/*.tar.gz
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
/_b2in_interior_shop/
/docs/
# Original-Bildzulieferungen (mehrere MB je Datei) bleiben ausserhalb der Versionierung;
# die optimierten WebP-Fassungen liegen unter public/img/assets/marken/.
/dev/2026-08-28/images/
# 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: Domain-specific configurations include:
- **portal.b2in.test**: Admin backend with Flux UI theme - **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 - **b2a.test**: Azur Blue/Liberty Red color scheme, Inter/Merriweather fonts
- **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts - **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts
- **style2own.test**: Imperial Blue/Sand Gold color scheme, Inter/EB Garamond 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 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 ## Important Files
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide - `DOMAINS-CONFIG.md`: Detailed domain configuration guide
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup 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 ## 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. 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/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0

View file

@ -1,105 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Console\Command;
/**
* Findet Inhalte, die auf der englischen Seite deutsch erscheinen (09.10.2026): CMS-Sektionen,
* Marken (auch Merkmale, Leistungen, Abschnitte, Showroom, Bildunterschriften) und Magazin.
* Erkennung über typische deutsche Wörter – Eigennamen wie „Möbelservice“ allein schlagen nicht an.
*/
class CmsUntranslated extends Command
{
protected $signature = 'cms:untranslated {--locale=en : Zielsprache}';
protected $description = 'Listet CMS-Inhalte, die in der Zielsprache fehlen oder noch deutsch sind';
private const GermanWords = '/\b(und|der|die|das|für|mit|nicht|wir|Sie|Ihr|Ihre|Ihnen|ist|auf|ein|eine|vom|zum|zur|bei|aus|oder|auch|sich|werden|wird|unser|unsere|dem|den|des|im|sind|alle|mehr|über|wie|nach|nur|noch)\b/u';
public function handle(): int
{
$locale = (string) $this->option('locale');
$findings = [];
foreach (CmsContent::query()->orderBy('group')->orderBy('key')->get() as $row) {
$translations = $row->getTranslations('value');
$label = "CMS {$row->group}.{$row->key}";
if (! array_key_exists($locale, $translations)) {
$findings[] = [$label, 'keine Übersetzung'];
continue;
}
$this->collect($translations[$locale], $label, $findings);
}
foreach (CmsBrand::query()->ordered()->get() as $brand) {
foreach ($brand->translatable as $attribute) {
$translations = $brand->getTranslations($attribute);
if (filled($translations[CmsBrand::BASE_LOCALE] ?? null) && blank($translations[$locale] ?? null)) {
$findings[] = ["Marke {$brand->slug}.{$attribute}", 'keine Übersetzung'];
} else {
$this->collect($translations[$locale] ?? null, "Marke {$brand->slug}.{$attribute}", $findings);
}
}
foreach (CmsBrand::LOCALIZED_FIELDS as $field) {
$this->collect($brand->localized($field, $locale), "Marke {$brand->slug}.{$field}", $findings);
}
}
foreach (CmsArticle::query()->get() as $article) {
foreach ($article->translatable as $attribute) {
$translations = $article->getTranslations($attribute);
$label = "Magazin {$article->slug}.{$attribute}";
if (filled($translations[CmsBrand::BASE_LOCALE] ?? null) && blank($translations[$locale] ?? null)) {
$findings[] = [$label, 'keine Übersetzung'];
} else {
$this->collect($translations[$locale] ?? null, $label, $findings);
}
}
}
if ($findings === []) {
$this->info("Keine deutschen oder fehlenden Texte für „{$locale}“ gefunden.");
return self::SUCCESS;
}
$this->table(['Stelle', 'Text'], $findings);
$this->warn(count($findings).' Stellen.');
return self::FAILURE;
}
/**
* @param list<array{0: string, 1: string}> $findings
*/
private function collect(mixed $value, string $path, array &$findings): void
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$this->collect($item, "{$path}.{$key}", $findings);
}
return;
}
if (! is_string($value)) {
return;
}
$text = trim(html_entity_decode(strip_tags($value)));
if ($text !== '' && preg_match_all(self::GermanWords, $text) >= 2) {
$findings[] = [$path, mb_strimwidth($text, 0, 90, '…')];
}
}
}

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

@ -1,36 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Erlaubt die Live-Vorschau im Inhalte-Editor des Portals (09.10.2026).
*
* Der Editor auf portal.b2in.eu zeigt die Website in einem iframe. Der Webserver schickt
* `X-Frame-Options: SAMEORIGIN`, das jede andere Domain ausschließt – also auch das Portal.
* `Content-Security-Policy: frame-ancestors` löst X-Frame-Options ab: Browser werten dann nur
* noch diese Liste aus. Erlaubt sind die eigene Domain und das Portal, alle anderen Seiten
* dürfen die Website weiterhin nicht einbetten.
*/
class AllowPortalFraming
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response->headers->has('Content-Security-Policy')) {
return $response;
}
$portal = rtrim((string) config('domains.domain_portal_url'), '/');
$ancestors = trim("'self' {$portal}");
$response->headers->set('Content-Security-Policy', "frame-ancestors {$ancestors}");
$response->headers->remove('X-Frame-Options');
return $response;
}
}

View file

@ -32,12 +32,6 @@ class BasicAuthMiddleware
return $next($request); 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.) // Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
if (str_starts_with($path, 'flux/')) { if (str_starts_with($path, 'flux/')) {
return $next($request); 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. * @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
*
* @return array<int, string>
*/
private function hourProperties(): array
{
return [
'hoursMondayOpen', 'hoursMondayClose',
'hoursTuesdayOpen', 'hoursTuesdayClose',
'hoursWednesdayOpen', 'hoursWednesdayClose',
'hoursThursdayOpen', 'hoursThursdayClose',
'hoursFridayOpen', 'hoursFridayClose',
'hoursSaturdayOpen', 'hoursSaturdayClose',
'hoursSundayOpen', 'hoursSundayClose',
];
}
/**
* Der Flux-Zeit-Picker meldet das Leeren per X nicht zuverlaessig an wire:model.
* Deshalb liest das Formular die Werte beim Absenden aus dem DOM und uebergibt sie hier.
* Nur die Zeitfelder duerfen so gesetzt werden – alles andere kommt aus wire:model.
*
* @param array<string, string|null> $hours Zeit-Picker-Werte aus dem DOM
*/ */
public function save(array $hours = []): void public function save(array $hours = []): void
{ {
foreach ($this->hourProperties() as $prop) { foreach ($hours as $prop => $value) {
if (array_key_exists($prop, $hours)) { if (property_exists($this, $prop)) {
$this->{$prop} = is_string($hours[$prop]) ? $hours[$prop] : ''; $this->{$prop} = $value ?? '';
} }
} }

View file

@ -53,8 +53,7 @@ class DisplayVersionEditor extends Component
public string $mediaSubline = ''; public string $mediaSubline = '';
/** Anzeigedauer eines Bildes in Sekunden als Eingabetext, Komma oder Punkt erlaubt („1,5“). */ public int $mediaDuration = 10;
public string $mediaDuration = '10';
public bool $mediaIsActive = true; public bool $mediaIsActive = true;
@ -248,12 +247,6 @@ class DisplayVersionEditor extends Component
public function saveItem(): void 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(); $content = $this->buildItemContent();
$isActive = $this->getActiveFlag(); $isActive = $this->getActiveFlag();
@ -286,41 +279,6 @@ class DisplayVersionEditor extends Component
$this->refreshModulePreview(); $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 public function deleteItem(int $id): void
{ {
DisplayVersionItem::findOrFail($id)->delete(); DisplayVersionItem::findOrFail($id)->delete();
@ -479,7 +437,7 @@ class DisplayVersionEditor extends Component
$this->mediaUrl = $content['media_url'] ?? ''; $this->mediaUrl = $content['media_url'] ?? '';
$this->mediaHeadline = $content['headline'] ?? ''; $this->mediaHeadline = $content['headline'] ?? '';
$this->mediaSubline = $content['subline'] ?? ''; $this->mediaSubline = $content['subline'] ?? '';
$this->mediaDuration = self::formatDuration($content['duration_seconds'] ?? 10); $this->mediaDuration = $content['duration_seconds'] ?? 10;
$this->mediaIsActive = $isActive; $this->mediaIsActive = $isActive;
} }
@ -543,7 +501,7 @@ class DisplayVersionEditor extends Component
'media_url' => $this->mediaUrl, 'media_url' => $this->mediaUrl,
'headline' => $this->mediaHeadline, 'headline' => $this->mediaHeadline,
'subline' => $this->mediaSubline, 'subline' => $this->mediaSubline,
'duration_seconds' => self::parseDuration($this->mediaDuration) ?? 10, 'duration_seconds' => $this->mediaDuration,
], ],
'slide' => [ 'slide' => [
'type' => 'detail', 'type' => 'detail',
@ -705,7 +663,7 @@ class DisplayVersionEditor extends Component
$this->mediaUrl = ''; $this->mediaUrl = '';
$this->mediaHeadline = ''; $this->mediaHeadline = '';
$this->mediaSubline = ''; $this->mediaSubline = '';
$this->mediaDuration = '10'; $this->mediaDuration = 10;
$this->mediaIsActive = true; $this->mediaIsActive = true;
$this->slideShowLogo = true; $this->slideShowLogo = true;
$this->slideLogoUrl = ''; $this->slideLogoUrl = '';

View file

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

@ -20,84 +20,8 @@ class FAQ extends Component
$this->content = cms_theme_section($this->section); $this->content = cms_theme_section($this->section);
} }
/**
* Fragen nach Bereich gruppiert, in Seitenreihenfolge: zuerst die allgemeinen Fragen ohne
* Bereich, dann die Bereiche aus `categories` (Überschrift + Symbol), zuletzt Bereiche, die nur
* an Fragen stehen. Leere Bereiche entfallen.
*
* @param array<string, mixed> $content
* @return list<array{title: ?string, icon: ?string, questions: list<array<string, mixed>>}>
*/
public static function groups(array $content): array
{
$content = self::flatten($content);
$groups = ['' => ['title' => null, 'icon' => null, 'questions' => []]];
foreach ($content['categories'] ?? [] as $category) {
$title = trim((string) ($category['title'] ?? ''));
if ($title !== '') {
$groups[$title] = ['title' => $title, 'icon' => ($category['icon'] ?? '') ?: 'question-mark-circle', 'questions' => []];
}
}
foreach ($content['questions'] ?? [] as $question) {
if (! is_array($question) || trim((string) ($question['question'] ?? '')) === '') {
continue;
}
$title = trim((string) ($question['category'] ?? ''));
$groups[$title] ??= ['title' => $title, 'icon' => 'question-mark-circle', 'questions' => []];
$groups[$title]['questions'][] = $question;
}
return array_values(array_filter($groups, fn (array $group): bool => $group['questions'] !== []));
}
/**
* Wandelt die frühere Form mit verschachtelten `sections` (Bereich mit eigener Fragenliste) in
* die flache Form um, die der Inhalte-Editor bearbeiten kann (09.10.2026): jede Frage trägt
* ihren Bereich in `category`, Überschrift und Symbol stehen in `categories`.
*
* @param array<string, mixed> $content
* @return array<string, mixed>
*/
public static function flatten(array $content): array
{
if (! isset($content['sections']) || ! is_array($content['sections'])) {
return $content;
}
$questions = array_map(
fn (mixed $question): mixed => is_array($question) ? [...$question, 'category' => $question['category'] ?? ''] : $question,
is_array($content['questions'] ?? null) ? $content['questions'] : [],
);
$categories = is_array($content['categories'] ?? null) ? $content['categories'] : [];
foreach ($content['sections'] as $section) {
if (! is_array($section)) {
continue;
}
$title = trim((string) ($section['title'] ?? ''));
$categories[] = ['title' => $title, 'icon' => (string) ($section['icon'] ?? '')];
foreach ($section['questions'] ?? [] as $question) {
if (is_array($question)) {
$questions[] = [...$question, 'category' => $title];
}
}
}
unset($content['sections']);
$content['questions'] = $questions;
$content['categories'] = $categories;
return $content;
}
public function render() public function render()
{ {
return view('livewire.web.components.sections.f-a-q', ['groups' => self::groups($this->content)]); return view('livewire.web.components.sections.f-a-q');
} }
} }

View file

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

View file

@ -4,86 +4,16 @@ namespace App\Livewire\Web\Components\Sections;
use Livewire\Component; 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 class Hero extends Component
{ {
/** @var array<string, mixed> */ public $content = [];
public array $content = [];
/** @var array<int, array{image: string, alt: string, label: string, caption: string}> */ public function mount()
public array $slides = [];
public function mount(): void
{ {
$section = cms_theme_section('hero'); $this->content = cms_theme_section('hero');
$this->content = is_array($section) ? $section : [];
$this->slides = self::activeSlides($this->content['slides'] ?? []) ?: $this->singleImage();
} }
/** public function render()
* 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
{ {
return view('livewire.web.components.sections.hero'); return view('livewire.web.components.sections.hero');
} }

View file

@ -4,10 +4,6 @@ namespace App\Livewire\Web\Components\Sections;
use Livewire\Component; 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 class HeroSlider extends Component
{ {
public $content = []; public $content = [];
@ -16,8 +12,7 @@ class HeroSlider extends Component
public function mount() public function mount()
{ {
$section = cms_theme_section('hero_slider'); $this->content = cms_theme_section('hero_slider');
$this->content = is_array($section) ? $section : [];
} }
public function nextSlide() 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; namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsArticle;
use Livewire\Component; use Livewire\Component;
class MagazinDetail extends Component class MagazinDetail extends Component
{ {
public $slug; public $articleId;
public $article; public $article;
@ -15,41 +14,25 @@ class MagazinDetail extends Component
public $content; public $content;
/** Rueckweg zur Magazinliste der Welt, zu der der Beitrag gehoert. */ public function mount($id = 1)
public string $listUrl = '';
public function mount($slug)
{ {
$this->slug = (string) $slug; $this->articleId = (int) $id;
$this->loadArticle(); $this->loadArticle();
$this->listUrl = $this->articleWorld() === CmsArticle::WORLD_IMMOBILIEN
? route('immobilien.magazin')
: route('magazin');
$this->loadRelatedArticles(); $this->loadRelatedArticles();
$this->loadThemeContent(); $this->loadThemeContent();
} }
private function articleWorld(): string
{
return (string) ($this->article['world'] ?? CmsArticle::WORLD_INTERIOR);
}
private function loadArticle() private function loadArticle()
{ {
$articles = $this->getArticlesData(); $articles = $this->getArticlesData();
$this->article = $articles[$this->articleId] ?? $articles[1];
// Unbekannte Slugs haben frueher stillschweigend Artikel 1 ausgeliefert – mit Status 200.
// Das erzeugt Duplicate Content unter beliebig vielen URLs.
abort_unless(isset($articles[$this->slug]), 404);
$this->article = $articles[$this->slug];
} }
private function loadRelatedArticles() private function loadRelatedArticles()
{ {
$this->relatedArticles = collect($this->getArticlesData()) $articles = $this->getArticlesData();
->filter(fn (array $article) => ($article['world'] ?? CmsArticle::WORLD_INTERIOR) === $this->articleWorld()) $this->relatedArticles = collect($articles)
->reject(fn (array $article, string $slug) => $slug === $this->slug) ->filter(fn ($article, $key) => $key != $this->articleId)
->take(2) ->take(2)
->values() ->values()
->toArray(); ->toArray();
@ -60,18 +43,9 @@ class MagazinDetail extends Component
$this->content = cms_theme_section('magazin_detail'); $this->content = cms_theme_section('magazin_detail');
} }
/** @var array<string, array<string, mixed>>|null */ private function getArticlesData()
private ?array $articlesCache = null;
/**
* Wird beim Rendern zweimal gebraucht (Artikel + verwandte Artikel) – deshalb gemerkt,
* damit die Detailseite nur eine Abfrage auslöst.
*
* @return array<string, array<string, mixed>>
*/
private function getArticlesData(): array
{ {
return $this->articlesCache ??= CmsArticle::forFrontend(); return trans('b2in.articles');
} }
public function render() public function render()

View file

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

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 = []; public $content = [];
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_story). */ public function mount()
public string $section = 'our_story';
public function mount(string $section = 'our_story')
{ {
$this->section = $section; $this->content = cms_theme_section('our_story');
$this->content = cms_theme_section($this->section);
} }
public function render() public function render()

View file

@ -8,13 +8,9 @@ class OurValues extends Component
{ {
public $content = []; public $content = [];
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_values). */ public function mount()
public string $section = 'our_values';
public function mount(string $section = 'our_values')
{ {
$this->section = $section; $this->content = cms_theme_section('our_values');
$this->content = cms_theme_section($this->section);
} }
public function render() 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'; public $bg = 'bg-background';
/** Inhalt in weißer Box mit kleinerem Bild (Startseite, 07.10.2026); die anderen Themes bleiben offen. */ public function mount($bg = 'bg-background')
public bool $boxed = false;
public function mount($bg = 'bg-background', bool $boxed = false)
{ {
$this->bg = $bg; $this->bg = $bg;
$this->boxed = $boxed;
$this->content = cms_theme_section('vision_section'); $this->content = cms_theme_section('vision_section');
} }

View file

@ -0,0 +1,20 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class AnnouncementBar extends Component
{
public array $content = [];
public function mount(): void
{
$this->content = cms_theme_section('announcement_bar');
}
public function render()
{
return view('livewire.web.components.ui.announcement-bar');
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use Livewire\Component;
class ContactForm extends Component
{
public string $firstName = '';
public string $lastName = '';
public string $company = '';
public string $email = '';
public string $phone = '';
public string $subject = '';
public string $message = '';
public bool $privacy = false;
public bool $success = false;
/** @var string Hidden honeypot field */
public string $website = '';
public ?int $formLoadedAt = null;
/** @var array<string, mixed> */
public array $content = [];
public function mount(): void
{
$this->content = cms_theme_section('contact_form');
$this->formLoadedAt = time();
}
/** @return array<string, string> */
public function getSubjectsProperty(): array
{
return $this->content['form']['subjects'] ?? [];
}
/** @return array<int, array<string, mixed>> */
public function getContactInfoProperty(): array
{
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'],
'message' => ['required', 'string', 'max:2000'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:255'],
'privacy' => ['accepted'],
'website' => ['nullable', 'string', 'max:0'],
]);
$spamDetector = SpamDetector::fromConfig();
$payload = [
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'email' => $this->email,
'subject' => $this->subject,
'message' => $this->message,
'company' => $this->company,
'phone' => $this->phone,
'website' => $this->website,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => false,
];
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
$subjectLine = $subjectLabel
? 'Kontaktanfrage B2in: ' . $subjectLabel
: __('contact-form::contact-form.default_subject');
$service->handle($payload, $subjectLine, 'contact-form');
$this->success = true;
$this->firstName = '';
$this->lastName = '';
$this->company = '';
$this->email = '';
$this->phone = '';
$this->subject = '';
$this->message = '';
$this->privacy = false;
$this->website = '';
$this->formLoadedAt = time();
}
public function render(): \Illuminate\View\View
{
return view('livewire.web.components.ui.contact-form');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class Footer extends Component
{
public function render()
{
return view('livewire.web.components.ui.footer');
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class Header extends Component
{
public $isMobileMenuOpen = false;
public $domainName;
public $domainUrl;
public $content = [];
public $currentLocale;
public $availableLocales = [
'de' => 'DE',
'en' => 'EN',
];
public function mount(): void
{
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
$this->currentLocale = app()->getLocale();
$this->content = cms_theme_section('header');
// Ensure required keys exist
if (!isset($this->content['portal_login'])) {
$this->content['portal_login'] = 'Portal Login';
}
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
$this->content['navigation'] = [];
}
}
public function switchLanguage(string $locale): mixed
{
if (array_key_exists($locale, $this->availableLocales)) {
app()->setLocale($locale);
session(['locale' => $locale]);
$this->currentLocale = $locale;
$referer = request()->header('Referer') ?? '/';
return redirect()->to($referer);
}
return null;
}
public function toggleMobileMenu(): void
{
$this->isMobileMenuOpen = ! $this->isMobileMenuOpen;
}
public function closeMobileMenu(): void
{
$this->isMobileMenuOpen = false;
}
public function isActiveRoute($url)
{
$currentPath = request()->path();
$currentPath = '/' . ltrim($currentPath, '/');
// Exact match
if ($currentPath === $url) {
return true;
}
// Special handling for home route
if ($url === '/' && ($currentPath === '/' || $currentPath === '')) {
return true;
}
// Check if current path starts with the navigation URL (for sub-pages)
if ($url !== '/' && str_starts_with($currentPath, rtrim($url, '/'))) {
return true;
}
return false;
}
public function render()
{
return view('livewire.web.components.ui.header');
}
}

View file

@ -1,46 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
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);
}
public function render()
{
return view('livewire.web.components.ui.announcement-bar');
}
}

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

@ -1,205 +0,0 @@
<?php
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
{
public string $firstName = '';
public string $lastName = '';
public string $company = '';
public string $email = '';
public string $phone = '';
public string $subject = '';
public string $message = '';
public bool $privacy = false;
public bool $success = false;
/** @var string Hidden honeypot field */
public string $website = '';
public ?int $formLoadedAt = null;
/** @var array<string, mixed> */
public array $content = [];
/** Farbwelt der Seite (`interior` | `immobilien`). */
public string $world = 'interior';
public function mount(string $world = 'interior'): void
{
$this->world = $world === 'immobilien' ? 'immobilien' : 'interior';
$this->content = $this->loadContent();
$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>
*/
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 array<int, array<string, mixed>> */
public function getContactInfoProperty(): array
{
return $this->content['contact_info'] ?? [];
}
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))],
'message' => ['required', 'string', 'max:2000'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:255'],
'privacy' => ['accepted'],
'website' => ['nullable', 'string', 'max:0'],
]);
$spamDetector = SpamDetector::fromConfig();
$payload = [
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'email' => $this->email,
'subject' => $this->subject,
'message' => $this->message,
'company' => $this->company,
'phone' => $this->phone,
'website' => $this->website,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => false,
];
$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
: __('contact-form::contact-form.default_subject');
$service->handle($payload, $subjectLine, 'contact-form');
$this->success = true;
$this->firstName = '';
$this->lastName = '';
$this->company = '';
$this->email = '';
$this->phone = '';
$this->subject = '';
$this->message = '';
$this->privacy = false;
$this->website = '';
$this->formLoadedAt = time();
}
public function render(): \Illuminate\View\View
{
return view('livewire.web.components.ui.contact-form');
}
}

View file

@ -1,69 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
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

@ -1,130 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class Header extends Component
{
public $isMobileMenuOpen = false;
public $domainName;
public $domainUrl;
public $content = [];
public $currentLocale;
public $availableLocales = [
'de' => 'DE',
'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');
// Ensure required keys exist
if (!isset($this->content['portal_login'])) {
$this->content['portal_login'] = 'Portal Login';
}
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
{
if (array_key_exists($locale, $this->availableLocales)) {
app()->setLocale($locale);
session(['locale' => $locale]);
$this->currentLocale = $locale;
$referer = request()->header('Referer') ?? '/';
return redirect()->to($referer);
}
return null;
}
public function toggleMobileMenu(): void
{
$this->isMobileMenuOpen = ! $this->isMobileMenuOpen;
}
public function closeMobileMenu(): void
{
$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
{
$path = $this->navPath((string) $url);
if ($this->currentPath === $path) {
return true;
}
if ($path === '/' || ! str_starts_with($this->currentPath, rtrim($path, '/').'/')) {
return false;
}
foreach ($this->content['navigation'] ?? [] as $navItem) {
$other = $this->navPath((string) ($navItem['url'] ?? ''));
if ($other === $this->currentPath) {
return false;
}
if (strlen($other) > strlen($path) && str_starts_with($this->currentPath, rtrim($other, '/').'/')) {
return false;
}
}
return true;
}
private function navPath(string $url): string
{
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
return '/'.ltrim($path, '/');
}
public function render()
{
return view('livewire.web.components.ui.header');
}
}

View file

@ -13,47 +13,12 @@ class CmsArticle extends Model
protected $table = 'cms_articles'; 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 = [ protected $fillable = [
'slug', 'slug',
'title', 'title',
'subtitle', 'subtitle',
'image', 'image',
'category', 'category',
'world',
'date_label', 'date_label',
'read_time', 'read_time',
'author', 'author',
@ -88,104 +53,6 @@ class CmsArticle extends Model
return $query->orderBy('order')->orderByDesc('created_at'); 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> * @return array<string, mixed>
*/ */
@ -198,7 +65,6 @@ class CmsArticle extends Model
'subtitle' => $this->subtitle, 'subtitle' => $this->subtitle,
'image' => $this->image, 'image' => $this->image,
'category' => $this->category, 'category' => $this->category,
'world' => $this->world ?? self::WORLD_INTERIOR,
'date' => $this->date_label, 'date' => $this->date_label,
'readTime' => $this->read_time, 'readTime' => $this->read_time,
'author' => $this->author ?? [], 'author' => $this->author ?? [],

View file

@ -1,481 +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];
/** Sprache der Spalten highlights, services, sections, showroom und der Bildunterschriften. */
public const BASE_LOCALE = 'de';
/**
* Felder, die nur einsprachig als Spalte existieren und ihre Übersetzungen in `field_translations`
* tragen (09.10.2026). `gallery_captions` sind die Bildunterschriften, nach Bild-Datei.
*/
public const LOCALIZED_FIELDS = ['highlights', 'services', 'sections', 'showroom', 'gallery_captions'];
protected $table = 'cms_brands';
protected $fillable = [
'slug',
'name',
'tagline',
'teaser',
'summary',
'highlights',
'description',
'logo',
'image',
'services',
'sections',
'widget_label',
'cta_title',
'cta_text',
'gallery',
'field_translations',
'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',
'field_translations' => '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>
*/
/**
* Ein einsprachiges Feld in der gewünschten Sprache: die Übersetzung aus `field_translations`,
* sonst die deutsche Spalte. `gallery_captions` liefert Bild-Datei => Unterschrift.
*/
public function localized(string $field, ?string $locale = null): mixed
{
$locale ??= app()->getLocale();
$base = $field === 'gallery_captions'
? collect(self::normalizeGallery($this->gallery))->mapWithKeys(fn (array $item) => [$item['image'] => $item['caption']])->all()
: $this->getAttribute($field);
if ($locale === self::BASE_LOCALE) {
return $base;
}
$translated = $this->field_translations[$locale][$field] ?? null;
if ($field === 'gallery_captions') {
foreach ($base as $image => $caption) {
if (filled($translated[$image] ?? null)) {
$base[$image] = (string) $translated[$image];
}
}
return $base;
}
return filled($translated) ? $translated : $base;
}
/**
* Schreibt ein einsprachiges Feld: Deutsch in die Spalte, andere Sprachen nach `field_translations`.
* Eine Übersetzung, die genau dem deutschen Stand entspricht, wird nicht gespeichert – sonst
* gälte deutscher Text als übersetzt.
*/
public function setLocalized(string $field, string $locale, mixed $value): void
{
if ($locale === self::BASE_LOCALE) {
if ($field !== 'gallery_captions') {
$this->setAttribute($field, $value);
}
return;
}
$translations = $this->field_translations ?? [];
$base = $this->localized($field, self::BASE_LOCALE);
if ($field === 'gallery_captions') {
$value = array_filter(
(array) $value,
fn (mixed $caption, string $image): bool => filled($caption) && $caption !== ($base[$image] ?? null),
ARRAY_FILTER_USE_BOTH,
);
}
if (blank($value) || $value === $base) {
unset($translations[$locale][$field]);
} else {
$translations[$locale][$field] = $value;
}
$translations = array_filter($translations, fn (mixed $fields): bool => filled($fields));
$this->field_translations = $translations === [] ? null : $translations;
}
/**
* Bildergalerie in der gewünschten Sprache (Bilder und Rubriken sind sprachunabhängig).
*
* @return list<array{image: string, category: string, caption: string}>
*/
public function localizedGallery(?string $locale = null): array
{
$captions = $this->localized('gallery_captions', $locale);
return array_map(
fn (array $item): array => [...$item, 'caption' => (string) ($captions[$item['image']] ?? $item['caption'])],
self::normalizeGallery($this->gallery),
);
}
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->localized('highlights') ?? [],
'description' => $this->description,
'logo' => $this->logo,
'image' => $this->image,
'services' => $this->localized('services') ?? [],
'sections' => self::normalizeSections($this->localized('sections')),
'widget_label' => (string) $this->widget_label,
'cta_title' => (string) $this->cta_title,
'cta_text' => (string) $this->cta_text,
'gallery' => $this->localizedGallery(),
'website_url' => $this->website_url,
'showroom' => $this->localized('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; 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\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -33,17 +29,5 @@ class AppServiceProvider extends ServiceProvider
if ($scheme === 'https') { if ($scheme === 'https') {
URL::forceScheme('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]); $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 // Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden // Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
$assetUrl = $domainConfig['url']; $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

@ -1,170 +0,0 @@
<?php
namespace App\Services\Monitoring;
use App\Support\Monitoring\ErrorReportScrubber;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
/**
* Daten für die Monitoring-Seite im Admin (09.10.2026): dieselben Prüfungen wie `/health`, dazu
* Version, geplante Aufgaben, fehlgeschlagene Jobs und die letzten Fehler und Alarme aus den
* Logs. Liest nur, ändert nichts; Logs nur das Ende der Datei, damit die Seite schnell bleibt.
*/
class SystemStatus
{
/** Zeitzone für die Anzeige – die App rechnet in UTC. */
public const DisplayTimezone = 'Europe/Berlin';
private const LogTailBytes = 262144;
private const LogLinePattern = '/^\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.*)$/';
public function __construct(private readonly HealthProbe $probe) {}
/**
* @return array{status: string, checks: list<array{key: string, label: string, status: string, detail: string}>}
*/
public function health(): array
{
$checks = $this->probe->checks();
return ['status' => HealthProbe::worstStatus($checks), 'checks' => $checks];
}
/**
* @return array{version: string, environment: string, debug: bool, php: string, laravel: string, deployed_at: ?Carbon}
*/
public function application(): array
{
$release = base_path('RELEASE');
return [
'version' => (string) (config('app.version') ?? '—'),
'environment' => app()->environment(),
'debug' => (bool) config('app.debug'),
'php' => PHP_VERSION,
'laravel' => app()->version(),
'deployed_at' => is_file($release) ? Carbon::createFromTimestamp((int) filemtime($release))->timezone(self::DisplayTimezone) : null,
];
}
/**
* Geplante Aufgaben mit nächstem Lauf (über `schedule:list`, das die Konsolen-Routen lädt).
*
* @return list<array{command: string, expression: string, next: ?Carbon, description: string}>
*/
public function scheduledTasks(): array
{
try {
Artisan::call('schedule:list', ['--json' => true]);
$tasks = json_decode(Artisan::output(), true);
} catch (Throwable) {
return [];
}
return collect(is_array($tasks) ? $tasks : [])
->map(fn (array $task): array => [
'command' => trim((string) preg_replace('/^.*?artisan\s+/', '', (string) ($task['command'] ?? ''))),
'expression' => (string) ($task['expression'] ?? ''),
'next' => filled($task['next_due_date'] ?? null) ? Carbon::parse($task['next_due_date'])->timezone(self::DisplayTimezone) : null,
'description' => (string) ($task['description'] ?? ''),
])
->values()
->all();
}
/**
* @return array{count: int, latest: list<array{job: string, queue: string, failed_at: Carbon, error: string}>}
*/
public function failedJobs(int $limit = 5): array
{
try {
$table = DB::table((string) config('queue.failed.table', 'failed_jobs'));
return [
'count' => $table->count(),
'latest' => $table->orderByDesc('failed_at')->limit($limit)->get()
->map(fn (object $job): array => [
'job' => (string) (json_decode((string) $job->payload, true)['displayName'] ?? 'Job'),
'queue' => (string) $job->queue,
'failed_at' => Carbon::parse($job->failed_at)->timezone(self::DisplayTimezone),
'error' => ErrorReportScrubber::scrubText(Str::limit(strtok((string) $job->exception, "\n") ?: '', 180)),
])
->all(),
];
} catch (Throwable) {
return ['count' => 0, 'latest' => []];
}
}
/**
* Letzte Einträge ab einer Stufe aus dem jüngsten Log einer Art (`laravel`, `monitoring`).
*
* @param list<string> $levels
* @return list<array{at: Carbon, level: string, message: string}>
*/
public function recentLogEntries(string $prefix, array $levels, int $limit = 10): array
{
$files = glob(storage_path("logs/{$prefix}*.log")) ?: [];
usort($files, fn (string $a, string $b): int => filemtime($b) <=> filemtime($a));
$file = $files[0] ?? null;
if ($file === null) {
return [];
}
$handle = @fopen($file, 'rb');
if ($handle === false) {
return [];
}
$size = (int) filesize($file);
fseek($handle, max(0, $size - self::LogTailBytes));
$tail = (string) stream_get_contents($handle);
fclose($handle);
$entries = [];
foreach (preg_split('/\R/', $tail) ?: [] as $line) {
if (preg_match(self::LogLinePattern, $line, $match) !== 1 || ! in_array(strtoupper($match[2]), $levels, true)) {
continue;
}
$message = (string) preg_replace('/\s*\{"(exception|userId)".*$/s', '', $match[3]);
$entries[] = [
'at' => Carbon::parse($match[1])->timezone(self::DisplayTimezone),
'level' => strtolower($match[2]),
'message' => ErrorReportScrubber::scrubText(Str::limit($message, 220)),
];
}
return array_slice(array_reverse($entries), 0, $limit);
}
/**
* Links zu den Diensten außerhalb der App, soweit konfiguriert.
*
* @return array<string, string>
*/
public function links(): array
{
$links = [];
$dsn = (string) config('sentry.dsn');
if (preg_match('#^https?://[^@]+@([^/]+)/(\d+)#', $dsn, $match) === 1) {
$links['GlitchTip'] = "https://{$match[1]}/";
}
if (filled(config('monitoring.status_url'))) {
$links['Uptime Kuma'] = (string) config('monitoring.status_url');
}
return $links;
}
}

View file

@ -27,10 +27,8 @@ class DisplayModuleSettings
], ],
DisplayVersionType::B2in->value => [ DisplayVersionType::B2in->value => [
'theme' => 'dark', 'theme' => 'dark',
// Standard-Theme ist dunkel, das Logo liegt dort auf einem dunklen Scrim – 'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
// deshalb die negative (weisse) Variante. 'header_claim' => 'Connecting Design & Property',
'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg',
'header_claim' => 'Premium-Einrichtung. Persönlich gemacht.',
'logo_position' => 'top-left', 'logo_position' => 'top-left',
'claim_position' => 'top-right', 'claim_position' => 'top-right',
'show_logo' => true, '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 <?php
use App\Models\Setting;
use FluxCms\Core\Models\CmsMedia; use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\CmsContentService; use FluxCms\Core\Services\CmsContentService;
@ -49,6 +48,8 @@ if (! function_exists('cms_theme_section')) {
/** @var array<string, array{0: string, 1: string}> $sections */ /** @var array<string, array{0: string, 1: string}> $sections */
$sections = config('cms_section_map.sections', []); $sections = config('cms_section_map.sections', []);
/** @var array<string, string> $langKeys */
$langKeys = config('cms_section_map.lang_keys', []);
if (isset($sections[$sectionKey])) { if (isset($sections[$sectionKey])) {
[$group, $key] = $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')) { return trans("b2in.themes.{$theme}.{$langKey}");
/**
* 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;
} }
} }
@ -131,13 +94,6 @@ if (! function_exists('theme_image_url')) {
return asset('storage/'.$trimmed); 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)]))); $candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)])));
foreach ($candidates as $candidate) { foreach ($candidates as $candidate) {
if (CmsMedia::query()->where('filename', $candidate)->exists()) { 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')) { if (! function_exists('media_url')) {
/** /**
* Resolve a CmsMedia filename to its full storage URL. * Resolve a CmsMedia filename to its full storage URL.
* Cached per request to avoid repeated DB queries. * Uses in-memory cache 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).
*/ */
function media_url(?string $filename, string $profile = ''): string function media_url(?string $filename, string $profile = ''): string
{ {
@ -182,80 +153,27 @@ if (! function_exists('media_url')) {
return ''; return '';
} }
// once(): je Request (und je Test) zwischengespeichert, nicht über die ganze Prozesslaufzeit. static $resolved = [];
return once(function () use ($filename, $profile): string { $cacheKey = $filename.'|'.$profile;
$media = CmsMedia::where('filename', $filename)->first();
if (! $media) { if (isset($resolved[$cacheKey])) {
return asset('img/assets/'.ltrim($filename, '/')); return $resolved[$cacheKey];
}
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');
} }
$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'))) { if (! $media) {
return 'immobilien'; $resolved[$cacheKey] = asset('assets/images/'.$filename);
return $resolved[$cacheKey];
} }
return 'interior'; if ($profile && $media->hasConversion($profile)) {
} $resolved[$cacheKey] = $media->getConversionUrl($profile);
} } else {
$resolved[$cacheKey] = $media->getUrl();
if (! function_exists('b2in_worlds')) { }
/**
* Die beiden Welten für den Welten-Wechsler in Kopf- und Fußzeile. return $resolved[$cacheKey];
*
* @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);
} }
} }

View file

@ -1,25 +1,14 @@
<?php <?php
use App\Http\Controllers\HealthController;
use App\Http\Middleware\ReportErrorSignals;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; 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__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/domains.php', web: __DIR__.'/../routes/domains.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', 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) { ->withMiddleware(function (Middleware $middleware) {
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers // Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
@ -38,23 +27,11 @@ return Application::configure(basePath: dirname(__DIR__))
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class, '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);
// Inhalte-Editor im Portal zeigt die Website im iframe: frame-ancestors statt SAMEORIGIN
$middleware->append(\App\Http\Middleware\AllowPortalFraming::class);
// BasicAuth ganz am Ende, nach Session-Middleware // BasicAuth ganz am Ende, nach Session-Middleware
if (env('BASIC_AUTH_ENABLED', true)) { if (env('BASIC_AUTH_ENABLED', true)) {
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class); $middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
} }
}) })
->withExceptions(function (Exceptions $exceptions) { ->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(); })->create();

View file

@ -22,7 +22,6 @@
"livewire/flux": "^2.1.1", "livewire/flux": "^2.1.1",
"livewire/flux-pro": "^2.6", "livewire/flux-pro": "^2.6",
"livewire/volt": "^1.7.0", "livewire/volt": "^1.7.0",
"sentry/sentry-laravel": "^4.29",
"spatie/laravel-permission": "^6.17" "spatie/laravel-permission": "^6.17"
}, },
"require-dev": { "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f2a8196dfd6ef735f3e827805a8a1f94", "content-hash": "9e07d437a063526b426b920d400db8b8",
"packages": [ "packages": [
{ {
"name": "acme/contact-form", "name": "acme/contact-form",
"version": "dev-feature/b2in-interior", "version": "dev-master",
"dist": { "dist": {
"type": "path", "type": "path",
"url": "packages/acme/contact-form", "url": "packages/acme/contact-form",
@ -55,7 +55,7 @@
}, },
{ {
"name": "acme/cookie-consent", "name": "acme/cookie-consent",
"version": "dev-feature/b2in-interior", "version": "dev-master",
"dist": { "dist": {
"type": "path", "type": "path",
"url": "packages/acme/CookieConsent", "url": "packages/acme/CookieConsent",
@ -860,7 +860,7 @@
}, },
{ {
"name": "flux-cms/core", "name": "flux-cms/core",
"version": "dev-feature/b2in-interior", "version": "dev-master",
"dist": { "dist": {
"type": "path", "type": "path",
"url": "packages/flux-cms/core", "url": "packages/flux-cms/core",
@ -1603,66 +1603,6 @@
], ],
"time": "2026-02-19T13:11:17+00:00" "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", "name": "laravel/fortify",
"version": "v1.35.0", "version": "v1.35.0",
@ -3553,84 +3493,6 @@
], ],
"time": "2026-02-16T23:10:27+00:00" "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", "name": "paragonie/constant_time_encoding",
"version": "v3.1.3", "version": "v3.1.3",
@ -4516,195 +4378,6 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "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", "name": "spatie/laravel-package-tools",
"version": "1.93.0", "version": "1.93.0",
@ -5928,77 +5601,6 @@
], ],
"time": "2026-02-05T15:57:06+00:00" "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", "name": "symfony/polyfill-ctype",
"version": "v1.33.0", "version": "v1.33.0",
@ -6893,93 +6495,6 @@
], ],
"time": "2026-01-26T15:07:59+00:00" "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", "name": "symfony/routing",
"version": "v7.4.6", "version": "v7.4.6",
@ -8452,6 +7967,66 @@
}, },
"time": "2025-04-30T06:54:44+00:00" "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", "name": "laravel/boost",
"version": "v1.8.11", "version": "v1.8.11",

View file

@ -15,18 +15,6 @@ return [
'name' => env('APP_NAME', 'Laravel'), '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 | Application Environment
@ -53,34 +41,6 @@ return [
'debug' => (bool) env('APP_DEBUG', false), '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 | 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 alle Fragen der Seite. „category“ ordnet eine Frage einem Bereich zu (leer = allgemeine Fragen oben); Überschrift und Symbol der Bereiche stehen in „categories“, ihre Reihenfolge ist die auf der Seite.'],
],
],
'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. * Wird von cms_theme_section() und CmsContentSeeder verwendet.
*/ */
return [ return [
'lang_keys' => [
'card_section' => 'partner_card_section',
],
'sections' => [ 'sections' => [
'announcement_bar' => ['global', 'announcement_bar'], 'announcement_bar' => ['global', 'announcement_bar'],
'announcement_bar_immobilien' => ['global', 'announcement_bar_immobilien'],
'header' => ['global', 'header'], 'header' => ['global', 'header'],
'footer' => ['global', 'footer'],
'cta_section' => ['global', 'cta_section'], 'cta_section' => ['global', 'cta_section'],
'cta_section_immobilien' => ['global', 'cta_section_immobilien'], 'cta_section_portfolio' => ['global', 'cta_section_portfolio'],
'appointment_widget' => ['global', 'appointment_widget'],
'hero' => ['home', 'hero'], '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'], 'founder_bar' => ['home', 'founder_bar'],
'synergie_section' => ['home', 'synergie_section'],
'ecosystem_core' => ['home', 'ecosystem_core'],
'vision_section' => ['home', 'vision_section'], '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' => ['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' => ['contact', 'contact_form'],
'contact_form_immobilien' => ['contact', 'contact_form_immobilien'],
'about_hero' => ['about', 'hero'], 'about_hero' => ['about', 'hero'],
'our_story' => ['about', 'our_story'], 'our_story' => ['about', 'our_story'],
'about_image_break' => ['about', 'image_break'], 'about_image_break' => ['about', 'image_break'],
'our_values' => ['about', 'our_values'], '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_detail' => ['magazin', 'detail'],
'magazin_list' => ['magazin', 'list'], 'magazin_list' => ['magazin', 'list'],

View file

@ -71,29 +71,13 @@ return [
'view_prefix' => 'b2in', 'view_prefix' => 'b2in',
'assets_dir' => 'build/b2in', 'assets_dir' => 'build/b2in',
'description' => 'Haupt-Landingpage mit Standard-Theme', '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' => [ 'color_scheme' => [
'primary' => '#202c2b', // Tanne 'primary' => '#2b3f51', // Anthracite
'secondary' => '#a8741f', // Ocker 'secondary' => '#20a0da', // Dynamic Blue
], ],
'font_family' => [ 'font_family' => [
'primary' => 'Inter', 'primary' => 'Inter',
'secondary' => 'EB Garamond', 'secondary' => 'IBM Plex Sans',
],
'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
],
], ],
], ],

View file

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

View file

@ -1,87 +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,
],
/*
|--------------------------------------------------------------------------
| Monitoring-Seite im Admin
|--------------------------------------------------------------------------
|
| Link zur Statusseite von Uptime Kuma (optional). GlitchTip wird aus der
| DSN abgeleitet.
|
*/
'status_url' => env('MONITORING_STATUS_URL'),
];

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), 'subtitle' => fake()->sentence(12),
'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg', 'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg',
'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']), 'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']),
'world' => CmsArticle::WORLD_INTERIOR,
'date_label' => fake()->date('F j, Y'), 'date_label' => fake()->date('F j, Y'),
'read_time' => fake()->numberBetween(3, 10).' min read', 'read_time' => fake()->numberBetween(3, 10).' min read',
'author' => [ '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']);
});
}
};

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