Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbf04e1f9c | |||
| b5ed756d67 | |||
| fcb10be120 | |||
| 4b07bbdcae | |||
| 3211569fee | |||
| 90aa079cf7 | |||
| 7953a8c9a1 | |||
| 1242d4bce8 | |||
| 3958a1fe89 | |||
| c8899e2d7d | |||
| 093e0ece51 | |||
| 3ee7b44a77 | |||
| f971f416db | |||
| a52347845e | |||
| 4ef3a4d8f1 | |||
| 6d53243ea6 |
774 changed files with 46093 additions and 3699 deletions
30
.env.example
30
.env.example
|
|
@ -80,3 +80,33 @@ DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
|
|||
# Nützlich in der Entwicklung, um Seed-Daten und manuelle Testeinträge zu erhalten.
|
||||
# Hinweis: Migrationen müssen mindestens einmal für die Test-DB ausgeführt werden: DB_DATABASE=testing sail artisan migrate
|
||||
# TESTING_PRESERVE_DATABASE=false
|
||||
|
||||
# Seed-Admin (DatabaseSeeder)
|
||||
SEED_ADMIN_NAME="Admin"
|
||||
SEED_ADMIN_EMAIL=admin@b2in.test
|
||||
SEED_ADMIN_PASSWORD=
|
||||
|
||||
# --- Version (leer lassen: kommt aus der Datei RELEASE, die dev/betrieb/deploy.sh schreibt)
|
||||
APP_VERSION=
|
||||
|
||||
# --- GlitchTip (errors.adametz.media, Projekte server-b2in / browser-b2in) – siehe dev/betrieb/README.md
|
||||
SENTRY_LARAVEL_DSN=
|
||||
SENTRY_BROWSER_DSN=
|
||||
SENTRY_ENVIRONMENT=production # Vorschau: preview, lokal: local
|
||||
SENTRY_RELEASE=
|
||||
|
||||
# --- Betriebs-Alerts (Mail und ntfy-Push)
|
||||
MONITORING_ALERTS_ENABLED=true
|
||||
MONITORING_ALERT_EMAIL=
|
||||
MONITORING_NTFY_URL=https://push.adametz.media
|
||||
MONITORING_NTFY_TOPIC=b2in-ops
|
||||
MONITORING_NTFY_TOKEN=
|
||||
MONITORING_NTFY_PRIORITY=4
|
||||
|
||||
# --- Tiefer Health-Check /health (Bearer-Token für Uptime Kuma) – je Umgebung ein eigener Wert
|
||||
MONITORING_HEALTH_TOKEN=
|
||||
MONITORING_QUEUE_BACKLOG_SECONDS=300
|
||||
MONITORING_DISK_MIN_FREE_PERCENT=10
|
||||
|
||||
# --- Nur lokal: Lesezugriff auf die GlitchTip-API (project:read, event:read) – nie committen
|
||||
GLITCHTIP_API_TOKEN=
|
||||
|
|
|
|||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -1,12 +1,17 @@
|
|||
# Laravel
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
/public/build
|
||||
# public/build ist versioniert (Git-Deploy: auf dem Server gibt es kein Node). Nur die
|
||||
# Ausgabe des alten Standard-Builds (`npm run build`) liegt direkt darunter und wird nicht gebraucht.
|
||||
/public/build/assets/
|
||||
/public/build/manifest.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/public/flux
|
||||
/storage/*.key
|
||||
# schreibt dev/betrieb/deploy.sh beim Deploy (Version des ausgelieferten Tags)
|
||||
/RELEASE
|
||||
/storage/app
|
||||
/storage/framework
|
||||
/storage/language
|
||||
|
|
@ -50,11 +55,31 @@ yarn-error.log
|
|||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
Icon
|
||||
# macOS-Ordnersymbol heißt „Icon\r“ – ohne [\r] träfe das Muster bei core.ignorecase auch resources/views/flux/icon/
|
||||
Icon[\r]
|
||||
|
||||
|
||||
# Project specific
|
||||
_static/
|
||||
_work/
|
||||
_storage/
|
||||
dev/immobilien 07-05-2026/AZIZI SharePoint
|
||||
dev/immobilien 07-05-2026/AZIZI SharePoint
|
||||
# Deployment-Archive werden bei Bedarf neu erzeugt
|
||||
dev/**/deploy/*.tar.gz
|
||||
|
||||
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
|
||||
/_b2in_interior_shop/
|
||||
/docs/
|
||||
|
||||
# Original-Bildzulieferungen (mehrere MB je Datei) bleiben ausserhalb der Versionierung;
|
||||
# die optimierten WebP-Fassungen liegen unter public/img/assets/marken/.
|
||||
/dev/2026-08-28/images/
|
||||
|
||||
# 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/
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ Domains are configured in `config/domains.php` with environment variables:
|
|||
|
||||
Domain-specific configurations include:
|
||||
- **portal.b2in.test**: Admin backend with Flux UI theme
|
||||
- **b2in.test**: Anthracite/Dynamic Blue color scheme, Inter/IBM Plex Sans fonts
|
||||
- **b2in.test**: two color worlds on one theme – Interior (default: Tanne `#202c2b`, Creme `#f7f4ef`, Sand `#d9cbbf`, Ocker `#d39e46`, Clay `#a38f82`, Anthrazit `#2d2926`; Inter/EB Garamond) and Immobilien (`body.world-immobilien`: Anthracite/Dynamic Blue, Inter/IBM Plex Sans). The world comes from `b2in_world()` (app/helpers.php), the logo per world from `ThemeHelper::getLogoPath()`
|
||||
- **b2a.test**: Azur Blue/Liberty Red color scheme, Inter/Merriweather fonts
|
||||
- **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts
|
||||
- **style2own.test**: Imperial Blue/Sand Gold color scheme, Inter/EB Garamond fonts
|
||||
|
|
@ -142,6 +142,11 @@ For local development, add domains to your hosts file:
|
|||
127.0.0.1 style2own.test
|
||||
```
|
||||
|
||||
## Deployment & Monitoring
|
||||
- Deploys run via Git tags only: `dev/betrieb/deploy.sh vX.Y.Z` on the server; remotes: `origin` (forge.adametz.media, deploy source), `NAS` (backup only). Runbook: `dev/betrieb/README.md`.
|
||||
- `public/build` is versioned: build locally (`npm run build:web && npm run build:portal`) and commit before tagging.
|
||||
- Errors go to GlitchTip (`errors.adametz.media`, org `adametz-media`, projects `server-b2in` = 8, `browser-b2in` = 7); read-only API token as `GLITCHTIP_API_TOKEN` in `.env`. `/health` (token) for Uptime Kuma, alerts via ntfy topic `b2in-ops`.
|
||||
|
||||
## Important Files
|
||||
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide
|
||||
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide
|
||||
|
|
@ -160,7 +165,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.18
|
||||
- php - 8.4.26
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
|
|
|
|||
25
app/Console/Commands/MonitoringHeartbeat.php
Normal file
25
app/Console/Commands/MonitoringHeartbeat.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
50
app/Console/Commands/MonitoringQueueProbe.php
Normal file
50
app/Console/Commands/MonitoringQueueProbe.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,11 +5,20 @@ namespace App\Helpers;
|
|||
class ThemeHelper
|
||||
{
|
||||
/**
|
||||
* Get the logo path based on the current theme
|
||||
* Logo-Pfad für das aktuelle Theme.
|
||||
*
|
||||
* Für b2in hängt die Datei zusätzlich an der Farbwelt: die Interior-Welt trägt das Logo
|
||||
* mit dem Zusatz „Interior“ und ockerfarbenem Bogen, die Immobilien-Welt das Logo ohne
|
||||
* Zusatz mit blauem Bogen. Ohne Angabe wird die Welt aus der aktuellen Route gelesen.
|
||||
*/
|
||||
public static function getLogoPath(string $type = 'positive'): string
|
||||
public static function getLogoPath(string $type = 'positive', ?string $world = null): string
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
if ($theme === 'b2in' && ($world ?? b2in_world()) === 'immobilien') {
|
||||
return self::getLogoPathForBrand('b2in-immobilien', $type);
|
||||
}
|
||||
|
||||
return self::getLogoPathForBrand($theme, $type);
|
||||
}
|
||||
|
||||
|
|
@ -22,21 +31,25 @@ class ThemeHelper
|
|||
|
||||
$logoMap = [
|
||||
'b2in' => [
|
||||
'positive' => 'img/logos/b2in-interior-logo-positive.svg',
|
||||
'negative' => 'img/logos/b2in-interior-logo-negative.svg',
|
||||
],
|
||||
'b2in-immobilien' => [
|
||||
'positive' => 'img/logos/b2in-logo-positive.svg',
|
||||
'negative' => 'img/logos/b2in-logo-negative.svg'
|
||||
'negative' => 'img/logos/b2in-logo-negative.svg',
|
||||
],
|
||||
'b2a' => [
|
||||
'positive' => 'img/logos/b2a-logo-positiv.svg',
|
||||
'negative' => 'img/logos/b2a-logo-negativ.svg'
|
||||
'negative' => 'img/logos/b2a-logo-negativ.svg',
|
||||
],
|
||||
'stileigentum' => [
|
||||
'positive' => 'img/logos/stileigentum-logo-positiv.svg',
|
||||
'negative' => 'img/logos/stileigentum-logo-negativ.svg'
|
||||
'negative' => 'img/logos/stileigentum-logo-negativ.svg',
|
||||
],
|
||||
'style2own' => [
|
||||
'positive' => 'img/logos/style2own-logo-positiv.svg',
|
||||
'negative' => 'img/logos/style2own-logo-negativ.svg'
|
||||
]
|
||||
'negative' => 'img/logos/style2own-logo-negativ.svg',
|
||||
],
|
||||
];
|
||||
|
||||
return $logoMap[$brand][$type] ?? $logoMap['b2in'][$type];
|
||||
|
|
@ -48,6 +61,7 @@ class ThemeHelper
|
|||
public static function getFaviconPath(): string
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
return "img/favicons/{$theme}-favicon.ico";
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +71,7 @@ class ThemeHelper
|
|||
public static function getThemeCssPath(): string
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
return "resources/css/web/theme-{$theme}.css";
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +81,7 @@ class ThemeHelper
|
|||
public static function getDomainConfig(): array
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
|
||||
return config("domains.domains.{$theme}", []);
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +91,7 @@ class ThemeHelper
|
|||
public static function getPrimaryColor(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['color_scheme']['primary'] ?? '#2b3f51';
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +101,7 @@ class ThemeHelper
|
|||
public static function getSecondaryColor(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['color_scheme']['secondary'] ?? '#20a0da';
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +111,7 @@ class ThemeHelper
|
|||
public static function getPrimaryFont(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['font_family']['primary'] ?? 'Inter';
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +121,7 @@ class ThemeHelper
|
|||
public static function getSecondaryFont(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['font_family']['secondary'] ?? 'IBM Plex Sans';
|
||||
}
|
||||
|
||||
|
|
@ -111,12 +131,14 @@ class ThemeHelper
|
|||
public static function getDomainName(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['domain_name'] ?? 'B2IN';
|
||||
}
|
||||
|
||||
public static function getDomainUrl(): string
|
||||
{
|
||||
$config = self::getDomainConfig();
|
||||
|
||||
return $config['url'] ?? config('app.url');
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +149,7 @@ class ThemeHelper
|
|||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
$config = config("domains.domains.{$brand}", []);
|
||||
|
||||
|
||||
return [
|
||||
'primary' => $config['color_scheme']['primary'] ?? '#2b3f51',
|
||||
'secondary' => $config['color_scheme']['secondary'] ?? '#20a0da',
|
||||
|
|
@ -141,14 +163,14 @@ class ThemeHelper
|
|||
public static function getBrandName(?string $brand = null): string
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
|
||||
|
||||
$brandNames = [
|
||||
'b2in' => 'B2IN',
|
||||
'b2a' => 'B2A',
|
||||
'stileigentum' => 'StilEigentum',
|
||||
'style2own' => 'Style2Own',
|
||||
];
|
||||
|
||||
|
||||
return $brandNames[$brand] ?? strtoupper($brand);
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +180,7 @@ class ThemeHelper
|
|||
public static function getBrandConfig(?string $brand = null): array
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
|
||||
return config("domains.domains.{$brand}", []);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
app/Http/Controllers/DisplayEmbedController.php
Normal file
34
app/Http/Controllers/DisplayEmbedController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Display;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* Öffentliche, schreibgeschützte Einbettung eines Displays auf der Website.
|
||||
*
|
||||
* Bewusst getrennt von {@see \App\Http\Controllers\Api\DisplayPreviewController}: Die
|
||||
* Preview-Routen arbeiten mit `preview_token` und zeigen den **Entwurf**. Nach außen darf weder
|
||||
* ein Token noch ein unveröffentlichter Stand gelangen. Diese Route kennt nur die numerische ID
|
||||
* und liefert ausschließlich veröffentlichte Playlists aktiver Displays.
|
||||
*/
|
||||
class DisplayEmbedController extends Controller
|
||||
{
|
||||
public function show(Display $display): BinaryFileResponse
|
||||
{
|
||||
abort_unless($display->is_active, 404);
|
||||
|
||||
$playlist = $display->livePlaylist()->withCount('modules')->first();
|
||||
|
||||
abort_if($playlist === null || $playlist->modules_count === 0, 404);
|
||||
|
||||
// Der Player liest die ID aus dem Pfad (/display/{id}) und holt sich die Konfiguration
|
||||
// über die öffentliche Route /api/display/{display}/config – dieselbe Quelle wie das
|
||||
// physische Display. Keine doppelte Pflege.
|
||||
return response()->file(public_path('_cabinet/display/index.html'), [
|
||||
'Cache-Control' => 'no-cache, must-revalidate',
|
||||
'X-Robots-Tag' => 'noindex',
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/HealthController.php
Normal file
39
app/Http/Controllers/HealthController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,12 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für den Health-Check: Uptime Kuma schickt seinen eigenen
|
||||
// Authorization-Header (Bearer-Token), /health prüft ihn selbst.
|
||||
if ($path === 'health' || $path === 'up') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
||||
if (str_starts_with($path, 'flux/')) {
|
||||
return $next($request);
|
||||
|
|
|
|||
96
app/Http/Middleware/ReportErrorSignals.php
Normal file
96
app/Http/Middleware/ReportErrorSignals.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
31
app/Jobs/Monitoring/QueueProbe.php
Normal file
31
app/Jobs/Monitoring/QueueProbe.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?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));
|
||||
}
|
||||
}
|
||||
|
|
@ -98,13 +98,35 @@ class CabinetInfoTablet extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
|
||||
* Zeit-Properties, die beim Speichern aus dem DOM uebernommen werden duerfen.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function hourProperties(): array
|
||||
{
|
||||
return [
|
||||
'hoursMondayOpen', 'hoursMondayClose',
|
||||
'hoursTuesdayOpen', 'hoursTuesdayClose',
|
||||
'hoursWednesdayOpen', 'hoursWednesdayClose',
|
||||
'hoursThursdayOpen', 'hoursThursdayClose',
|
||||
'hoursFridayOpen', 'hoursFridayClose',
|
||||
'hoursSaturdayOpen', 'hoursSaturdayClose',
|
||||
'hoursSundayOpen', 'hoursSundayClose',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Flux-Zeit-Picker meldet das Leeren per X nicht zuverlaessig an wire:model.
|
||||
* Deshalb liest das Formular die Werte beim Absenden aus dem DOM und uebergibt sie hier.
|
||||
* Nur die Zeitfelder duerfen so gesetzt werden – alles andere kommt aus wire:model.
|
||||
*
|
||||
* @param array<string, string|null> $hours Zeit-Picker-Werte aus dem DOM
|
||||
*/
|
||||
public function save(array $hours = []): void
|
||||
{
|
||||
foreach ($hours as $prop => $value) {
|
||||
if (property_exists($this, $prop)) {
|
||||
$this->{$prop} = $value ?? '';
|
||||
foreach ($this->hourProperties() as $prop) {
|
||||
if (array_key_exists($prop, $hours)) {
|
||||
$this->{$prop} = is_string($hours[$prop]) ? $hours[$prop] : '';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public string $mediaSubline = '';
|
||||
|
||||
public int $mediaDuration = 10;
|
||||
/** Anzeigedauer eines Bildes in Sekunden als Eingabetext, Komma oder Punkt erlaubt („1,5“). */
|
||||
public string $mediaDuration = '10';
|
||||
|
||||
public bool $mediaIsActive = true;
|
||||
|
||||
|
|
@ -247,6 +248,12 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public function saveItem(): void
|
||||
{
|
||||
if ($this->itemType === 'media' && $this->mediaType === 'image' && self::parseDuration($this->mediaDuration) === null) {
|
||||
$this->addError('mediaDuration', 'Bitte eine Dauer zwischen 0,5 und 120 Sekunden angeben, z. B. 1,5.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $this->buildItemContent();
|
||||
$isActive = $this->getActiveFlag();
|
||||
|
||||
|
|
@ -279,6 +286,41 @@ class DisplayVersionEditor extends Component
|
|||
$this->refreshModulePreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sekunden aus der Eingabe lesen: „1,5“, „1.5“ und „10“ sind gültig, gerundet auf Zehntel.
|
||||
* Erlaubt sind 0,5 bis 120 Sekunden; alles andere ergibt null.
|
||||
*/
|
||||
public static function parseDuration(string|int|float $input): ?float
|
||||
{
|
||||
$normalized = str_replace(',', '.', trim((string) $input));
|
||||
|
||||
if ($normalized === '' || ! is_numeric($normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = round((float) $normalized, 1);
|
||||
|
||||
if ($seconds < 0.5 || $seconds > 120) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $seconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sekunden für die Anzeige: ganze Zahlen ohne Nachkommastelle, sonst mit Komma („1,5“).
|
||||
*/
|
||||
public static function formatDuration(int|float|string|null $seconds): string
|
||||
{
|
||||
$seconds = (float) str_replace(',', '.', (string) ($seconds ?? 10));
|
||||
|
||||
if (floor($seconds) === $seconds) {
|
||||
return (string) (int) $seconds;
|
||||
}
|
||||
|
||||
return str_replace('.', ',', (string) round($seconds, 1));
|
||||
}
|
||||
|
||||
public function deleteItem(int $id): void
|
||||
{
|
||||
DisplayVersionItem::findOrFail($id)->delete();
|
||||
|
|
@ -437,7 +479,7 @@ class DisplayVersionEditor extends Component
|
|||
$this->mediaUrl = $content['media_url'] ?? '';
|
||||
$this->mediaHeadline = $content['headline'] ?? '';
|
||||
$this->mediaSubline = $content['subline'] ?? '';
|
||||
$this->mediaDuration = $content['duration_seconds'] ?? 10;
|
||||
$this->mediaDuration = self::formatDuration($content['duration_seconds'] ?? 10);
|
||||
$this->mediaIsActive = $isActive;
|
||||
}
|
||||
|
||||
|
|
@ -501,7 +543,7 @@ class DisplayVersionEditor extends Component
|
|||
'media_url' => $this->mediaUrl,
|
||||
'headline' => $this->mediaHeadline,
|
||||
'subline' => $this->mediaSubline,
|
||||
'duration_seconds' => $this->mediaDuration,
|
||||
'duration_seconds' => self::parseDuration($this->mediaDuration) ?? 10,
|
||||
],
|
||||
'slide' => [
|
||||
'type' => 'detail',
|
||||
|
|
@ -663,7 +705,7 @@ class DisplayVersionEditor extends Component
|
|||
$this->mediaUrl = '';
|
||||
$this->mediaHeadline = '';
|
||||
$this->mediaSubline = '';
|
||||
$this->mediaDuration = 10;
|
||||
$this->mediaDuration = '10';
|
||||
$this->mediaIsActive = true;
|
||||
$this->slideShowLogo = true;
|
||||
$this->slideLogoUrl = '';
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ class AboutHero extends Component
|
|||
{
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_about_hero). */
|
||||
public string $section = 'about_hero';
|
||||
|
||||
public function mount(string $section = 'about_hero')
|
||||
{
|
||||
$this->content = cms_theme_section('about_hero');
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
25
app/Livewire/Web/Components/Sections/AudienceSwitch.php
Normal file
25
app/Livewire/Web/Components/Sections/AudienceSwitch.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class AudienceSwitch extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('audience_switch');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.audience-switch');
|
||||
}
|
||||
}
|
||||
89
app/Livewire/Web/Components/Sections/BrandDetail.php
Normal file
89
app/Livewire/Web/Components/Sections/BrandDetail.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
class BrandDetail extends Component
|
||||
{
|
||||
public string $slug = '';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $brand = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $otherBrands = [];
|
||||
|
||||
/**
|
||||
* Galerie getrennt nach realisierten Projekten und Inspirationen.
|
||||
*
|
||||
* @var array{projects: array<int, array<string, mixed>>, inspirations: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public array $gallery = ['projects' => [], 'inspirations' => []];
|
||||
|
||||
public function mount(string $slug): void
|
||||
{
|
||||
$brands = CmsBrand::forFrontend();
|
||||
|
||||
abort_unless(isset($brands[$slug]), 404);
|
||||
|
||||
$this->slug = $slug;
|
||||
$this->brand = $brands[$slug];
|
||||
$this->gallery = CmsBrand::groupGallery($this->brand['gallery'] ?? []);
|
||||
$this->otherBrands = collect($brands)
|
||||
->reject(fn (array $brand, string $key) => $key === $slug)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$craft = $this->craftSection($slug, $brands);
|
||||
|
||||
if ($craft !== null) {
|
||||
$this->brand['sections'][] = $craft;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsamer Handwerk-Abschluss (Lang-Sektion `brand_craft`, Nachtrag 15): steht als letzter
|
||||
* Abschnitt auf jeder Markenseite – außer auf der Seite der Handwerksmarke selbst. Der Link
|
||||
* dorthin erscheint nur, wenn diese Marke veröffentlicht ist und eine Detailseite hat.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $brands
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function craftSection(string $slug, array $brands): ?array
|
||||
{
|
||||
$content = cms_theme_section('brand_craft');
|
||||
|
||||
if (! is_array($content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$craftSlug = (string) ($content['brand_slug'] ?? '');
|
||||
|
||||
if ($craftSlug !== '' && $craftSlug === $slug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sections = CmsBrand::normalizeSections([$content]);
|
||||
|
||||
if ($sections === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$section = $sections[0];
|
||||
$section['anchor'] = 'handwerk';
|
||||
|
||||
if ($craftSlug !== '' && ! empty($brands[$craftSlug]['has_detail_page'])) {
|
||||
$section['link'] = route('marken.show', ['slug' => $craftSlug]);
|
||||
$section['link_text'] = (string) ($content['link_text'] ?? $brands[$craftSlug]['name'] ?? '');
|
||||
}
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.brand-detail');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/BrandOverview.php
Normal file
25
app/Livewire/Web/Components/Sections/BrandOverview.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
class BrandOverview extends Component
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $brands = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$this->brands = array_values(CmsBrand::forFrontend());
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.brand-overview');
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,13 @@ class CTASection extends Component
|
|||
|
||||
public $section = '';
|
||||
|
||||
public function mount($bg = 'bg-secondary', $section = 'cta_section')
|
||||
public function mount($bg = 'cta-band', $section = 'cta_section')
|
||||
{
|
||||
// Der Standard-Abschluss hat je Welt eigene Texte und fuehrt zur Kontaktseite der Welt.
|
||||
if ($section === 'cta_section' && b2in_world() === 'immobilien') {
|
||||
$section = 'cta_section_immobilien';
|
||||
}
|
||||
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
$this->bg = $bg;
|
||||
|
|
|
|||
30
app/Livewire/Web/Components/Sections/CoreBrands.php
Normal file
30
app/Livewire/Web/Components/Sections/CoreBrands.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
class CoreBrands extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $brands = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('core_brands');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->brands = array_values(CmsBrand::forFrontend());
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.core-brands');
|
||||
}
|
||||
}
|
||||
84
app/Livewire/Web/Components/Sections/DisplayFeed.php
Normal file
84
app/Livewire/Web/Components/Sections/DisplayFeed.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,12 @@ class FounderBar extends Component
|
|||
{
|
||||
public array $content = [];
|
||||
|
||||
public function mount(): void
|
||||
/** Hintergrundklasse der Sektion; unter den Interior-Heroes (Startseite, Marken, Über uns) `bg-accent`. */
|
||||
public string $bg = 'bg-background';
|
||||
|
||||
public function mount(string $bg = 'bg-background'): void
|
||||
{
|
||||
$this->bg = $bg;
|
||||
$this->content = cms_theme_section('founder_bar');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,86 @@ namespace App\Livewire\Web\Components\Sections;
|
|||
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Hero der Startseite: Text links, rechts eine ruhige Slideshow als Hauptelement des Rahmens
|
||||
* (Abstimmung 07.10.2026, Vorbild dev/2026-08-28/Farbwelt und Stil.png).
|
||||
*
|
||||
* Die Bilder kommen aus der Liste `slides` der Sektion `home/hero` (je `image`, `label`, `caption`,
|
||||
* `alt`, `active`) und laufen in dieser Reihenfolge. Die Infokarte über dem Bild zeigt je Slide
|
||||
* Marke (`label`) und Motiv (`caption`), ohne Angaben die festen Texte `card_title`/`card_text`.
|
||||
* Ist die Liste leer oder alles deaktiviert, steht das einzelne Bild `image` da wie früher.
|
||||
*/
|
||||
class Hero extends Component
|
||||
{
|
||||
public $content = [];
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public function mount()
|
||||
/** @var array<int, array{image: string, alt: string, label: string, caption: string}> */
|
||||
public array $slides = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->content = cms_theme_section('hero');
|
||||
$section = 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ namespace App\Livewire\Web\Components\Sections;
|
|||
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Hero der Themes b2a, Stileigentum und Style2own (Stand vor dem B2in-Entwurf vom 24.09.2026).
|
||||
* Ohne gepflegte Sektion `hero_slider` bleibt der Bereich leer.
|
||||
*/
|
||||
class HeroSlider extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
|
@ -12,7 +16,8 @@ class HeroSlider extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$this->content = cms_theme_section('hero_slider');
|
||||
$section = cms_theme_section('hero_slider');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
}
|
||||
|
||||
public function nextSlide()
|
||||
|
|
|
|||
25
app/Livewire/Web/Components/Sections/ImmobilienTeaser.php
Normal file
25
app/Livewire/Web/Components/Sections/ImmobilienTeaser.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ImmobilienTeaser extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('immobilien_teaser');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.immobilien-teaser');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Livewire\Component;
|
||||
|
||||
class MagazinDetail extends Component
|
||||
{
|
||||
public $articleId;
|
||||
public $slug;
|
||||
|
||||
public $article;
|
||||
|
||||
|
|
@ -14,25 +15,41 @@ class MagazinDetail extends Component
|
|||
|
||||
public $content;
|
||||
|
||||
public function mount($id = 1)
|
||||
/** Rueckweg zur Magazinliste der Welt, zu der der Beitrag gehoert. */
|
||||
public string $listUrl = '';
|
||||
|
||||
public function mount($slug)
|
||||
{
|
||||
$this->articleId = (int) $id;
|
||||
$this->slug = (string) $slug;
|
||||
$this->loadArticle();
|
||||
$this->listUrl = $this->articleWorld() === CmsArticle::WORLD_IMMOBILIEN
|
||||
? route('immobilien.magazin')
|
||||
: route('magazin');
|
||||
$this->loadRelatedArticles();
|
||||
$this->loadThemeContent();
|
||||
}
|
||||
|
||||
private function articleWorld(): string
|
||||
{
|
||||
return (string) ($this->article['world'] ?? CmsArticle::WORLD_INTERIOR);
|
||||
}
|
||||
|
||||
private function loadArticle()
|
||||
{
|
||||
$articles = $this->getArticlesData();
|
||||
$this->article = $articles[$this->articleId] ?? $articles[1];
|
||||
|
||||
// Unbekannte Slugs haben frueher stillschweigend Artikel 1 ausgeliefert – mit Status 200.
|
||||
// Das erzeugt Duplicate Content unter beliebig vielen URLs.
|
||||
abort_unless(isset($articles[$this->slug]), 404);
|
||||
|
||||
$this->article = $articles[$this->slug];
|
||||
}
|
||||
|
||||
private function loadRelatedArticles()
|
||||
{
|
||||
$articles = $this->getArticlesData();
|
||||
$this->relatedArticles = collect($articles)
|
||||
->filter(fn ($article, $key) => $key != $this->articleId)
|
||||
$this->relatedArticles = collect($this->getArticlesData())
|
||||
->filter(fn (array $article) => ($article['world'] ?? CmsArticle::WORLD_INTERIOR) === $this->articleWorld())
|
||||
->reject(fn (array $article, string $slug) => $slug === $this->slug)
|
||||
->take(2)
|
||||
->values()
|
||||
->toArray();
|
||||
|
|
@ -43,9 +60,18 @@ class MagazinDetail extends Component
|
|||
$this->content = cms_theme_section('magazin_detail');
|
||||
}
|
||||
|
||||
private function getArticlesData()
|
||||
/** @var array<string, array<string, mixed>>|null */
|
||||
private ?array $articlesCache = null;
|
||||
|
||||
/**
|
||||
* Wird beim Rendern zweimal gebraucht (Artikel + verwandte Artikel) – deshalb gemerkt,
|
||||
* damit die Detailseite nur eine Abfrage auslöst.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function getArticlesData(): array
|
||||
{
|
||||
return trans('b2in.articles');
|
||||
return $this->articlesCache ??= CmsArticle::forFrontend();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Livewire\Component;
|
||||
|
||||
class MagazinList extends Component
|
||||
|
|
@ -10,34 +11,41 @@ class MagazinList extends Component
|
|||
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Farbwelt der Liste: `interior` (/magazin) oder `immobilien` (/immobilien/magazin). */
|
||||
public string $world = CmsArticle::WORLD_INTERIOR;
|
||||
|
||||
public function mount(string $world = CmsArticle::WORLD_INTERIOR)
|
||||
{
|
||||
$this->world = in_array($world, CmsArticle::WORLDS, true) ? $world : CmsArticle::WORLD_INTERIOR;
|
||||
$this->loadPosts();
|
||||
$this->loadThemeContent();
|
||||
}
|
||||
|
||||
private function loadPosts()
|
||||
{
|
||||
$articles = trans('b2in.articles');
|
||||
$this->posts = collect($articles)->map(function ($article) {
|
||||
$this->posts = collect(CmsArticle::forFrontend($this->world))->map(function (array $article) {
|
||||
return [
|
||||
'id' => $article['id'],
|
||||
'slug' => $article['slug'],
|
||||
'title' => $article['title'],
|
||||
'excerpt' => $article['content']['intro'], // Using intro as excerpt
|
||||
'excerpt' => $article['content']['intro'] ?? '',
|
||||
'image' => $article['image'],
|
||||
'date' => $article['date'],
|
||||
'readTime' => $article['readTime'],
|
||||
];
|
||||
})->all();
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
private function loadThemeContent()
|
||||
{
|
||||
$this->content = cms_theme_section('magazin_list');
|
||||
$this->content = cms_theme_section(
|
||||
$this->world === CmsArticle::WORLD_IMMOBILIEN ? 'magazin_list_immobilien' : 'magazin_list'
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.magazin-list');
|
||||
return view('livewire.web.components.sections.magazin-list', [
|
||||
'posts' => $this->posts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
app/Livewire/Web/Components/Sections/MarkenCta.php
Normal file
25
app/Livewire/Web/Components/Sections/MarkenCta.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class MarkenCta extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('marken_cta');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.marken-cta');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/MarkenGrowth.php
Normal file
25
app/Livewire/Web/Components/Sections/MarkenGrowth.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class MarkenGrowth extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('marken_growth');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.marken-growth');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/MarkenNetwork.php
Normal file
25
app/Livewire/Web/Components/Sections/MarkenNetwork.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class MarkenNetwork extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('marken_network');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.marken-network');
|
||||
}
|
||||
}
|
||||
83
app/Livewire/Web/Components/Sections/OriginSection.php
Normal file
83
app/Livewire/Web/Components/Sections/OriginSection.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
|
||||
* (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
|
||||
*
|
||||
* Überschrift, Karte, Drehscheibe und Ablauf-Block kommen aus dem CMS (Fallback Lang-Datei).
|
||||
* Die Standorte kommen aus den veröffentlichten Marken: Ort, Entfernung und Rolle pflegt
|
||||
* Marcel in der Markenverwaltung. Die Positionen in der schematischen Karte gehören zur
|
||||
* Zeichnung und bleiben hier im Code.
|
||||
*/
|
||||
class OriginSection extends Component
|
||||
{
|
||||
/**
|
||||
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
|
||||
*
|
||||
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
|
||||
*
|
||||
* @var array<string, array{x: int, y: int, label: string}>
|
||||
*/
|
||||
public const MAP_POSITIONS = [
|
||||
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
|
||||
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
|
||||
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
|
||||
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
|
||||
];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('origin_section');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Marken mit gepflegtem Fertigungsort, in der Reihenfolge der Marken.
|
||||
*
|
||||
* Eine unveröffentlichte Marke fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
|
||||
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
|
||||
*
|
||||
* @return list<array{slug: string, city: string, brand: string, role: string, distance: string, link: ?string, map: ?array{x: int, y: int, label: string}}>
|
||||
*/
|
||||
public function getLocationsProperty(): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
$city = trim((string) ($brand['origin_city'] ?? ''));
|
||||
$name = trim((string) ($brand['name'] ?? ''));
|
||||
|
||||
if ($city === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'slug' => (string) $slug,
|
||||
'city' => $city,
|
||||
'brand' => $name,
|
||||
'role' => trim((string) ($brand['origin_role'] ?? '')),
|
||||
'distance' => trim((string) ($brand['origin_distance'] ?? '')),
|
||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
'map' => self::MAP_POSITIONS[$slug] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.origin-section');
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,13 @@ class OurStory extends Component
|
|||
{
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_story). */
|
||||
public string $section = 'our_story';
|
||||
|
||||
public function mount(string $section = 'our_story')
|
||||
{
|
||||
$this->content = cms_theme_section('our_story');
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -8,9 +8,13 @@ class OurValues extends Component
|
|||
{
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_values). */
|
||||
public string $section = 'our_values';
|
||||
|
||||
public function mount(string $section = 'our_values')
|
||||
{
|
||||
$this->content = cms_theme_section('our_values');
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
32
app/Livewire/Web/Components/Sections/PageHero.php
Normal file
32
app/Livewire/Web/Components/Sections/PageHero.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?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');
|
||||
}
|
||||
}
|
||||
113
app/Livewire/Web/Components/Sections/ReferenceProjects.php
Normal file
113
app/Livewire/Web/Components/Sections/ReferenceProjects.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use App\Models\CmsReference;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Realisierte Projekte auf /referenzen.
|
||||
*
|
||||
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
|
||||
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
|
||||
* die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien, über alle Marken
|
||||
* hinweg gesammelt. Visualisierungen und Entwürfe bleiben in beiden Fällen draußen.
|
||||
*/
|
||||
class ReferenceProjects extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $projects = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public int $limit = 0;
|
||||
|
||||
public function mount(string $bg = '', int $limit = 0): void
|
||||
{
|
||||
$section = cms_theme_section('reference_projects');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
$this->limit = $limit;
|
||||
|
||||
$projects = $this->fromReferences();
|
||||
|
||||
if ($projects === []) {
|
||||
$projects = $this->fromBrandGalleries();
|
||||
}
|
||||
|
||||
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fromReferences(): array
|
||||
{
|
||||
$references = CmsReference::query()->published()->ordered()->get();
|
||||
|
||||
if ($references->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$brands = CmsBrand::forFrontend();
|
||||
$projects = [];
|
||||
|
||||
foreach ($references as $reference) {
|
||||
$gallery = CmsReference::normalizeGallery($reference->gallery);
|
||||
$image = (string) ($reference->image ?: ($gallery[0]['image'] ?? ''));
|
||||
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$projects[] = [
|
||||
'key' => 'ref-'.$reference->id,
|
||||
'image' => $image,
|
||||
'caption' => (string) $reference->title,
|
||||
'meta' => implode(' · ', array_filter([$reference->location, $reference->year])),
|
||||
'description' => (string) $reference->description,
|
||||
'gallery' => array_values(array_filter($gallery, fn (array $item) => $item['image'] !== $image)),
|
||||
'brands' => $reference->resolveBrands($brands),
|
||||
];
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function fromBrandGalleries(): array
|
||||
{
|
||||
$projects = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
foreach (CmsBrand::groupGallery($brand['gallery'] ?? [])['projects'] as $index => $item) {
|
||||
$projects[] = [
|
||||
'key' => $slug.'-'.$index,
|
||||
'image' => $item['image'],
|
||||
'caption' => $item['caption'],
|
||||
'meta' => '',
|
||||
'description' => '',
|
||||
'gallery' => [],
|
||||
'brands' => [[
|
||||
'slug' => $slug,
|
||||
'name' => (string) ($brand['name'] ?? ''),
|
||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
]],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.reference-projects');
|
||||
}
|
||||
}
|
||||
25
app/Livewire/Web/Components/Sections/Sponsoring.php
Normal file
25
app/Livewire/Web/Components/Sections/Sponsoring.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Sponsoring extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public string $bg = '';
|
||||
|
||||
public function mount(string $bg = ''): void
|
||||
{
|
||||
$section = cms_theme_section('sponsoring');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.sponsoring');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,13 @@ class VisionSection extends Component
|
|||
|
||||
public $bg = 'bg-background';
|
||||
|
||||
public function mount($bg = 'bg-background')
|
||||
/** Inhalt in weißer Box mit kleinerem Bild (Startseite, 07.10.2026); die anderen Themes bleiben offen. */
|
||||
public bool $boxed = false;
|
||||
|
||||
public function mount($bg = 'bg-background', bool $boxed = false)
|
||||
{
|
||||
$this->bg = $bg;
|
||||
$this->boxed = $boxed;
|
||||
$this->content = cms_theme_section('vision_section');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,35 @@ class AnnouncementBar extends Component
|
|||
{
|
||||
public array $content = [];
|
||||
|
||||
/** Farbwelt der Seite – jede Welt hat ihre eigene Meldung. */
|
||||
public string $world = 'interior';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->content = cms_theme_section('announcement_bar');
|
||||
$this->world = b2in_world();
|
||||
|
||||
// Interior: global.announcement_bar, Immobilien: global.announcement_bar_immobilien.
|
||||
$section = cms_theme_section($this->world === 'immobilien' ? 'announcement_bar_immobilien' : 'announcement_bar');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schluessel, unter dem sich der Browser das Wegklicken merkt.
|
||||
*
|
||||
* Leitet sich aus dem Inhalt ab, nicht nur aus der gepflegten `id`: Wird die Leiste im CMS
|
||||
* neu belegt, ohne die `id` zu aendern, bekaemen Wiederkehrer die neue Meldung sonst nie
|
||||
* zu sehen, weil ihr alter Dismiss-Eintrag im localStorage weitergilt.
|
||||
*/
|
||||
public function getDismissKeyProperty(): string
|
||||
{
|
||||
$fingerprint = implode('|', [
|
||||
$this->content['id'] ?? 'default',
|
||||
$this->content['badge'] ?? '',
|
||||
$this->content['text'] ?? '',
|
||||
$this->content['link_url'] ?? '',
|
||||
]);
|
||||
|
||||
return 'announcement_dismissed_'.substr(md5($fingerprint), 0, 12);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
166
app/Livewire/Web/Components/UI/AppointmentWidget.php
Normal file
166
app/Livewire/Web/Components/UI/AppointmentWidget.php
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Ui;
|
||||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use App\Models\CmsBrand;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Schwebende Terminanfrage – auf allen Seiten unten rechts.
|
||||
*
|
||||
* Setzt wie die übrigen Formulare der Seite auf {@see ContactFormService} und {@see SpamDetector}
|
||||
* auf, statt auf Acme\ContactForm\Livewire\ContactForm: dessen mitgelieferte View bringt ein
|
||||
* eigenes (blaues) Layout mit, das nicht zum Auftritt passt. Honeypot, Rate-Limit und
|
||||
* Spam-Erkennung kommen so trotzdem aus dem Paket.
|
||||
*/
|
||||
class AppointmentWidget extends Component
|
||||
{
|
||||
/** Feste Anliegen neben den gepflegten Marken. */
|
||||
public const EXTRA_TOPICS = [
|
||||
'projektausstattung' => 'Projektausstattung',
|
||||
'immobilien' => 'Immobilien',
|
||||
'sonstiges' => 'Sonstiges',
|
||||
];
|
||||
|
||||
/** Farbwelt der Seite – in der Immobilien-Welt ist das Anliegen vorbelegt. */
|
||||
public string $world = 'interior';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $phone = '';
|
||||
|
||||
public string $topic = '';
|
||||
|
||||
public string $preferredDate = '';
|
||||
|
||||
public string $message = '';
|
||||
|
||||
public bool $privacy = false;
|
||||
|
||||
public bool $success = false;
|
||||
|
||||
/** @var string Honeypot – muss leer bleiben. */
|
||||
public string $website = '';
|
||||
|
||||
public ?int $formLoadedAt = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$section = cms_theme_section('appointment_widget');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->formLoadedAt = time();
|
||||
$this->world = b2in_world();
|
||||
|
||||
if ($this->world === 'immobilien') {
|
||||
$this->topic = 'immobilien';
|
||||
}
|
||||
|
||||
$this->applyBrandContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf einer Markenseite trägt der Button die Beschriftung dieser Marke.
|
||||
*
|
||||
* Die Zulieferung sieht ausdrücklich keinen zweiten CTA-Button vor, sondern eine
|
||||
* kontextbezogene Beschriftung des vorhandenen Widgets. Da das Widget im Layout steckt und
|
||||
* nicht je Seite eingebunden wird, liest es den Kontext aus der Route.
|
||||
*/
|
||||
private function applyBrandContext(): void
|
||||
{
|
||||
$route = request()->route();
|
||||
|
||||
if ($route?->getName() !== 'marken.show') {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = (string) $route->parameter('slug');
|
||||
$brand = CmsBrand::forFrontend()[$slug] ?? null;
|
||||
$label = is_array($brand) ? trim((string) ($brand['widget_label'] ?? '')) : '';
|
||||
|
||||
if ($label !== '') {
|
||||
$this->content['button_label'] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auswahl „Anliegen": gepflegte Kernmarken zuerst, danach die festen Einträge.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getTopicsProperty(): array
|
||||
{
|
||||
$brands = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
$brands[$slug] = (string) ($brand['name'] ?? $slug);
|
||||
}
|
||||
|
||||
return $brands + self::EXTRA_TOPICS;
|
||||
}
|
||||
|
||||
public function getWhatsappUrlProperty(): string
|
||||
{
|
||||
return whatsapp_link();
|
||||
}
|
||||
|
||||
public function submit(ContactFormService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email:rfc', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:80'],
|
||||
'topic' => ['nullable', 'string', 'max:255'],
|
||||
'preferredDate' => ['nullable', 'date', 'after_or_equal:today'],
|
||||
'message' => ['nullable', 'string', 'max:2000'],
|
||||
'privacy' => ['accepted'],
|
||||
'website' => ['nullable', 'string', 'max:0'],
|
||||
], [
|
||||
'preferredDate.after_or_equal' => __('ui.appointment.date_in_past'),
|
||||
]);
|
||||
|
||||
$topicLabel = $this->topics[$this->topic] ?? '';
|
||||
|
||||
$payload = [
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'topic' => $topicLabel,
|
||||
'preferred_date' => $this->preferredDate,
|
||||
'message' => $this->message,
|
||||
'website' => $this->website,
|
||||
'source' => request()->path(),
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'is_spam' => false,
|
||||
];
|
||||
|
||||
$payload['is_spam'] = SpamDetector::fromConfig()->detect($payload, $this->formLoadedAt);
|
||||
|
||||
$subject = ($this->world === 'immobilien' ? 'Terminanfrage B2in Immobilien' : 'Terminanfrage B2in Interior')
|
||||
.($topicLabel !== '' ? ': '.$topicLabel : '');
|
||||
$service->handle($payload, $subject, 'appointment-widget');
|
||||
|
||||
$this->success = true;
|
||||
$this->reset(['name', 'email', 'phone', 'topic', 'preferredDate', 'message', 'privacy', 'website']);
|
||||
$this->topic = $this->world === 'immobilien' ? 'immobilien' : '';
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function startOver(): void
|
||||
{
|
||||
$this->success = false;
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.web.components.ui.appointment-widget');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ namespace App\Livewire\Web\Components\Ui;
|
|||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class ContactForm extends Component
|
||||
|
|
@ -34,16 +36,109 @@ class ContactForm extends Component
|
|||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
public function mount(): void
|
||||
/** Farbwelt der Seite (`interior` | `immobilien`). */
|
||||
public string $world = 'interior';
|
||||
|
||||
public function mount(string $world = 'interior'): void
|
||||
{
|
||||
$this->content = cms_theme_section('contact_form');
|
||||
$this->world = $world === 'immobilien' ? 'immobilien' : 'interior';
|
||||
$this->content = $this->loadContent();
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
/**
|
||||
* Inhalte der Welt. Die Immobilien-Welt liegt als Overlay über der Interior-Sektion:
|
||||
* `contact_form_immobilien` trägt nur, was abweicht (Einstieg, Betreffe) – Adresse,
|
||||
* Beschriftungen und Social Media erbt sie von `contact_form`.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function loadContent(): array
|
||||
{
|
||||
$base = cms_theme_section('contact_form');
|
||||
$base = is_array($base) ? $base : [];
|
||||
|
||||
if ($this->world !== 'immobilien') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$overlay = cms_theme_section('contact_form_immobilien');
|
||||
|
||||
if (! is_array($overlay)) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return array_replace_recursive($base, $overlay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Betreffe in Gruppen: Interior (mit den gepflegten Kernmarken vorneweg), Immobilien, B2in.
|
||||
* Die Welt der Seite bestimmt die Reihenfolge – ihre Gruppe steht oben.
|
||||
*
|
||||
* Ohne `subject_groups` (aeltere CMS-Staende) faellt das Formular auf die flache Liste zurueck.
|
||||
*
|
||||
* @return array<string, array{label: string, subjects: array<string, string>}>
|
||||
*/
|
||||
public function getSubjectGroupsProperty(): array
|
||||
{
|
||||
$groups = $this->content['form']['subject_groups'] ?? null;
|
||||
|
||||
if (! is_array($groups) || $groups === []) {
|
||||
$flat = $this->content['form']['subjects'] ?? [];
|
||||
unset($flat['']);
|
||||
|
||||
return $flat === [] ? [] : ['all' => ['label' => '', 'subjects' => $flat]];
|
||||
}
|
||||
|
||||
$brands = [];
|
||||
|
||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
||||
$brands[$slug] = (string) ($brand['name'] ?? $slug);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($groups as $key => $group) {
|
||||
$subjects = is_array($group['subjects'] ?? null) ? $group['subjects'] : [];
|
||||
|
||||
if ($key === 'interior') {
|
||||
$subjects = $brands + $subjects;
|
||||
}
|
||||
|
||||
$out[$key] = [
|
||||
'label' => (string) ($group['label'] ?? ''),
|
||||
'subjects' => $subjects,
|
||||
];
|
||||
}
|
||||
|
||||
$first = $this->world === 'immobilien' ? 'immobilien' : 'interior';
|
||||
|
||||
if (isset($out[$first])) {
|
||||
$out = [$first => $out[$first]] + $out;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle waehlbaren Betreffe als flache Liste (Schluessel → Beschriftung).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getSubjectsProperty(): array
|
||||
{
|
||||
return $this->content['form']['subjects'] ?? [];
|
||||
$flat = [];
|
||||
|
||||
foreach ($this->getSubjectGroupsProperty() as $group) {
|
||||
$flat += $group['subjects'];
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
public function getSubjectPlaceholderProperty(): string
|
||||
{
|
||||
return (string) ($this->content['form']['subjects'][''] ?? $this->content['form']['subject_placeholder'] ?? __('ui.contact_form.subject_placeholder'));
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
|
|
@ -52,19 +147,13 @@ class ContactForm extends Component
|
|||
return $this->content['contact_info'] ?? [];
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function getSocialMediaProperty(): array
|
||||
{
|
||||
return $this->content['social_media']['platforms'] ?? [];
|
||||
}
|
||||
|
||||
public function submit(ContactFormService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'firstName' => ['required', 'string', 'max:255'],
|
||||
'lastName' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email:rfc', 'max:255'],
|
||||
'subject' => ['required', 'string'],
|
||||
'subject' => ['required', 'string', Rule::in(array_keys($this->subjects))],
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
'company' => ['nullable', 'string', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:255'],
|
||||
|
|
@ -90,8 +179,9 @@ class ContactForm extends Component
|
|||
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
|
||||
|
||||
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
|
||||
$prefix = $this->world === 'immobilien' ? 'Kontaktanfrage B2in Immobilien' : 'Kontaktanfrage B2in';
|
||||
$subjectLine = $subjectLabel
|
||||
? 'Kontaktanfrage B2in: ' . $subjectLabel
|
||||
? $prefix.': '.$subjectLabel
|
||||
: __('contact-form::contact-form.default_subject');
|
||||
$service->handle($payload, $subjectLine, 'contact-form');
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,62 @@ use Livewire\Component;
|
|||
|
||||
class Footer extends Component
|
||||
{
|
||||
/** @var array<string, mixed> */
|
||||
public array $content = [];
|
||||
|
||||
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
|
||||
public string $world = 'interior';
|
||||
|
||||
/** @var array<string, array{label: string, url: string, active: bool}> */
|
||||
public array $worlds = [];
|
||||
|
||||
public string $logoPath = '';
|
||||
|
||||
/**
|
||||
* Negatives Logo für die Farbvariante „Modern“ (Nachtrag 18): dort liegt der Interior-Footer
|
||||
* dunkel in Tanne. Die Palette schaltet im Browser, deshalb liegen beide Logos im Markup und
|
||||
* das Stylesheet blendet das passende ein. Leer in der Immobilien-Welt, die ohnehin das
|
||||
* negative Logo trägt.
|
||||
*/
|
||||
public string $logoPathDark = '';
|
||||
|
||||
/**
|
||||
* Fuer die Immobilien-Welt zeigen die gemeinsamen Footer-Links auf die Seiten dieser Welt.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const IMMOBILIEN_LINKS = [
|
||||
'/faq' => '/immobilien/faq',
|
||||
'/about' => '/immobilien/ueber-b2in',
|
||||
'/magazin' => '/immobilien/magazin',
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$section = cms_theme_section('footer');
|
||||
$this->content = is_array($section) ? $section : [];
|
||||
$this->world = b2in_world();
|
||||
|
||||
if ($this->world === 'immobilien' && is_array($this->content['links'] ?? null)) {
|
||||
$this->content['links'] = array_map(function ($link) {
|
||||
if (is_array($link) && isset(self::IMMOBILIEN_LINKS[$link['url'] ?? ''])) {
|
||||
$link['url'] = self::IMMOBILIEN_LINKS[$link['url']];
|
||||
}
|
||||
|
||||
return $link;
|
||||
}, $this->content['links']);
|
||||
}
|
||||
$this->worlds = b2in_worlds($this->world);
|
||||
|
||||
// Der Interior-Footer liegt auf Sand (helle Fläche) und trägt deshalb das positive
|
||||
// Logo; der Immobilien-Footer bleibt dunkel und nutzt die negative Variante.
|
||||
$variant = $this->world === 'immobilien' ? 'negative' : 'positive';
|
||||
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath($variant, $this->world);
|
||||
$this->logoPathDark = $this->world === 'immobilien'
|
||||
? ''
|
||||
: \App\Helpers\ThemeHelper::getLogoPath('negative', $this->world);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.ui.footer');
|
||||
|
|
|
|||
|
|
@ -16,13 +16,33 @@ class Header extends Component
|
|||
'en' => 'EN',
|
||||
];
|
||||
|
||||
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
|
||||
public string $world = 'interior';
|
||||
|
||||
/** @var array<string, array{label: string, url: string, active: bool}> */
|
||||
public array $worlds = [];
|
||||
|
||||
public string $logoPath = '';
|
||||
|
||||
/** Pfad der Seite, in mount() festgehalten – ein Livewire-Request traegt ihn nicht mehr. */
|
||||
public string $currentPath = '/';
|
||||
|
||||
/** Ziel des Kontakt-Buttons: /contact (Interior) bzw. /immobilien/kontakt (Immobilien). */
|
||||
public string $contactUrl = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
|
||||
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
|
||||
$this->currentLocale = app()->getLocale();
|
||||
$this->world = b2in_world();
|
||||
$this->worlds = b2in_worlds($this->world);
|
||||
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath('positive', $this->world);
|
||||
$this->currentPath = '/'.ltrim(request()->path(), '/');
|
||||
$this->contactUrl = route($this->world === 'immobilien' ? 'immobilien.contact' : 'contact');
|
||||
|
||||
$this->content = cms_theme_section('header');
|
||||
// Jede Welt hat ihre eigene Navigation (CMS: global.header bzw. global.header_immobilien).
|
||||
$this->content = cms_theme_section($this->world === 'immobilien' ? 'header_immobilien' : 'header');
|
||||
|
||||
// Ensure required keys exist
|
||||
if (!isset($this->content['portal_login'])) {
|
||||
|
|
@ -31,6 +51,14 @@ class Header extends Component
|
|||
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
|
||||
$this->content['navigation'] = [];
|
||||
}
|
||||
|
||||
// Referenzen lassen sich im CMS ausblenden; dann verschwindet auch der Menüpunkt.
|
||||
if (! references_page_enabled()) {
|
||||
$this->content['navigation'] = array_values(array_filter(
|
||||
$this->content['navigation'],
|
||||
fn ($item) => rtrim(parse_url((string) ($item['url'] ?? ''), PHP_URL_PATH) ?? '', '/') !== '/referenzen',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function switchLanguage(string $locale): mixed
|
||||
|
|
@ -57,27 +85,43 @@ class Header extends Component
|
|||
{
|
||||
$this->isMobileMenuOpen = false;
|
||||
}
|
||||
public function isActiveRoute($url)
|
||||
/**
|
||||
* Aktiver Navigationspunkt: exakter Treffer, sonst der laengste Eintrag, dessen Pfad ein
|
||||
* Praefix der Seite ist. So markiert /immobilien/magazin nur „Magazin“, nicht auch
|
||||
* „Immobilien“. Anker (#…) zaehlen nicht zum Pfad.
|
||||
*/
|
||||
public function isActiveRoute($url): bool
|
||||
{
|
||||
$currentPath = request()->path();
|
||||
$currentPath = '/' . ltrim($currentPath, '/');
|
||||
$path = $this->navPath((string) $url);
|
||||
|
||||
// Exact match
|
||||
if ($currentPath === $url) {
|
||||
if ($this->currentPath === $path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special handling for home route
|
||||
if ($url === '/' && ($currentPath === '/' || $currentPath === '')) {
|
||||
return true;
|
||||
if ($path === '/' || ! str_starts_with($this->currentPath, rtrim($path, '/').'/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if current path starts with the navigation URL (for sub-pages)
|
||||
if ($url !== '/' && str_starts_with($currentPath, rtrim($url, '/'))) {
|
||||
return true;
|
||||
foreach ($this->content['navigation'] ?? [] as $navItem) {
|
||||
$other = $this->navPath((string) ($navItem['url'] ?? ''));
|
||||
|
||||
if ($other === $this->currentPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlen($other) > strlen($path) && str_starts_with($this->currentPath, rtrim($other, '/').'/')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private function navPath(string $url): string
|
||||
{
|
||||
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
|
||||
|
||||
return '/'.ltrim($path, '/');
|
||||
}
|
||||
public function render()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,12 +13,47 @@ class CmsArticle extends Model
|
|||
|
||||
protected $table = 'cms_articles';
|
||||
|
||||
/**
|
||||
* Alte numerische Artikel-IDs (resources/lang/{locale}/b2in.php) → Slug.
|
||||
*
|
||||
* Wird an drei Stellen gebraucht: beim Anlegen durch den CmsArticleSeeder, für den
|
||||
* Lang-Fallback und für die 301-Weiterleitung der alten /magazin/{id}-URLs.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const LEGACY_ID_TO_SLUG = [
|
||||
1 => 'escrow-system-dubai-investoren',
|
||||
2 => 'spotlight-al-jaddaf-hotspot',
|
||||
3 => 'turnkey-investments-moeblierung-rendite',
|
||||
4 => 'supply-chain-management-vertragssicherheit',
|
||||
5 => 'local-for-local-regionaler-moebelhandel',
|
||||
];
|
||||
|
||||
public const WORLD_INTERIOR = 'interior';
|
||||
|
||||
public const WORLD_IMMOBILIEN = 'immobilien';
|
||||
|
||||
/** @var array<int, string> */
|
||||
public const WORLDS = [self::WORLD_INTERIOR, self::WORLD_IMMOBILIEN];
|
||||
|
||||
/**
|
||||
* Beiträge aus dem Bestand, die zur Immobilien-Welt gehören (Dubai-Themen).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const LEGACY_IMMOBILIEN_SLUGS = [
|
||||
'escrow-system-dubai-investoren',
|
||||
'spotlight-al-jaddaf-hotspot',
|
||||
'turnkey-investments-moeblierung-rendite',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'title',
|
||||
'subtitle',
|
||||
'image',
|
||||
'category',
|
||||
'world',
|
||||
'date_label',
|
||||
'read_time',
|
||||
'author',
|
||||
|
|
@ -53,6 +88,104 @@ class CmsArticle extends Model
|
|||
return $query->orderBy('order')->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
public function scopeForWorld(Builder $query, string $world): Builder
|
||||
{
|
||||
return $query->where('world', $world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Artikel fürs Frontend, nach Slug indiziert.
|
||||
*
|
||||
* Das CMS gewinnt. Solange keine Artikel gepflegt sind, greift die Lang-Datei als Fallback –
|
||||
* dieselbe Reihenfolge wie bei cms_theme_section(), damit das Magazin auf einer frischen
|
||||
* Installation nicht leer ist.
|
||||
*
|
||||
* Mit `$world` nur die Beiträge dieser Farbwelt (Interior oder Immobilien) – die beiden
|
||||
* Magazinlisten sind getrennt, die Detailseite rendert in der Welt ihres Beitrags.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function forFrontend(?string $world = null): array
|
||||
{
|
||||
$maintained = static::query()->published()->exists();
|
||||
|
||||
if ($maintained) {
|
||||
return static::query()
|
||||
->published()
|
||||
->when($world !== null, fn (Builder $query) => $query->forWorld($world))
|
||||
->ordered()
|
||||
->get()
|
||||
->mapWithKeys(fn (self $article) => [$article->slug => $article->toFrontendArray()])
|
||||
->all();
|
||||
}
|
||||
|
||||
$articles = static::fallbackFromLang();
|
||||
|
||||
if ($world === null) {
|
||||
return $articles;
|
||||
}
|
||||
|
||||
return array_filter($articles, fn (array $article) => ($article['world'] ?? self::WORLD_INTERIOR) === $world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug zu einer alten numerischen Magazin-ID, falls bekannt.
|
||||
*/
|
||||
public static function slugForLegacyId(int|string $id): ?string
|
||||
{
|
||||
return self::LEGACY_ID_TO_SLUG[(int) $id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slug eines Artikels aus der Lang-Datei.
|
||||
*
|
||||
* Neue Artikel bringen ihren Slug selbst mit (`'slug' => …`). Die fünf ursprünglichen
|
||||
* Artikel hatten keinen und werden weiter über {@see LEGACY_ID_TO_SLUG} aufgelöst – die
|
||||
* Konstante bleibt damit das, was sie ist: die Karte für die alten `/magazin/{id}`-URLs.
|
||||
*
|
||||
* @param array<string, mixed> $article
|
||||
*/
|
||||
public static function slugForLangArticle(int|string $id, array $article): ?string
|
||||
{
|
||||
$slug = $article['slug'] ?? null;
|
||||
|
||||
if (is_string($slug) && $slug !== '') {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
return self::slugForLegacyId($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function fallbackFromLang(): array
|
||||
{
|
||||
$articles = trans('b2in.articles');
|
||||
|
||||
if (! is_array($articles)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($articles as $id => $article) {
|
||||
if (! is_array($article) || ($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>
|
||||
*/
|
||||
|
|
@ -65,6 +198,7 @@ class CmsArticle extends Model
|
|||
'subtitle' => $this->subtitle,
|
||||
'image' => $this->image,
|
||||
'category' => $this->category,
|
||||
'world' => $this->world ?? self::WORLD_INTERIOR,
|
||||
'date' => $this->date_label,
|
||||
'readTime' => $this->read_time,
|
||||
'author' => $this->author ?? [],
|
||||
|
|
|
|||
389
app/Models/CmsBrand.php
Normal file
389
app/Models/CmsBrand.php
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* Kernmarke mit eigener Detailseite (CABINET, Rotpunkt Küchen, Febrü …).
|
||||
*
|
||||
* Nicht zu verwechseln mit {@see Brand} – das ist die Produkt-/Partner-Marke.
|
||||
*/
|
||||
class CmsBrand extends Model
|
||||
{
|
||||
use HasFactory, HasTranslations;
|
||||
|
||||
/** Tatsaechlich ausgefuehrtes Projekt – ein Foto aus einem echten Auftrag. */
|
||||
public const GALLERY_PROJECT = 'project';
|
||||
|
||||
/** Entwurf, Rendering oder KI-gestuetzte Darstellung – nicht realisiert. */
|
||||
public const GALLERY_INSPIRATION = 'inspiration';
|
||||
|
||||
/** Einblick in Werkstatt, Fertigung oder Montage des Herstellers – weder Projekt noch Entwurf (Nachtrag 19). */
|
||||
public const GALLERY_COMPANY = 'company';
|
||||
|
||||
/** @var list<string> */
|
||||
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION, self::GALLERY_COMPANY];
|
||||
|
||||
protected $table = 'cms_brands';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'tagline',
|
||||
'teaser',
|
||||
'summary',
|
||||
'highlights',
|
||||
'description',
|
||||
'logo',
|
||||
'image',
|
||||
'services',
|
||||
'sections',
|
||||
'widget_label',
|
||||
'cta_title',
|
||||
'cta_text',
|
||||
'gallery',
|
||||
'website_url',
|
||||
'showroom',
|
||||
'origin_city',
|
||||
'origin_distance',
|
||||
'origin_role',
|
||||
'is_published',
|
||||
'order',
|
||||
];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $translatable = [
|
||||
'name',
|
||||
'tagline',
|
||||
'teaser',
|
||||
'summary',
|
||||
'description',
|
||||
'widget_label',
|
||||
'cta_title',
|
||||
'cta_text',
|
||||
'origin_city',
|
||||
'origin_distance',
|
||||
'origin_role',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'highlights' => 'array',
|
||||
'services' => 'array',
|
||||
'sections' => 'array',
|
||||
'gallery' => 'array',
|
||||
'is_published' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_published', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('order')->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Kernmarken fürs Frontend, nach Slug indiziert.
|
||||
*
|
||||
* Das CMS gewinnt. Solange keine Marke gepflegt ist, greift die Liste `core_brands.brands`
|
||||
* der Lang-Datei als Fallback, damit die Startseite auf einer frischen Installation nicht ohne
|
||||
* Kernmarken dasteht.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function forFrontend(): array
|
||||
{
|
||||
$brands = static::query()->published()->ordered()->get();
|
||||
|
||||
if ($brands->isNotEmpty()) {
|
||||
return $brands
|
||||
->mapWithKeys(fn (self $brand) => [$brand->slug => $brand->toFrontendArray()])
|
||||
->all();
|
||||
}
|
||||
|
||||
return static::fallbackFromLang();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function fallbackFromLang(): array
|
||||
{
|
||||
// Direkt aus der Lang-Datei: Im CMS-Eintrag `home.core_brands` steht die Liste nicht
|
||||
// (mehr), dort pflegt man nur Überschrift und Untertitel – die Marken in der Markenverwaltung.
|
||||
$brands = trans('b2in.themes.'.config('app.theme', 'b2in').'.core_brands.brands');
|
||||
$brands = is_array($brands) ? $brands : [];
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
if (! is_array($brand) || empty($brand['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = $brand['slug'] ?? \Illuminate\Support\Str::slug((string) $brand['name']);
|
||||
|
||||
$entry = array_merge([
|
||||
'slug' => $slug,
|
||||
'tagline' => '',
|
||||
'teaser' => '',
|
||||
'summary' => '',
|
||||
'highlights' => [],
|
||||
'description' => '',
|
||||
'logo' => '',
|
||||
'image' => '',
|
||||
'services' => [],
|
||||
'sections' => [],
|
||||
'widget_label' => '',
|
||||
'cta_title' => '',
|
||||
'cta_text' => '',
|
||||
'gallery' => [],
|
||||
'website_url' => '',
|
||||
'showroom' => '',
|
||||
'origin_city' => '',
|
||||
'origin_distance' => '',
|
||||
'origin_role' => '',
|
||||
'has_detail_page' => false,
|
||||
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
|
||||
|
||||
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
|
||||
$entry['sections'] = self::normalizeSections($entry['sections']);
|
||||
|
||||
$out[$slug] = $entry;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitenabschnitte in eine feste Form bringen.
|
||||
*
|
||||
* Jeder Abschnitt hat einen Titel und Absätze. Optional dazu:
|
||||
* - `claim` hervorgehobene Schlusszeile
|
||||
* - `quote` Zitat mit `quote_author`
|
||||
* - `detail` Vertiefung, die im Frontend aufklappbar dargestellt wird
|
||||
*
|
||||
* @param mixed $sections
|
||||
* @return list<array{title: string, paragraphs: list<string>, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list<string>}|null}>
|
||||
*/
|
||||
public static function normalizeSections($sections): array
|
||||
{
|
||||
if (! is_array($sections)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
if (! is_array($section)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paragraphs = self::paragraphList($section['paragraphs'] ?? []);
|
||||
$title = trim((string) ($section['title'] ?? ''));
|
||||
|
||||
if ($title === '' && $paragraphs === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$detail = null;
|
||||
|
||||
if (is_array($section['detail'] ?? null)) {
|
||||
$detailParagraphs = self::paragraphList($section['detail']['paragraphs'] ?? []);
|
||||
$detailTitle = trim((string) ($section['detail']['title'] ?? ''));
|
||||
|
||||
if ($detailTitle !== '' || $detailParagraphs !== []) {
|
||||
$detail = ['title' => $detailTitle, 'paragraphs' => $detailParagraphs];
|
||||
}
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'title' => $title,
|
||||
'paragraphs' => $paragraphs,
|
||||
'claim' => trim((string) ($section['claim'] ?? '')),
|
||||
'quote' => trim((string) ($section['quote'] ?? '')),
|
||||
'quote_author' => trim((string) ($section['quote_author'] ?? '')),
|
||||
'detail' => $detail,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absätze eines Abschnitts für die Ausgabe gruppieren.
|
||||
*
|
||||
* Ein Absatz, der mit „- “ beginnt, ist ein Listenpunkt; aufeinanderfolgende Punkte bilden
|
||||
* eine Liste. Steht im Punkt ein Doppelpunkt, wird der Teil davor als fette Einleitung
|
||||
* ausgegeben („- Sichere Prüfung & Terminierung: Wir vereinbaren …“). So lässt sich eine
|
||||
* Aufzählung im Backend im normalen Textfeld pflegen.
|
||||
*
|
||||
* @param list<string> $paragraphs
|
||||
* @return list<array{type: 'paragraph', text: string}|array{type: 'list', items: list<array{lead: string, text: string}>}>
|
||||
*/
|
||||
public static function paragraphBlocks(array $paragraphs): array
|
||||
{
|
||||
$blocks = [];
|
||||
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
if (! preg_match('/^[-–•]\s+(.+)$/su', $paragraph, $match)) {
|
||||
$blocks[] = ['type' => 'paragraph', 'text' => $paragraph];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$lead, $text] = str_contains($match[1], ': ')
|
||||
? array_map('trim', explode(': ', $match[1], 2))
|
||||
: ['', trim($match[1])];
|
||||
|
||||
$last = array_key_last($blocks);
|
||||
|
||||
if ($last === null || $blocks[$last]['type'] !== 'list') {
|
||||
$blocks[] = ['type' => 'list', 'items' => []];
|
||||
$last = array_key_last($blocks);
|
||||
}
|
||||
|
||||
$blocks[$last]['items'][] = ['lead' => $lead, 'text' => $text];
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $paragraphs
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function paragraphList($paragraphs): array
|
||||
{
|
||||
if (is_string($paragraphs)) {
|
||||
$paragraphs = preg_split('/\R{2,}/', $paragraphs) ?: [];
|
||||
}
|
||||
|
||||
if (! is_array($paragraphs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
fn ($p) => trim((string) (is_array($p) ? ($p['text'] ?? '') : $p)),
|
||||
$paragraphs,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie-Eintraege in eine feste Form bringen.
|
||||
*
|
||||
* Ein Eintrag ohne (oder mit unbekannter) Kategorie gilt als Inspiration, nicht als Projekt:
|
||||
* Ein Entwurf, der versehentlich als Referenz durchgeht, waere die schaedlichere Verwechslung.
|
||||
*
|
||||
* @param mixed $gallery
|
||||
* @return list<array{image: string, category: string, caption: string}>
|
||||
*/
|
||||
public static function normalizeGallery($gallery): array
|
||||
{
|
||||
if (! is_array($gallery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($gallery as $item) {
|
||||
$image = is_array($item) ? (string) ($item['image'] ?? '') : (string) $item;
|
||||
$image = trim($image);
|
||||
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$category = is_array($item) ? (string) ($item['category'] ?? '') : '';
|
||||
|
||||
$out[] = [
|
||||
'image' => $image,
|
||||
'category' => in_array($category, self::GALLERY_CATEGORIES, true)
|
||||
? $category
|
||||
: self::GALLERY_INSPIRATION,
|
||||
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie nach realisierten Projekten, Einblicken in die Werkstatt und Inspirationen getrennt.
|
||||
*
|
||||
* @param mixed $gallery
|
||||
* @return array{projects: list<array{image: string, category: string, caption: string}>, company: list<array{image: string, category: string, caption: string}>, inspirations: list<array{image: string, category: string, caption: string}>}
|
||||
*/
|
||||
public static function groupGallery($gallery): array
|
||||
{
|
||||
$items = self::normalizeGallery($gallery);
|
||||
|
||||
return [
|
||||
'projects' => array_values(array_filter(
|
||||
$items,
|
||||
fn (array $item) => $item['category'] === self::GALLERY_PROJECT,
|
||||
)),
|
||||
'company' => array_values(array_filter(
|
||||
$items,
|
||||
fn (array $item) => $item['category'] === self::GALLERY_COMPANY,
|
||||
)),
|
||||
'inspirations' => array_values(array_filter(
|
||||
$items,
|
||||
fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION,
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschriftung des Badges auf dem Galeriebild („Realisiertes Projekt“, „Aus der Werkstatt“, „Visualisierung“).
|
||||
*/
|
||||
public static function galleryBadge(string $category): string
|
||||
{
|
||||
return match ($category) {
|
||||
self::GALLERY_PROJECT => __('ui.brand_gallery_badge_project'),
|
||||
self::GALLERY_COMPANY => __('ui.brand_gallery_badge_company'),
|
||||
default => __('ui.brand_gallery_badge_inspiration'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toFrontendArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'name' => $this->name,
|
||||
'tagline' => $this->tagline,
|
||||
'teaser' => $this->teaser,
|
||||
'summary' => $this->summary,
|
||||
'highlights' => $this->highlights ?? [],
|
||||
'description' => $this->description,
|
||||
'logo' => $this->logo,
|
||||
'image' => $this->image,
|
||||
'services' => $this->services ?? [],
|
||||
'sections' => self::normalizeSections($this->sections),
|
||||
'widget_label' => (string) $this->widget_label,
|
||||
'cta_title' => (string) $this->cta_title,
|
||||
'cta_text' => (string) $this->cta_text,
|
||||
'gallery' => self::normalizeGallery($this->gallery),
|
||||
'website_url' => $this->website_url,
|
||||
'showroom' => $this->showroom,
|
||||
'origin_city' => (string) $this->origin_city,
|
||||
'origin_distance' => (string) $this->origin_distance,
|
||||
'origin_role' => (string) $this->origin_role,
|
||||
'has_detail_page' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
115
app/Models/CmsReference.php
Normal file
115
app/Models/CmsReference.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
/**
|
||||
* Referenzprojekt der Interior-Welt: ein realisierter Auftrag mit Titelbild, weiteren Bildern
|
||||
* und den beteiligten Kernmarken (Nachtrag 13). Erscheint auf /referenzen.
|
||||
*
|
||||
* Nicht zu verwechseln mit {@see CmsProject} – das sind die Immobilienprojekte.
|
||||
*/
|
||||
class CmsReference extends Model
|
||||
{
|
||||
use HasFactory, HasTranslations;
|
||||
|
||||
protected $table = 'cms_references';
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'location',
|
||||
'year',
|
||||
'description',
|
||||
'image',
|
||||
'gallery',
|
||||
'brand_slugs',
|
||||
'is_published',
|
||||
'order',
|
||||
];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $translatable = ['title', 'description'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'gallery' => 'array',
|
||||
'brand_slugs' => 'array',
|
||||
'is_published' => 'boolean',
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_published', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('order')->orderBy('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie in eine feste Form bringen: nur Einträge mit Bild, Bildunterschrift getrimmt.
|
||||
*
|
||||
* @param mixed $gallery
|
||||
* @return list<array{image: string, caption: string}>
|
||||
*/
|
||||
public static function normalizeGallery($gallery): array
|
||||
{
|
||||
if (! is_array($gallery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($gallery as $item) {
|
||||
$image = trim((string) (is_array($item) ? ($item['image'] ?? '') : $item));
|
||||
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'image' => $image,
|
||||
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beteiligte Marken als Liste mit Name und Link, aufgelöst gegen die veröffentlichten Marken.
|
||||
*
|
||||
* Eine unveröffentlichte oder gelöschte Marke fällt heraus.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $brands Ergebnis von CmsBrand::forFrontend()
|
||||
* @return list<array{slug: string, name: string, link: ?string}>
|
||||
*/
|
||||
public function resolveBrands(array $brands): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($this->brand_slugs ?? [] as $slug) {
|
||||
$slug = (string) $slug;
|
||||
|
||||
if (! isset($brands[$slug])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'slug' => $slug,
|
||||
'name' => (string) ($brands[$slug]['name'] ?? $slug),
|
||||
'link' => ! empty($brands[$slug]['has_detail_page']) ? '/marken/'.$slug : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\OperationalAlert;
|
||||
use Illuminate\Foundation\Console\AboutCommand;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
|
|
@ -29,5 +33,17 @@ class AppServiceProvider extends ServiceProvider
|
|||
if ($scheme === 'https') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
|
||||
Queue::failing(function (JobFailed $event): void {
|
||||
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
|
||||
'connection' => $event->connectionName,
|
||||
'queue' => $event->job->getQueue(),
|
||||
'exception' => $event->exception->getMessage(),
|
||||
]);
|
||||
});
|
||||
|
||||
// Ausgelieferte Version in `php artisan about` (ABOUT_SECTION in dev/betrieb/deploy.sh)
|
||||
AboutCommand::add('B2in', fn (): array => ['Version' => config('app.version') ?? '—']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ class ThemeServiceProvider extends ServiceProvider
|
|||
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
|
||||
}
|
||||
|
||||
// Unbekannter Host (Vorschau-, Staging- oder IP-Aufruf): Bis hierher stünde in `url` noch
|
||||
// APP_URL bzw. der Vorgabewert aus config/domains.php – also eine *fremde* Domain. Alle
|
||||
// Assets und Links würden dorthin zeigen, obwohl die Instanz unter einem anderen Namen
|
||||
// läuft. Außerhalb von `production` gilt deshalb der tatsächliche Request-Origin.
|
||||
//
|
||||
// In `production` bleibt es bewusst bei der konfigurierten URL: dort darf ein gefälschter
|
||||
// Host-Header keine Links auf eine fremde Domain erzeugen.
|
||||
if ($domainConfig['domain_name'] !== $host && config('app.env') !== 'production') {
|
||||
$domainConfig['domain_name'] = $host;
|
||||
$domainConfig['url'] = Request::getSchemeAndHttpHost();
|
||||
}
|
||||
|
||||
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
|
||||
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
|
||||
$assetUrl = $domainConfig['url'];
|
||||
|
|
|
|||
187
app/Services/CmsPageTree.php
Normal file
187
app/Services/CmsPageTree.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Seitenbaum für den Inhalte-Editor (config/cms_pages.php, Nachtrag 12).
|
||||
*
|
||||
* Verbindet die Registry mit dem Datenbestand: je Sektion, ob sie in flux_cms_contents liegt
|
||||
* (dann mit `content_id`), und was sonst noch in der Datenbank steht, aber in keiner Seite
|
||||
* eingetragen ist („Nicht mehr verwendet“).
|
||||
*/
|
||||
class CmsPageTree
|
||||
{
|
||||
/** Schlüssel des Sammelpunkts für nicht eingetragene Sektionen. */
|
||||
public const UNLISTED = 'unlisted';
|
||||
|
||||
/**
|
||||
* Alle Seiten mit ihren Sektionen in Website-Reihenfolge.
|
||||
*
|
||||
* @return array<string, array{title: string, path: ?string, sections: list<array<string, mixed>>}>
|
||||
*/
|
||||
public function pages(): array
|
||||
{
|
||||
$rows = CmsContent::query()
|
||||
->get(['id', 'group', 'key'])
|
||||
->keyBy(fn (CmsContent $row) => $row->group.'/'.$row->key);
|
||||
|
||||
$pages = [];
|
||||
|
||||
foreach (config('cms_pages.pages', []) as $pageKey => $page) {
|
||||
$sections = [];
|
||||
|
||||
foreach ($page['sections'] ?? [] as $section) {
|
||||
$id = $section['group'].'/'.$section['key'];
|
||||
|
||||
$sections[] = array_merge($section, [
|
||||
'page' => $pageKey,
|
||||
'path' => $section['path'] ?? $page['path'] ?? null,
|
||||
'anchor' => $section['anchor'] ?? null,
|
||||
'content_id' => $rows[$id]->id ?? null,
|
||||
'theme_key' => $this->themeKey($section['group'], $section['key']),
|
||||
]);
|
||||
}
|
||||
|
||||
$pages[$pageKey] = [
|
||||
'title' => $page['title'],
|
||||
'path' => $page['path'] ?? null,
|
||||
'sections' => $sections,
|
||||
];
|
||||
}
|
||||
|
||||
$unlisted = $this->unlistedSections($rows->keys()->all(), $rows);
|
||||
|
||||
if ($unlisted !== []) {
|
||||
$pages[self::UNLISTED] = [
|
||||
'title' => config('cms_pages.unlisted.title', 'Nicht mehr verwendet'),
|
||||
'path' => null,
|
||||
'sections' => $unlisted,
|
||||
];
|
||||
}
|
||||
|
||||
return $pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Sektion aus der Registry samt Datenbankbezug, oder null.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function section(string $group, string $key): ?array
|
||||
{
|
||||
foreach ($this->pages() as $page) {
|
||||
foreach ($page['sections'] as $section) {
|
||||
if ($section['group'] === $group && $section['key'] === $key) {
|
||||
return $section;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klartext-Beschriftung eines Feldes: Sektions-Override, dann Wörterbuch, sonst lesbar gemacht.
|
||||
*/
|
||||
public function fieldLabel(string $field, ?string $group = null, ?string $key = null): string
|
||||
{
|
||||
if ($group !== null && $key !== null) {
|
||||
foreach (config('cms_pages.pages', []) as $page) {
|
||||
foreach ($page['sections'] ?? [] as $section) {
|
||||
if ($section['group'] === $group && $section['key'] === $key && isset($section['fields'][$field])) {
|
||||
return $section['fields'][$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$labels = config('cms_pages.field_labels', []);
|
||||
|
||||
if (isset($labels[$field])) {
|
||||
return $labels[$field];
|
||||
}
|
||||
|
||||
return Str::ucfirst(str_replace('_', ' ', $field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse der Vorschau für eine Sektion: Frontend-Domain, Pfad, Cache-Brecher und Sprungmarke.
|
||||
*/
|
||||
public function previewUrl(?array $section, int $version = 0): ?string
|
||||
{
|
||||
$path = $section['path'] ?? null;
|
||||
|
||||
if ($path === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = rtrim(config('domains.protocol', 'https://').config('domains.domain_b2in'), '/')
|
||||
.'/'.ltrim($path, '/')
|
||||
.'?cms-preview='.$version;
|
||||
|
||||
if (! empty($section['anchor'])) {
|
||||
$url .= '#'.$section['anchor'];
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-Section-Schlüssel (Lang-Datei) zu Gruppe/Key, für „Aus Vorlage anlegen“.
|
||||
*/
|
||||
public function themeKey(string $group, string $key): ?string
|
||||
{
|
||||
foreach (config('cms_section_map.sections', []) as $themeKey => $target) {
|
||||
if ($target === [$group, $key]) {
|
||||
return $themeKey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $ids
|
||||
* @param \Illuminate\Support\Collection<string, CmsContent> $rows
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function unlistedSections(array $ids, $rows): array
|
||||
{
|
||||
$listed = [];
|
||||
|
||||
foreach (config('cms_pages.pages', []) as $page) {
|
||||
foreach ($page['sections'] ?? [] as $section) {
|
||||
$listed[$section['group'].'/'.$section['key']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (isset($listed[$id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row = $rows[$id];
|
||||
|
||||
$out[] = [
|
||||
'group' => $row->group,
|
||||
'key' => $row->key,
|
||||
'page' => self::UNLISTED,
|
||||
'path' => null,
|
||||
'anchor' => null,
|
||||
'title' => $row->group.' / '.$row->key,
|
||||
'description' => null,
|
||||
'content_id' => $row->id,
|
||||
'theme_key' => null,
|
||||
];
|
||||
}
|
||||
|
||||
usort($out, fn (array $a, array $b) => [$a['group'], $a['key']] <=> [$b['group'], $b['key']]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
198
app/Services/Monitoring/HealthProbe.php
Normal file
198
app/Services/Monitoring/HealthProbe.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?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];
|
||||
}
|
||||
}
|
||||
|
|
@ -27,8 +27,10 @@ class DisplayModuleSettings
|
|||
],
|
||||
DisplayVersionType::B2in->value => [
|
||||
'theme' => 'dark',
|
||||
'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
|
||||
'header_claim' => 'Connecting Design & Property',
|
||||
// Standard-Theme ist dunkel, das Logo liegt dort auf einem dunklen Scrim –
|
||||
// deshalb die negative (weisse) Variante.
|
||||
'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg',
|
||||
'header_claim' => 'Premium-Einrichtung. Persönlich gemacht.',
|
||||
'logo_position' => 'top-left',
|
||||
'claim_position' => 'top-right',
|
||||
'show_logo' => true,
|
||||
|
|
|
|||
159
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
159
app/Support/Monitoring/ErrorReportScrubber.php
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/Support/Monitoring/ErrorSignal.php
Normal file
68
app/Support/Monitoring/ErrorSignal.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
36
app/Support/Monitoring/Release.php
Normal file
36
app/Support/Monitoring/Release.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
96
app/Support/OperationalAlert.php
Normal file
96
app/Support/OperationalAlert.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
170
app/helpers.php
170
app/helpers.php
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
|
||||
|
|
@ -48,8 +49,6 @@ if (! function_exists('cms_theme_section')) {
|
|||
|
||||
/** @var array<string, array{0: string, 1: string}> $sections */
|
||||
$sections = config('cms_section_map.sections', []);
|
||||
/** @var array<string, string> $langKeys */
|
||||
$langKeys = config('cms_section_map.lang_keys', []);
|
||||
|
||||
if (isset($sections[$sectionKey])) {
|
||||
[$group, $key] = $sections[$sectionKey];
|
||||
|
|
@ -60,9 +59,47 @@ if (! function_exists('cms_theme_section')) {
|
|||
}
|
||||
}
|
||||
|
||||
$langKey = $langKeys[$sectionKey] ?? $sectionKey;
|
||||
return trans("b2in.themes.{$theme}.{$sectionKey}");
|
||||
}
|
||||
}
|
||||
|
||||
return trans("b2in.themes.{$theme}.{$langKey}");
|
||||
if (! function_exists('references_page_enabled')) {
|
||||
/**
|
||||
* Ob die Seite /referenzen samt Menüpunkt sichtbar ist – Schalter im CMS-Modul „Referenzen“
|
||||
* (Einstellung pages.referenzen_enabled). Ohne Eintrag ist die Seite sichtbar.
|
||||
*/
|
||||
function references_page_enabled(): bool
|
||||
{
|
||||
return (bool) Setting::getValue('pages', 'referenzen_enabled', true);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('whatsapp_link')) {
|
||||
/**
|
||||
* Klick-zum-Chat-Link, sofern im CMS eine Nummer hinterlegt ist (global.contact.whatsapp).
|
||||
*
|
||||
* Liefert einen leeren String, solange keine Nummer bestaetigt ist – der Button erscheint dann
|
||||
* gar nicht erst, statt auf eine erfundene Nummer zu zeigen.
|
||||
*/
|
||||
function whatsapp_link(): string
|
||||
{
|
||||
$service = app(CmsContentService::class);
|
||||
|
||||
// Die Nummer kann als eigener Eintrag oder als Feld der Sektion `global.contact` liegen.
|
||||
$number = $service->getIfExists('global.contact.whatsapp');
|
||||
|
||||
if ($number === null) {
|
||||
$contact = $service->getIfExists('global.contact');
|
||||
$number = is_array($contact) ? ($contact['whatsapp'] ?? null) : null;
|
||||
}
|
||||
|
||||
if (! is_string($number)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D+/', '', $number);
|
||||
|
||||
return $digits === '' ? '' : 'https://wa.me/'.$digits;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +131,13 @@ if (! function_exists('theme_image_url')) {
|
|||
return asset('storage/'.$trimmed);
|
||||
}
|
||||
|
||||
// Liegt die Datei unter public/img/assets/, kommt sie von dort – auch wenn sie inzwischen in
|
||||
// der Mediathek steht (AssetMediaImportSeeder). Nur so findet <x-web-picture> die WebP-Fassung
|
||||
// neben JPG/PNG.
|
||||
if (str_contains($trimmed, '/') && is_file(public_path('img/assets/'.$trimmed))) {
|
||||
return asset('img/assets/'.$trimmed);
|
||||
}
|
||||
|
||||
$candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)])));
|
||||
foreach ($candidates as $candidate) {
|
||||
if (CmsMedia::query()->where('filename', $candidate)->exists()) {
|
||||
|
|
@ -123,29 +167,14 @@ if (! function_exists('tcms')) {
|
|||
}
|
||||
}
|
||||
|
||||
if (! function_exists('cms_media_url')) {
|
||||
/**
|
||||
* Resolve a CMS content key (type=image) to a full media URL.
|
||||
* Falls back to asset('assets/images/...') if not found in media library.
|
||||
*/
|
||||
function cms_media_url(string $key, string $profile = ''): string
|
||||
{
|
||||
$filename = cms($key);
|
||||
|
||||
if (! $filename || ! is_string($filename) || $filename === $key) {
|
||||
$fallback = str_replace('.', '/', $key);
|
||||
|
||||
return asset('assets/images/'.basename($fallback));
|
||||
}
|
||||
|
||||
return media_url($filename, $profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('media_url')) {
|
||||
/**
|
||||
* Resolve a CmsMedia filename to its full storage URL.
|
||||
* Uses in-memory cache to avoid repeated DB queries.
|
||||
* Cached per request to avoid repeated DB queries.
|
||||
*
|
||||
* Ohne Eintrag in der Mediathek zeigt die URL auf public/img/assets/ – dort legen die Seeder
|
||||
* Marken-, Magazin- und Slideshow-Bilder ab (wie theme_image_url() im Frontend). Der frühere
|
||||
* Rückfall assets/images/ existierte nicht und lieferte im Admin 404 (07.10.2026).
|
||||
*/
|
||||
function media_url(?string $filename, string $profile = ''): string
|
||||
{
|
||||
|
|
@ -153,27 +182,80 @@ if (! function_exists('media_url')) {
|
|||
return '';
|
||||
}
|
||||
|
||||
static $resolved = [];
|
||||
$cacheKey = $filename.'|'.$profile;
|
||||
// once(): je Request (und je Test) zwischengespeichert, nicht über die ganze Prozesslaufzeit.
|
||||
return once(function () use ($filename, $profile): string {
|
||||
$media = CmsMedia::where('filename', $filename)->first();
|
||||
|
||||
if (isset($resolved[$cacheKey])) {
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
if (! $media) {
|
||||
return asset('img/assets/'.ltrim($filename, '/'));
|
||||
}
|
||||
|
||||
$media = CmsMedia::where('filename', $filename)->first();
|
||||
|
||||
if (! $media) {
|
||||
$resolved[$cacheKey] = asset('assets/images/'.$filename);
|
||||
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
|
||||
if ($profile && $media->hasConversion($profile)) {
|
||||
$resolved[$cacheKey] = $media->getConversionUrl($profile);
|
||||
} else {
|
||||
$resolved[$cacheKey] = $media->getUrl();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (is_string($routeName) && (str_starts_with($routeName, 'immobilien') || str_starts_with($routeName, 'dev.immobilien'))) {
|
||||
return 'immobilien';
|
||||
}
|
||||
|
||||
return 'interior';
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('b2in_worlds')) {
|
||||
/**
|
||||
* Die beiden Welten für den Welten-Wechsler in Kopf- und Fußzeile.
|
||||
*
|
||||
* @return array<string, array{label: string, url: string, active: bool}>
|
||||
*/
|
||||
function b2in_worlds(?string $current = null): array
|
||||
{
|
||||
$current = $current ?? b2in_world();
|
||||
|
||||
return [
|
||||
'interior' => [
|
||||
'label' => __('ui.world_interior'),
|
||||
'url' => url('/'),
|
||||
'active' => $current === 'interior',
|
||||
],
|
||||
'immobilien' => [
|
||||
'label' => __('ui.world_immobilien'),
|
||||
'url' => route('immobilien'),
|
||||
'active' => $current === 'immobilien',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('b2in_world_set')) {
|
||||
/**
|
||||
* Welt für den laufenden Request festlegen, wenn sie nicht an der Route hängt –
|
||||
* etwa auf der Magazin-Detailseite, die in der Welt ihres Beitrags rendert.
|
||||
* Muss vor dem Rendern von Kopf- und Fußzeile geschehen (beide halten die Welt in mount() fest).
|
||||
*/
|
||||
function b2in_world_set(string $world): void
|
||||
{
|
||||
app()->instance('b2in.world', $world);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,25 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\HealthController;
|
||||
use App\Http\Middleware\ReportErrorSignals;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Sentry\Laravel\Integration;
|
||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/domains.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
then: function (): void {
|
||||
// Tiefer Health-Check für den externen Monitor (Uptime Kuma), auf jeder Domain
|
||||
Route::get('/health', HealthController::class)
|
||||
->middleware('throttle:30,1')
|
||||
->name('health.deep');
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
|
||||
|
|
@ -27,11 +38,20 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
|
||||
]);
|
||||
|
||||
// Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) –
|
||||
// global, weil 404 vor dem Routing entsteht
|
||||
$middleware->append(ReportErrorSignals::class);
|
||||
|
||||
// BasicAuth ganz am Ende, nach Session-Middleware
|
||||
if (env('BASIC_AUTH_ENABLED', true)) {
|
||||
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
|
||||
}
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
// Gemeldete Exceptions an GlitchTip (ohne DSN ein No-op);
|
||||
// Laravels dontReport (404, Validierung, Auth) gilt weiter
|
||||
Integration::handles($exceptions);
|
||||
|
||||
// Vertippte artisan-Befehle sind kein Anwendungsfehler
|
||||
$exceptions->dontReport(CommandNotFoundException::class);
|
||||
})->create();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"livewire/flux": "^2.1.1",
|
||||
"livewire/flux-pro": "^2.6",
|
||||
"livewire/volt": "^1.7.0",
|
||||
"sentry/sentry-laravel": "^4.29",
|
||||
"spatie/laravel-permission": "^6.17"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
|
|||
553
composer.lock
generated
553
composer.lock
generated
|
|
@ -4,11 +4,11 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "9e07d437a063526b426b920d400db8b8",
|
||||
"content-hash": "f2a8196dfd6ef735f3e827805a8a1f94",
|
||||
"packages": [
|
||||
{
|
||||
"name": "acme/contact-form",
|
||||
"version": "dev-master",
|
||||
"version": "dev-feature/b2in-interior",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "packages/acme/contact-form",
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
},
|
||||
{
|
||||
"name": "acme/cookie-consent",
|
||||
"version": "dev-master",
|
||||
"version": "dev-feature/b2in-interior",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "packages/acme/CookieConsent",
|
||||
|
|
@ -860,7 +860,7 @@
|
|||
},
|
||||
{
|
||||
"name": "flux-cms/core",
|
||||
"version": "dev-master",
|
||||
"version": "dev-feature/b2in-interior",
|
||||
"dist": {
|
||||
"type": "path",
|
||||
"url": "packages/flux-cms/core",
|
||||
|
|
@ -1603,6 +1603,66 @@
|
|||
],
|
||||
"time": "2026-02-19T13:11:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/fortify",
|
||||
"version": "v1.35.0",
|
||||
|
|
@ -3493,6 +3553,84 @@
|
|||
],
|
||||
"time": "2026-02-16T23:10:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nyholm/psr7",
|
||||
"version": "1.8.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Nyholm/psr7.git",
|
||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/message-factory-implementation": "1.0",
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
"psr/http-message-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"http-interop/http-factory-tests": "^0.9",
|
||||
"php-http/message-factory": "^1.0",
|
||||
"php-http/psr7-integration-tests": "^1.0",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
|
||||
"symfony/error-handler": "^4.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nyholm\\Psr7\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Martijn van der Ven",
|
||||
"email": "martijn@vanderven.se"
|
||||
}
|
||||
],
|
||||
"description": "A fast PHP7 implementation of PSR-7",
|
||||
"homepage": "https://tnyholm.se",
|
||||
"keywords": [
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Nyholm/psr7/issues",
|
||||
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Zegnat",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nyholm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-09T07:06:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.1.3",
|
||||
|
|
@ -4378,6 +4516,195 @@
|
|||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.34.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-php.git",
|
||||
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/3d8f4e56becb10ede64e2ac6f8077f6434715461",
|
||||
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0",
|
||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"raven/raven": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"carthage-software/mago": "1.30.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.4",
|
||||
"guzzlehttp/guzzle": "^7.0|^8.0",
|
||||
"guzzlehttp/promises": "^2.0.3|^3.0.0",
|
||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"open-telemetry/api": "^1.1",
|
||||
"open-telemetry/context": "^1.1",
|
||||
"open-telemetry/exporter-otlp": "^1.1",
|
||||
"open-telemetry/gen-otlp-protobuf": "^1.2.1",
|
||||
"open-telemetry/sdk": "^1.1",
|
||||
"open-telemetry/sem-conv": "^1.27",
|
||||
"phpstan/phpstan": "^2.2",
|
||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
||||
"spiral/roadrunner-http": "^3.6",
|
||||
"spiral/roadrunner-worker": "^3.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||
"homepage": "http://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||
"source": "https://github.com/getsentry/sentry-php/tree/4.34.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-08T11:25:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry-laravel",
|
||||
"version": "4.29.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-laravel.git",
|
||||
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b3deab3d8818490220e1da8e2d7cca971f74410",
|
||||
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
||||
"nyholm/psr7": "^1.0",
|
||||
"php": "^7.2 | ^8.0",
|
||||
"sentry/sentry": "^4.34.0",
|
||||
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.11",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"laravel/ai": "^0.1.5 | ^1.0",
|
||||
"laravel/folio": "^1.1",
|
||||
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
||||
"laravel/octane": "^2.15",
|
||||
"laravel/pennant": "^1.0",
|
||||
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
|
||||
"mockery/mockery": "^1.3",
|
||||
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
|
||||
"phpstan/phpstan": "^1.10 | ^2.2",
|
||||
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Sentry": "Sentry\\Laravel\\Facade"
|
||||
},
|
||||
"providers": [
|
||||
"Sentry\\Laravel\\ServiceProvider",
|
||||
"Sentry\\Laravel\\Tracing\\ServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Sentry\\Laravel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "Laravel SDK for Sentry (https://sentry.io)",
|
||||
"homepage": "https://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"laravel",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-laravel/issues",
|
||||
"source": "https://github.com/getsentry/sentry-laravel/tree/4.29.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-10-08T12:31:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.93.0",
|
||||
|
|
@ -5601,6 +5928,77 @@
|
|||
],
|
||||
"time": "2026-02-05T15:57:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
"version": "v8.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/options-resolver.git",
|
||||
"reference": "88f9c561f678a02d54b897014049fa839e33ff82"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
|
||||
"reference": "88f9c561f678a02d54b897014049fa839e33ff82",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4.1",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\OptionsResolver\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an improved replacement for the array_replace PHP function",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"config",
|
||||
"configuration",
|
||||
"options"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
"version": "v1.33.0",
|
||||
|
|
@ -6495,6 +6893,93 @@
|
|||
],
|
||||
"time": "2026-01-26T15:07:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/psr-http-message-bridge",
|
||||
"version": "v8.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/psr-http-message-bridge.git",
|
||||
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c",
|
||||
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4.1",
|
||||
"psr/http-message": "^1.0|^2.0",
|
||||
"symfony/http-foundation": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"nyholm/psr7": "^1.1",
|
||||
"php-http/discovery": "^1.15",
|
||||
"psr/log": "^1.1.4|^2|^3",
|
||||
"symfony/browser-kit": "^7.4|^8.0",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/event-dispatcher": "^7.4|^8.0",
|
||||
"symfony/framework-bundle": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/runtime": "^7.4|^8.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\PsrHttpMessage\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "PSR HTTP message bridge",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-29T05:06:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v7.4.6",
|
||||
|
|
@ -7967,66 +8452,6 @@
|
|||
},
|
||||
"time": "2025-04-30T06:54:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v1.8.11",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,18 @@ return [
|
|||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ausgelieferte Version
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Git-Tag des Deploys (Datei RELEASE, schreibt dev/betrieb/deploy.sh) oder
|
||||
| APP_VERSION. Erscheint in /health, `php artisan about` und GlitchTip.
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
|
@ -41,6 +53,34 @@ return [
|
|||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Indexierung durch Suchmaschinen
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Nur die Produktivumgebung darf indexiert werden. Vorschau- und Testinstanzen
|
||||
| liefern damit automatisch `noindex, nofollow` – sonst konkurriert eine Kopie
|
||||
| der Website mit dem Original um dieselben Suchergebnisse.
|
||||
|
|
||||
| Bewusst als eigener Schalter und nicht nur an APP_ENV gekoppelt, damit eine
|
||||
| Vorschau notfalls auch mit APP_ENV=production noindex bleiben kann.
|
||||
|
|
||||
*/
|
||||
'indexable' => (bool) env('SITE_INDEXABLE', env('APP_ENV', 'production') === 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Obere Hinweisleiste (Ankündigungsleiste)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Seit der Abstimmung vom 01.10.2026 ausgeblendet – die Seite wirkt ohne die
|
||||
| Leiste ruhiger. Inhalt und CMS-Pflege bleiben erhalten; mit
|
||||
| B2IN_ANNOUNCEMENT_BAR=true ist sie auf allen Seiten wieder da.
|
||||
|
|
||||
*/
|
||||
|
||||
'announcement_bar' => (bool) env('B2IN_ANNOUNCEMENT_BAR', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|
|
|
|||
298
config/cms_pages.php
Normal file
298
config/cms_pages.php
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Seitenbaum für den Inhalte-Editor im Portal (Nachtrag 12).
|
||||
*
|
||||
* Der Editor zeigt die CMS-Sektionen nicht mehr nach Datenbank-Gruppe, sondern so, wie sie auf
|
||||
* der Website stehen: je Seite in der Reihenfolge von oben nach unten, mit Klartext-Titel und
|
||||
* einem Satz, der sagt, was die Sektion ist. `anchor` ist die Sprungmarke in der Vorschau
|
||||
* (das `id` des Abschnitts im Frontend).
|
||||
*
|
||||
* Eine Sektion, die auf mehreren Seiten steht, ist einmal eingetragen – dort, wo sie zuerst
|
||||
* auftaucht – und nennt die anderen Seiten in der Beschreibung.
|
||||
*
|
||||
* `group`/`key` sind Gruppe und Schlüssel in flux_cms_contents (siehe cms_section_map.php).
|
||||
* Sektionen, die in der Datenbank liegen, aber hier nicht eingetragen sind, sammelt der Editor
|
||||
* unter „Nicht mehr verwendet“ – sie gehen also nicht verloren, stehen aber auch nicht im Weg.
|
||||
*
|
||||
* `fields` überschreibt Feldbeschriftungen je Sektion; alles andere kommt aus `field_labels`.
|
||||
*/
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Feldbeschriftungen
|
||||
|--------------------------------------------------------------------------
|
||||
| Gilt für Felder auf Sektionsebene und für Felder in Listeneinträgen.
|
||||
| Unbekannte Schlüssel werden lesbar gemacht (button_text → „Button text“).
|
||||
*/
|
||||
'field_labels' => [
|
||||
'title' => 'Überschrift',
|
||||
'subtitle' => 'Untertitel',
|
||||
'headline' => 'Überschrift',
|
||||
'tagline' => 'Claim',
|
||||
'text' => 'Text',
|
||||
'intro' => 'Einleitung',
|
||||
'description' => 'Beschreibung',
|
||||
'content' => 'Inhalt',
|
||||
'lead' => 'Vorspann',
|
||||
'note' => 'Hinweis',
|
||||
'label' => 'Beschriftung',
|
||||
'badge' => 'Kennzeichen',
|
||||
'eyebrow' => 'Überzeile',
|
||||
'quote' => 'Zitat',
|
||||
'author' => 'Zitatgeber',
|
||||
'name' => 'Name',
|
||||
'role' => 'Rolle / Funktion',
|
||||
'city' => 'Ort',
|
||||
'value' => 'Wert',
|
||||
'number' => 'Zahl',
|
||||
'suffix' => 'Zusatz',
|
||||
'icon' => 'Icon',
|
||||
'image' => 'Bild',
|
||||
'image_alt' => 'Bildbeschreibung',
|
||||
'alt' => 'Bildbeschreibung',
|
||||
'logo' => 'Logo',
|
||||
'link' => 'Link',
|
||||
'url' => 'Adresse (URL)',
|
||||
'href' => 'Link',
|
||||
'link_text' => 'Linktext',
|
||||
'button_text' => 'Button-Beschriftung',
|
||||
'button_link' => 'Button-Link',
|
||||
'primary_button' => 'Erster Button',
|
||||
'secondary_button' => 'Zweiter Button',
|
||||
'cta_title' => 'Überschrift Abschluss',
|
||||
'cta_text' => 'Text Abschluss',
|
||||
'cta_link' => 'Link Abschluss',
|
||||
'items' => 'Einträge',
|
||||
'steps' => 'Schritte',
|
||||
'cards' => 'Karten',
|
||||
'stats' => 'Kennzahlen',
|
||||
'features' => 'Merkmale',
|
||||
'benefits' => 'Vorteile',
|
||||
'values' => 'Werte',
|
||||
'links' => 'Links',
|
||||
'columns' => 'Spalten',
|
||||
'menu' => 'Menüpunkte',
|
||||
'navigation' => 'Navigation',
|
||||
'topics' => 'Themen',
|
||||
'options' => 'Auswahlmöglichkeiten',
|
||||
'placeholder' => 'Platzhaltertext',
|
||||
'meta_title' => 'Seitentitel (Browser, Google)',
|
||||
'meta_description' => 'Beschreibung für Google',
|
||||
'email' => 'E-Mail',
|
||||
'phone' => 'Telefon',
|
||||
'whatsapp' => 'WhatsApp-Nummer',
|
||||
'address' => 'Adresse',
|
||||
'opening_hours' => 'Öffnungszeiten',
|
||||
'copyright' => 'Copyright-Zeile',
|
||||
'map_alt' => 'Beschreibung der Karte',
|
||||
'region_label' => 'Beschriftung der Region',
|
||||
'distance_suffix' => 'Zusatz hinter der Entfernung',
|
||||
'hub' => 'Drehscheibe (Bielefeld)',
|
||||
'steps_title' => 'Überschrift Ablauf-Block',
|
||||
'steps_text' => 'Einleitung Ablauf-Block',
|
||||
'x' => 'Position waagerecht (Entwickler)',
|
||||
'y' => 'Position senkrecht (Entwickler)',
|
||||
'id' => 'Sprungmarke (Entwickler)',
|
||||
'slug' => 'Adresskürzel (Entwickler)',
|
||||
'teaser' => 'Kurztext',
|
||||
'summary' => 'Zusammenfassung',
|
||||
'brands' => 'Marken',
|
||||
'projects' => 'Projekte',
|
||||
'partners' => 'Partner',
|
||||
'questions' => 'Fragen',
|
||||
'question' => 'Frage',
|
||||
'answer' => 'Antwort',
|
||||
'read_more' => 'Weiterlesen-Text',
|
||||
'empty_text' => 'Text, wenn nichts vorhanden ist',
|
||||
'success_text' => 'Text nach dem Absenden',
|
||||
'submit_text' => 'Beschriftung Absenden-Button',
|
||||
'privacy_text' => 'Datenschutz-Hinweis',
|
||||
'subjects' => 'Betreffe',
|
||||
'subject' => 'Betreff',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Seiten in der Reihenfolge des Menüs
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'pages' => [
|
||||
|
||||
'home' => [
|
||||
'title' => 'Startseite',
|
||||
'path' => '/',
|
||||
'sections' => [
|
||||
['group' => 'home', 'key' => 'hero', 'anchor' => 'hero', 'title' => 'Hero',
|
||||
'description' => 'Der große Einstieg ganz oben: Zeile über der Überschrift (eyebrow), Überschrift, Text, Button, Häkchen-Punkte (stats) und die Slideshow. Unter „slides“ je Bild: image, label (Marke) und caption (Motiv) – beides erscheint in der Karte über dem Bild und wechselt mit –, alt (Bildbeschreibung für Screenreader) und active (aus = ausgeblendet); die Reihenfolge der Liste ist die Reihenfolge der Slideshow. „interval“ = Sekunden pro Bild (3–30). card_title/card_text stehen in der Karte, wenn eine Slide weder label noch caption hat; image/image_alt sind das Ersatzbild, wenn keine Slide aktiv ist.'],
|
||||
['group' => 'home', 'key' => 'founder_bar', 'anchor' => 'gruender', 'title' => 'Gründer-Leiste',
|
||||
'description' => 'Schmale Zeile mit Marcel Scheibe und Zitat. Steht auch auf /marken, „Über uns“ und in der Immobilien-Welt.'],
|
||||
['group' => 'home', 'key' => 'audience_switch', 'anchor' => 'wege', 'title' => 'Zielgruppen-Wahl',
|
||||
'description' => 'Zwei Wege: Privat einrichten oder Projekt ausstatten.'],
|
||||
['group' => 'home', 'key' => 'core_brands', 'anchor' => 'marken', 'title' => 'Kernmarken',
|
||||
'description' => 'Überschrift und Untertitel des Marken-Rasters. Die Kacheln selbst kommen aus der Markenverwaltung.'],
|
||||
['group' => 'home', 'key' => 'origin_section', 'anchor' => 'herkunft', 'title' => 'Aus der Region gefertigt',
|
||||
'description' => 'Karte mit Bielefeld als Drehscheibe und der Ablauf-Block am Beispiel CABINET. Die Standorte pflegst du bei den Marken. Steht auch auf /marken.'],
|
||||
['group' => 'home', 'key' => 'vision_section', 'anchor' => 'vision', 'title' => 'Gebaut auf Expertise',
|
||||
'description' => 'Weiße Box auf Ocker: Überschrift, Absätze (paragraphs) und Porträt mit Bildunterschrift (image_caption).'],
|
||||
['group' => 'home', 'key' => 'sponsoring', 'anchor' => 'engagement', 'title' => 'Regional verbunden',
|
||||
'description' => 'Vereine, die B2in unterstützt: je Verein name, image (Logo), text und optional url – mit url öffnet die Kachel die Vereinsseite in einem neuen Tab.'],
|
||||
['group' => 'referenzen', 'key' => 'display_feed', 'anchor' => 'displays', 'title' => 'Aktuelles aus dem Showroom',
|
||||
'description' => '„Aktuelles aus unserem Showroom“: Überschrift, Text, Hinweis (note) und Link in der weißen Box. Das Display rechts kommt aus den Store Displays (display_id leer = das erste veröffentlichte), Besucher können es über „Größer ansehen“ vergrößern. Steht auch auf /referenzen.'],
|
||||
['group' => 'home', 'key' => 'immobilien_teaser', 'anchor' => 'immobilien', 'title' => 'Immobilien-Teaser',
|
||||
'description' => 'Der Übergang in die Immobilien-Welt am Ende der Startseite.'],
|
||||
['group' => 'global', 'key' => 'cta_section', 'anchor' => 'cta', 'title' => 'Abschluss: Termin anfragen',
|
||||
'description' => 'Der letzte Block vor dem Fuß auf der Startseite und auf /referenzen. Marken und „Über uns“ haben eigene Abschlüsse.'],
|
||||
],
|
||||
],
|
||||
|
||||
'marken' => [
|
||||
'title' => 'Marken',
|
||||
'path' => '/marken',
|
||||
'sections' => [
|
||||
['group' => 'marken', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
||||
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Einleitung (text), Bild und die Häkchen-Punkte (stats) oben auf /marken. Die Markenblöcke darunter kommen aus der Markenverwaltung.'],
|
||||
['group' => 'marken', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
|
||||
'description' => 'Großes Bild zwischen den Markenblöcken und „Aus der Region gefertigt“, damit sich die beiden hellen Sektionen absetzen. Mit Zitat (quote) und Autor (author); ohne Zitat steht nur das Bild.'],
|
||||
['group' => 'marken', 'key' => 'network', 'anchor' => 'netzwerk', 'title' => 'Mehr als Marken: unser Netzwerk',
|
||||
'description' => 'Partner und Zulieferer jenseits der Kernmarken.'],
|
||||
['group' => 'marken', 'key' => 'growth', 'anchor' => 'wachstum', 'title' => 'Die Markenwelt wächst',
|
||||
'description' => 'Hinweis, dass weitere Marken folgen.'],
|
||||
['group' => 'marken', 'key' => 'cta', 'anchor' => 'marken-cta', 'title' => 'Abschluss auf /marken',
|
||||
'description' => 'Der Abschlussblock „Lernen Sie unsere Marken kennen“ mit Terminanfrage und WhatsApp.'],
|
||||
['group' => 'marken', 'key' => 'craft', 'anchor' => 'handwerk', 'title' => 'Handwerk-Abschluss auf jeder Markenseite',
|
||||
'description' => 'Der letzte Abschnitt auf den Seiten von CABINET, Rotpunkt und Febrü: „Planung plus Handwerk“ mit Link zu Otterpohl. Einmal gepflegt, überall gleich.', 'path' => '/marken/cabinet'],
|
||||
],
|
||||
],
|
||||
|
||||
'referenzen' => [
|
||||
'title' => 'Referenzen',
|
||||
'path' => '/referenzen',
|
||||
'sections' => [
|
||||
['group' => 'referenzen', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
||||
'description' => 'Überschrift, Einleitung und Bild der Referenzen-Seite. Ob die Seite (samt Menüpunkt) erscheint, schaltest du unter „Referenzen“.'],
|
||||
['group' => 'referenzen', 'key' => 'projects', 'anchor' => 'projekte', 'title' => 'Realisierte Projekte',
|
||||
'description' => 'Überschrift, Text und Leerzustand über den Projekten. Die Projekte selbst pflegst du unter „Referenzen“.'],
|
||||
],
|
||||
],
|
||||
|
||||
'magazin' => [
|
||||
'title' => 'Magazin',
|
||||
'path' => '/magazin',
|
||||
'sections' => [
|
||||
['group' => 'magazin', 'key' => 'list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht',
|
||||
'description' => 'Überschrift und Einleitung der Übersicht. Die Beiträge pflegst du unter „Magazin“.'],
|
||||
['group' => 'magazin', 'key' => 'detail', 'anchor' => null, 'title' => 'Beitragsseite',
|
||||
'description' => 'Feste Texte auf jeder Beitragsseite, etwa „Zurück zur Übersicht“ und „Weitere Beiträge“.'],
|
||||
],
|
||||
],
|
||||
|
||||
'about' => [
|
||||
'title' => 'Über uns',
|
||||
'path' => '/about',
|
||||
'sections' => [
|
||||
['group' => 'about', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
||||
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte (card_title, card_text).'],
|
||||
['group' => 'about', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Unsere Geschichte',
|
||||
'description' => 'Überschrift, Text (summary) und die Zeitleiste (timeline) zur Entstehung von B2in.'],
|
||||
['group' => 'about', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
|
||||
'description' => 'Großes Bild zwischen Geschichte und Werten, mit Zitat (quote) und Autor (author).'],
|
||||
['group' => 'about', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Unsere Werte',
|
||||
'description' => 'Die Werte-Kacheln.'],
|
||||
['group' => 'about', 'key' => 'partner_cta', 'anchor' => 'cta', 'title' => 'Abschluss auf „Über uns“',
|
||||
'description' => 'Der Abschlussblock am Ende von „Über uns“.'],
|
||||
],
|
||||
],
|
||||
|
||||
'contact' => [
|
||||
'title' => 'Kontakt',
|
||||
'path' => '/contact',
|
||||
'sections' => [
|
||||
['group' => 'contact', 'key' => 'contact_form', 'anchor' => 'kontakt', 'title' => 'Kontaktformular',
|
||||
'description' => 'Kopf des Formulars (hero: eyebrow, title, subtitle), Betreffe, Feldbeschriftungen und Datenschutz-Hinweis.'],
|
||||
['group' => 'global', 'key' => 'contact', 'anchor' => 'kontakt', 'title' => 'Kontaktdaten',
|
||||
'description' => 'Telefon, E-Mail, Adresse und die WhatsApp-Nummer. Ohne WhatsApp-Nummer erscheint kein WhatsApp-Button.'],
|
||||
],
|
||||
],
|
||||
|
||||
'faq' => [
|
||||
'title' => 'FAQ',
|
||||
'path' => '/faq',
|
||||
'sections' => [
|
||||
['group' => 'faq', 'key' => 'faq', 'anchor' => 'faq', 'title' => 'Fragen und Antworten',
|
||||
'description' => 'Überschrift und die Liste der Fragen der Interior-Welt.'],
|
||||
],
|
||||
],
|
||||
|
||||
'global' => [
|
||||
'title' => 'Auf jeder Seite',
|
||||
'path' => '/',
|
||||
'sections' => [
|
||||
['group' => 'global', 'key' => 'announcement_bar', 'anchor' => 'header', 'title' => 'Hinweisleiste',
|
||||
'description' => 'Schmale Zeile ganz oben über dem Menü (text, badge, Link). „enabled“ schaltet sie ein. Zurzeit ist die Leiste für die ganze Website über B2IN_ANNOUNCEMENT_BAR in der .env aus – Änderungen hier erscheinen erst, wenn sie dort eingeschaltet ist.'],
|
||||
['group' => 'global', 'key' => 'header', 'anchor' => 'header', 'title' => 'Hauptmenü',
|
||||
'description' => 'Menüpunkte und Buttons im Kopf der Interior-Welt. Der Punkt „Referenzen“ erscheint nur, solange die Referenzen-Seite eingeschaltet ist.'],
|
||||
['group' => 'global', 'key' => 'appointment_widget', 'anchor' => null, 'title' => 'Terminanfrage-Widget',
|
||||
'description' => 'Das schwebende Widget rechts unten: Texte, Themen und Zeitfenster.'],
|
||||
['group' => 'global', 'key' => 'footer', 'anchor' => 'footer', 'title' => 'Fußbereich',
|
||||
'description' => 'Claim, Zeile unter dem Claim (byline) und die Linkliste (links) im Fuß.'],
|
||||
],
|
||||
],
|
||||
|
||||
'immobilien' => [
|
||||
'title' => 'Immobilien-Welt',
|
||||
'path' => '/immobilien',
|
||||
'sections' => [
|
||||
['group' => 'global', 'key' => 'announcement_bar_immobilien', 'anchor' => 'header', 'title' => 'Hinweisleiste (Immobilien)',
|
||||
'description' => 'Hinweisleiste über dem Menü der Immobilien-Welt. Wie die Interior-Leiste zurzeit über B2IN_ANNOUNCEMENT_BAR ausgeschaltet.'],
|
||||
['group' => 'global', 'key' => 'header_immobilien', 'anchor' => 'header', 'title' => 'Hauptmenü (Immobilien)',
|
||||
'description' => 'Menüpunkte und Buttons im Kopf der Immobilien-Welt.'],
|
||||
['group' => 'global', 'key' => 'cta_section_immobilien', 'anchor' => 'cta', 'title' => 'Abschluss (Immobilien)',
|
||||
'description' => 'Der letzte Block vor dem Fuß auf allen Immobilien-Seiten.'],
|
||||
['group' => 'immobilien', 'key' => 'magazin_list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht (Immobilien)',
|
||||
'description' => 'Überschrift und Einleitung unter /immobilien/magazin.', 'path' => '/immobilien/magazin'],
|
||||
['group' => 'immobilien', 'key' => 'about_hero', 'anchor' => 'seitenkopf', 'title' => 'Über B2in: Seitenkopf',
|
||||
'description' => 'Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte unter /immobilien/ueber-b2in.', 'path' => '/immobilien/ueber-b2in'],
|
||||
['group' => 'immobilien', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Über B2in: Geschichte',
|
||||
'description' => 'Der Geschichte-Block der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
|
||||
['group' => 'immobilien', 'key' => 'about_image_break', 'anchor' => 'bild', 'title' => 'Über B2in: Bildpause',
|
||||
'description' => 'Bild zwischen Geschichte und Werten.', 'path' => '/immobilien/ueber-b2in'],
|
||||
['group' => 'immobilien', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Über B2in: Werte',
|
||||
'description' => 'Die Werte-Kacheln der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
|
||||
['group' => 'faq', 'key' => 'faq_immobilien', 'anchor' => 'faq', 'title' => 'FAQ (Immobilien)',
|
||||
'description' => 'Fragen und Antworten unter /immobilien/faq.', 'path' => '/immobilien/faq'],
|
||||
['group' => 'contact', 'key' => 'contact_form_immobilien', 'anchor' => 'kontakt', 'title' => 'Kontaktformular (Immobilien)',
|
||||
'description' => 'Abweichende Texte und Betreffe unter /immobilien/kontakt. Leere Felder übernehmen den Interior-Wert.', 'path' => '/immobilien/kontakt'],
|
||||
],
|
||||
],
|
||||
|
||||
'legal' => [
|
||||
'title' => 'Rechtliches',
|
||||
'path' => '/impressum',
|
||||
'sections' => [
|
||||
['group' => 'legal', 'key' => 'impressum', 'anchor' => null, 'title' => 'Impressum',
|
||||
'description' => 'Pflichtangaben. Der Inhalt ist HTML.', 'path' => '/impressum'],
|
||||
['group' => 'legal', 'key' => 'privacy', 'anchor' => null, 'title' => 'Datenschutz',
|
||||
'description' => 'Datenschutzerklärung. Der Inhalt ist HTML.', 'path' => '/privacy'],
|
||||
['group' => 'legal', 'key' => 'terms', 'anchor' => null, 'title' => 'Nutzungsbedingungen',
|
||||
'description' => 'Nutzungsbedingungen der Website. Der Inhalt ist HTML.', 'path' => '/terms'],
|
||||
['group' => 'legal', 'key' => 'cookie_policy', 'anchor' => null, 'title' => 'Cookie-Hinweise',
|
||||
'description' => 'Cookie-Richtlinie. Der Inhalt ist HTML.', 'path' => '/cookie-policy'],
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sammelpunkt für alles, was in der Datenbank liegt, aber oben nicht steht
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'unlisted' => [
|
||||
'title' => 'Nicht mehr verwendet',
|
||||
'description' => 'Einträge in der Datenbank, die keine Seite anzeigt. Normalerweise leer – der UnusedCmsContentCleanupSeeder räumt solche Einträge ab.',
|
||||
],
|
||||
];
|
||||
|
|
@ -8,84 +8,55 @@ declare(strict_types=1);
|
|||
* Wird von cms_theme_section() und CmsContentSeeder verwendet.
|
||||
*/
|
||||
return [
|
||||
'lang_keys' => [
|
||||
'card_section' => 'partner_card_section',
|
||||
],
|
||||
|
||||
'sections' => [
|
||||
'announcement_bar' => ['global', 'announcement_bar'],
|
||||
'announcement_bar_immobilien' => ['global', 'announcement_bar_immobilien'],
|
||||
'header' => ['global', 'header'],
|
||||
'footer' => ['global', 'footer'],
|
||||
'cta_section' => ['global', 'cta_section'],
|
||||
'cta_section_portfolio' => ['global', 'cta_section_portfolio'],
|
||||
'cta_section_immobilien' => ['global', 'cta_section_immobilien'],
|
||||
'appointment_widget' => ['global', 'appointment_widget'],
|
||||
|
||||
'hero' => ['home', 'hero'],
|
||||
'audience_switch' => ['home', 'audience_switch'],
|
||||
'core_brands' => ['home', 'core_brands'],
|
||||
'origin_section' => ['home', 'origin_section'],
|
||||
|
||||
'marken_hero' => ['marken', 'hero'],
|
||||
'marken_image_break' => ['marken', 'image_break'],
|
||||
'marken_network' => ['marken', 'network'],
|
||||
'marken_growth' => ['marken', 'growth'],
|
||||
'marken_cta' => ['marken', 'cta'],
|
||||
'brand_craft' => ['marken', 'craft'],
|
||||
|
||||
'referenzen_hero' => ['referenzen', 'hero'],
|
||||
'reference_projects' => ['referenzen', 'projects'],
|
||||
'display_feed' => ['referenzen', 'display_feed'],
|
||||
'sponsoring' => ['home', 'sponsoring'],
|
||||
'immobilien_teaser' => ['home', 'immobilien_teaser'],
|
||||
'founder_bar' => ['home', 'founder_bar'],
|
||||
'synergie_section' => ['home', 'synergie_section'],
|
||||
'ecosystem_core' => ['home', 'ecosystem_core'],
|
||||
'vision_section' => ['home', 'vision_section'],
|
||||
'brand_worlds' => ['home', 'brand_worlds'],
|
||||
'integriertes_modell_b2in' => ['home', 'integriertes_modell_b2in'],
|
||||
|
||||
'immobilien_hero' => ['immobilien', 'hero'],
|
||||
'immobilien_hero_v2' => ['immobilien', 'hero_v2'],
|
||||
'immobilien_projects' => ['immobilien', 'projects_meta'],
|
||||
'immobilien_moebel_vorteil' => ['immobilien', 'moebel_vorteil'],
|
||||
'immobilien_trust' => ['immobilien', 'trust'],
|
||||
'immobilien_warum_dubai' => ['immobilien', 'warum_dubai'],
|
||||
'immobilien_image_break' => ['immobilien', 'image_break'],
|
||||
'immobilien_kaufprozess' => ['immobilien', 'kaufprozess'],
|
||||
'immobilien_bruecke' => ['immobilien', 'bruecke'],
|
||||
'immobilien_mindset' => ['immobilien', 'mindset'],
|
||||
'portfolio' => ['immobilien', 'portfolio'],
|
||||
|
||||
'netzwerk_hero' => ['netzwerk', 'hero'],
|
||||
'netzwerk_image_break' => ['netzwerk', 'image_break'],
|
||||
'netzwerk_teasers' => ['netzwerk', 'teasers'],
|
||||
'netzwerk_cta' => ['netzwerk', 'cta'],
|
||||
'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'],
|
||||
'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'],
|
||||
|
||||
'interior_hero' => ['netzwerk', 'interior_hero'],
|
||||
'interior_concept' => ['netzwerk', 'interior_concept'],
|
||||
'interior_brands' => ['netzwerk', 'interior_brands'],
|
||||
'interior_zielgruppen' => ['netzwerk', 'interior_zielgruppen'],
|
||||
'interior_process' => ['netzwerk', 'interior_process'],
|
||||
'interior_trust' => ['netzwerk', 'interior_trust'],
|
||||
|
||||
'faq' => ['faq', 'faq'],
|
||||
'faq_immobilien' => ['faq', 'faq_immobilien'],
|
||||
|
||||
// Immobilien-Welt: eigene Navigation, eigenes Magazin, eigene Ueber-uns-Seite
|
||||
'header_immobilien' => ['global', 'header_immobilien'],
|
||||
'magazin_list_immobilien' => ['immobilien', 'magazin_list'],
|
||||
'immobilien_about_hero' => ['immobilien', 'about_hero'],
|
||||
'immobilien_our_story' => ['immobilien', 'our_story'],
|
||||
'immobilien_about_image_break' => ['immobilien', 'about_image_break'],
|
||||
'immobilien_our_values' => ['immobilien', 'our_values'],
|
||||
|
||||
'contact_form' => ['contact', 'contact_form'],
|
||||
'contact_form_immobilien' => ['contact', 'contact_form_immobilien'],
|
||||
|
||||
'about_hero' => ['about', 'hero'],
|
||||
'our_story' => ['about', 'our_story'],
|
||||
'about_image_break' => ['about', 'image_break'],
|
||||
'our_values' => ['about', 'our_values'],
|
||||
'leadership_team' => ['about', 'leadership_team'],
|
||||
|
||||
'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'],
|
||||
'partner_cta' => ['about', 'partner_cta'],
|
||||
|
||||
'magazin_detail' => ['magazin', 'detail'],
|
||||
'magazin_list' => ['magazin', 'list'],
|
||||
|
|
|
|||
|
|
@ -71,13 +71,29 @@ return [
|
|||
'view_prefix' => 'b2in',
|
||||
'assets_dir' => 'build/b2in',
|
||||
'description' => 'Haupt-Landingpage mit Standard-Theme',
|
||||
// Interior-Welt (Standard). Die Immobilien-Welt behält das bisherige Blau,
|
||||
// siehe `worlds` und resources/css/web/theme-b2in.css.
|
||||
'color_scheme' => [
|
||||
'primary' => '#2b3f51', // Anthracite
|
||||
'secondary' => '#20a0da', // Dynamic Blue
|
||||
'primary' => '#202c2b', // Tanne
|
||||
'secondary' => '#a8741f', // Ocker
|
||||
],
|
||||
'font_family' => [
|
||||
'primary' => 'Inter',
|
||||
'secondary' => 'IBM Plex Sans',
|
||||
'secondary' => 'EB Garamond',
|
||||
],
|
||||
'worlds' => [
|
||||
'interior' => [
|
||||
'primary' => '#202c2b', // Tanne – Hauptton dunkel, Wortmarke
|
||||
'background' => '#f7f4ef', // Creme
|
||||
'surface' => '#d9cbbf', // Sand – Flächen, Footer
|
||||
'accent' => '#a8741f', // Ocker – Akzent, Logo-Bogen
|
||||
'secondary' => '#a38f82', // Clay – Sekundär, Produkte
|
||||
'text' => '#2d2926', // Anthrazit
|
||||
],
|
||||
'immobilien' => [
|
||||
'primary' => '#2b3f51', // Anthracite
|
||||
'accent' => '#20a0da', // Dynamic Blue
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ return [
|
|||
|
||||
'channels' => [
|
||||
|
||||
'monitoring' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/monitoring.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 14,
|
||||
],
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', env('LOG_STACK', 'single')),
|
||||
|
|
|
|||
75
config/monitoring.php
Normal file
75
config/monitoring.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?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,
|
||||
],
|
||||
|
||||
];
|
||||
21
config/seeding.php
Normal file
21
config/seeding.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Seed-Admin
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Zugangsdaten für den Admin-Benutzer, den der DatabaseSeeder anlegt.
|
||||
| Gehören nicht ins Repository – über die .env setzen.
|
||||
|
|
||||
*/
|
||||
|
||||
'admin_name' => env('SEED_ADMIN_NAME', 'Admin'),
|
||||
'admin_email' => env('SEED_ADMIN_EMAIL', 'admin@b2in.test'),
|
||||
'admin_password' => env('SEED_ADMIN_PASSWORD', 'password'),
|
||||
|
||||
];
|
||||
180
config/sentry.php
Normal file
180
config/sentry.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?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),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -23,6 +23,7 @@ class CmsArticleFactory extends Factory
|
|||
'subtitle' => fake()->sentence(12),
|
||||
'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg',
|
||||
'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']),
|
||||
'world' => CmsArticle::WORLD_INTERIOR,
|
||||
'date_label' => fake()->date('F j, Y'),
|
||||
'read_time' => fake()->numberBetween(3, 10).' min read',
|
||||
'author' => [
|
||||
|
|
|
|||
70
database/factories/CmsBrandFactory.php
Normal file
70
database/factories/CmsBrandFactory.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<CmsBrand>
|
||||
*/
|
||||
class CmsBrandFactory extends Factory
|
||||
{
|
||||
protected $model = CmsBrand::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->company();
|
||||
|
||||
return [
|
||||
'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 999),
|
||||
'name' => ['de' => $name, 'en' => $name],
|
||||
'tagline' => ['de' => fake()->sentence(4), 'en' => fake()->sentence(4)],
|
||||
'teaser' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
|
||||
'description' => ['de' => '<p>'.fake()->paragraph(4).'</p>', 'en' => '<p>'.fake()->paragraph(4).'</p>'],
|
||||
'logo' => '',
|
||||
'image' => '',
|
||||
'services' => [
|
||||
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
|
||||
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
|
||||
],
|
||||
'gallery' => [],
|
||||
'website_url' => fake()->url(),
|
||||
'showroom' => 'Bielefeld',
|
||||
'is_published' => true,
|
||||
'order' => fake()->numberBetween(1, 20),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Galerie mit realisierten Projekten und Inspirationen.
|
||||
*/
|
||||
public function withGallery(int $projects = 2, int $inspirations = 2): static
|
||||
{
|
||||
$items = [];
|
||||
|
||||
for ($i = 1; $i <= $projects; $i++) {
|
||||
$items[] = [
|
||||
'image' => 'projekt-'.$i.'.webp',
|
||||
'category' => CmsBrand::GALLERY_PROJECT,
|
||||
'caption' => 'Projekt '.$i,
|
||||
];
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= $inspirations; $i++) {
|
||||
$items[] = [
|
||||
'image' => 'inspiration-'.$i.'.webp',
|
||||
'category' => CmsBrand::GALLERY_INSPIRATION,
|
||||
'caption' => 'Inspiration '.$i,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->state(fn () => ['gallery' => $items]);
|
||||
}
|
||||
|
||||
public function unpublished(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_published' => false]);
|
||||
}
|
||||
}
|
||||
62
database/factories/CmsReferenceFactory.php
Normal file
62
database/factories/CmsReferenceFactory.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\CmsReference;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<CmsReference>
|
||||
*/
|
||||
class CmsReferenceFactory extends Factory
|
||||
{
|
||||
protected $model = CmsReference::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$title = fake()->unique()->words(3, true);
|
||||
|
||||
return [
|
||||
'title' => ['de' => ucfirst($title), 'en' => ucfirst($title)],
|
||||
'location' => fake()->city(),
|
||||
'year' => (string) fake()->numberBetween(2022, 2026),
|
||||
'description' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
|
||||
'image' => 'referenzen/'.fake()->unique()->slug(2).'.webp',
|
||||
'gallery' => [],
|
||||
'brand_slugs' => [],
|
||||
'is_published' => true,
|
||||
'order' => fake()->numberBetween(1, 20),
|
||||
];
|
||||
}
|
||||
|
||||
public function published(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_published' => true]);
|
||||
}
|
||||
|
||||
public function unpublished(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_published' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $slugs
|
||||
*/
|
||||
public function forBrands(array $slugs): static
|
||||
{
|
||||
return $this->state(fn () => ['brand_slugs' => $slugs]);
|
||||
}
|
||||
|
||||
public function withGallery(int $count = 2): static
|
||||
{
|
||||
return $this->state(function () use ($count) {
|
||||
$items = [];
|
||||
|
||||
for ($i = 1; $i <= $count; $i++) {
|
||||
$items[] = ['image' => 'referenzen/bild-'.$i.'.webp', 'caption' => 'Bild '.$i];
|
||||
}
|
||||
|
||||
return ['gallery' => $items];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Kernmarken (CABINET, Rotpunkt Küchen, Febrü) mit eigener Detailseite.
|
||||
*
|
||||
* Bewusst eine eigene Tabelle statt der bestehenden `brands`: die gehört zum
|
||||
* Produkt-/Partner-Kontext (Partner::brand(), Product::brand()) und hat eine andere Bedeutung.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cms_brands', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug')->unique();
|
||||
$table->json('name');
|
||||
$table->json('tagline')->nullable();
|
||||
$table->json('teaser')->nullable();
|
||||
$table->json('description')->nullable();
|
||||
$table->string('logo')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->json('services')->nullable();
|
||||
$table->json('gallery')->nullable();
|
||||
$table->string('website_url')->nullable();
|
||||
$table->string('showroom')->nullable();
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cms_brands');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Die Übersichtsseite /marken zeigt je Marke einen mittellangen Text plus drei Merkmale.
|
||||
*
|
||||
* Abgrenzung zu den vorhandenen Feldern:
|
||||
* teaser – Kurztext für die Kachel auf der Startseite
|
||||
* summary – Text auf der Übersichtsseite /marken (neu)
|
||||
* highlights – drei Stichpunkte auf /marken (neu)
|
||||
* description – Einleitung auf der Detailseite
|
||||
* services – Leistungen auf der Detailseite
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->json('summary')->nullable()->after('teaser');
|
||||
$table->json('highlights')->nullable()->after('summary');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->dropColumn(['summary', 'highlights']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Ausführliche Markenseiten nach den Zulieferungen vom 31.08.2026.
|
||||
*
|
||||
* Die gelieferten Texte (Rotpunkt, Febrü) bestehen aus mehreren Abschnitten, von denen einige
|
||||
* eine Vertiefung mitbringen, die laut Umsetzungshinweis aufklappbar dargestellt werden soll.
|
||||
* Dafür reichen `description` (Einleitung) und `services` (Kacheln) nicht aus.
|
||||
*
|
||||
* Ergänzt werden:
|
||||
* - `sections` Abschnitte der Seite, je mit Titel, Absätzen und optionaler Vertiefung
|
||||
* - `widget_label` Beschriftung des schwebenden Terminanfrage-Widgets auf dieser Markenseite
|
||||
* - `cta_title` Überschrift des Abschlussblocks
|
||||
* - `cta_text` Text des Abschlussblocks
|
||||
*
|
||||
* `sections` ist – wie `services`, `highlights` und `gallery` – bewusst nicht übersetzbar:
|
||||
* Die Texte liegen nur auf Deutsch vor. Die drei Textfelder folgen den übrigen Textfeldern
|
||||
* und sind übersetzbar.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->json('sections')->nullable()->after('services');
|
||||
$table->json('widget_label')->nullable()->after('sections');
|
||||
$table->json('cta_title')->nullable()->after('widget_label');
|
||||
$table->json('cta_text')->nullable()->after('cta_title');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->dropColumn(['sections', 'widget_label', 'cta_title', 'cta_text']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Magazinbeiträge gehören zu einer Farbwelt (Interior oder Immobilien).
|
||||
*
|
||||
* Die drei Dubai-Beiträge aus dem Bestand werden direkt der Immobilien-Welt zugeordnet,
|
||||
* damit sie nach der Trennung nicht in der Interior-Liste stehen.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_articles', function (Blueprint $table) {
|
||||
$table->string('world', 20)->default(CmsArticle::WORLD_INTERIOR)->index()->after('category');
|
||||
});
|
||||
|
||||
CmsArticle::query()
|
||||
->whereIn('slug', CmsArticle::LEGACY_IMMOBILIEN_SLUGS)
|
||||
->update(['world' => CmsArticle::WORLD_IMMOBILIEN]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_articles', function (Blueprint $table) {
|
||||
$table->dropIndex(['world']);
|
||||
$table->dropColumn('world');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Fertigungsort je Kernmarke (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
|
||||
*
|
||||
* Die Herkunfts-Sektion „Aus der Region gefertigt“ holt ihre Standorte nicht mehr aus einem
|
||||
* JSON-Eintrag im generischen Inhalte-Editor, sondern aus der Markenverwaltung: Marcel pflegt
|
||||
* Ort, Entfernung und Rolle direkt bei der Marke. Die Kartenpositionen bleiben im Code.
|
||||
*
|
||||
* - `origin_city` Fertigungsort, z. B. „Bünde“ – leer bedeutet: Marke erscheint nicht in der Sektion
|
||||
* - `origin_distance` Entfernung ab Bielefeld als Text, z. B. „rund 30 km“
|
||||
* - `origin_role` Ein Satz zur Rolle des Standorts
|
||||
*
|
||||
* Alle drei sind übersetzbar wie die übrigen Textfelder der Marke.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->json('origin_city')->nullable()->after('showroom');
|
||||
$table->json('origin_distance')->nullable()->after('origin_city');
|
||||
$table->json('origin_role')->nullable()->after('origin_distance');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_brands', function (Blueprint $table) {
|
||||
$table->dropColumn(['origin_city', 'origin_distance', 'origin_role']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Referenzprojekte der Interior-Welt (Nachtrag 13).
|
||||
*
|
||||
* AP5 hatte die Referenzen aus den Markengalerien zusammengesammelt, um keine zweite Pflegestelle
|
||||
* zu schaffen. Abstimmung 15.09.: Marcel soll Referenzen als eigene Einträge pflegen – ein
|
||||
* realisiertes Projekt hat mehr als ein Bild und mehr als eine Marke. Solange kein Eintrag
|
||||
* existiert, greift das Frontend weiter auf die Markengalerien zurück.
|
||||
*
|
||||
* - `title`, `description` übersetzbar
|
||||
* - `location`, `year` Ort und Jahr als Text („Bielefeld“, „2025“)
|
||||
* - `image` Titelbild (Dateiname aus der Medienbibliothek)
|
||||
* - `gallery` weitere Bilder mit Bildunterschrift
|
||||
* - `brand_slugs` beteiligte Kernmarken (Slugs aus cms_brands), verlinkt auf die Markenseiten
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cms_references', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->json('title');
|
||||
$table->string('location')->nullable();
|
||||
$table->string('year', 20)->nullable();
|
||||
$table->json('description')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->json('gallery')->nullable();
|
||||
$table->json('brand_slugs')->nullable();
|
||||
$table->boolean('is_published')->default(true);
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cms_references');
|
||||
}
|
||||
};
|
||||
104
database/seeders/AboutPageTextSeeder.php
Normal file
104
database/seeders/AboutPageTextSeeder.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Textkorrekturen auf /about (Abstimmung 06.10.2026, Nachtrag 21).
|
||||
*
|
||||
* „… am Ende auch dafür geradesteht“, dazu Flüchtigkeits- und Formulierungsfehler: Komma bei
|
||||
* „wenigen ausgewählten“, deutsche Anführungszeichen im Zitat, „Wort halten“, Otterpohl als
|
||||
* vierte Kernmarke in der Zeitleiste, „ungenutzt“ statt „liegen lässt“, „B2in-Partner“ und
|
||||
* ein Alternativtext für die Bildpause, der das Bild beschreibt (nur Interior-Welt).
|
||||
* Der Seeder tauscht nur genau diese Textstellen in den betroffenen CMS-Sektionen; alles andere
|
||||
* bleibt. Steht eine Stelle nicht mehr drin, passiert dort nichts. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=AboutPageTextSeeder
|
||||
*/
|
||||
class AboutPageTextSeeder extends Seeder
|
||||
{
|
||||
/** @var list<array{0: string, 1: string}> Gruppe und Schlüssel der Sektionen */
|
||||
public const SECTIONS = [
|
||||
['about', 'hero'],
|
||||
['about', 'image_break'],
|
||||
['about', 'our_story'],
|
||||
['about', 'our_values'],
|
||||
['about', 'partner_cta'],
|
||||
['netzwerk', 'partner_cta'],
|
||||
];
|
||||
|
||||
/** @var array<string, string> alte Stelle → neue Stelle */
|
||||
public const REPLACEMENTS = [
|
||||
'aufmisst und am Ende geradesteht.' => 'aufmisst und am Ende auch dafür geradesteht.',
|
||||
'mit wenigen, ausgewählten Herstellern' => 'mit wenigen ausgewählten Herstellern',
|
||||
'"Gute Einrichtung entsteht' => '„Gute Einrichtung entsteht',
|
||||
'bis nach der Montage."' => 'bis nach der Montage.“',
|
||||
'verbunden durch dieselbe Grundhaltung, dass Qualität und Wort halten zusammengehören.' => 'verbunden durch dieselbe Grundhaltung: Zu Qualität gehört, Wort zu halten.',
|
||||
'2026 rückt das Interior in den Mittelpunkt: Mit CABINET, Rotpunkt Küchen und Febrü stehen starke Kernmarken im Zentrum, ergänzt um ein Netzwerk regionaler Fachexperten. Internationale Immobilien bleiben als ergänzender Bereich bestehen.' => '2026 rückt die Einrichtung in den Mittelpunkt: CABINET, Rotpunkt Küchen, Febrü und Otterpohl Möbelservice bilden die Kernmarken, ergänzt um ein Netzwerk regionaler Fachexperten. Internationale Immobilien bleiben als zweiter Bereich bestehen.',
|
||||
'Wir planen den Raum, den ein Standardmöbel liegen lässt.' => 'Wir planen auch den Raum, den ein Standardmöbel ungenutzt lässt.',
|
||||
'Werden Sie B2in Partner' => 'Werden Sie B2in-Partner',
|
||||
'B2in – Immobilien und Einrichtung verbinden' => 'Wohnraum halb als Planungsskizze, halb fertig eingerichtet',
|
||||
'B2in – Connecting Real Estate and Interior' => 'Living space, half planning sketch, half fully furnished',
|
||||
'In 2026 interiors move to the centre: CABINET, Rotpunkt Küchen and Febrü form the core, complemented by a network of regional specialists. International real estate remains as a complementary area.' => 'In 2026 interiors move to the centre: CABINET, Rotpunkt Küchen, Febrü and Otterpohl Möbelservice form the core, complemented by a network of regional specialists. International real estate remains as a second area.',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$total = 0;
|
||||
|
||||
foreach (self::SECTIONS as [$group, $key]) {
|
||||
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
|
||||
|
||||
if (! $row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
$value = $this->replaceDeep($row->getTranslations('value'), $count);
|
||||
|
||||
if ($count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache($group);
|
||||
$total += $count;
|
||||
}
|
||||
|
||||
$this->command?->info($total > 0
|
||||
? "About-Texte korrigiert ({$total} Stelle(n))."
|
||||
: 'About-Texte: bereits aktuell oder nicht im CMS – nichts geändert.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function replaceDeep(array $data, int &$count): array
|
||||
{
|
||||
foreach ($data as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$data[$key] = $this->replaceDeep($item, $count);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replaced = strtr($item, self::REPLACEMENTS);
|
||||
|
||||
if ($replaced !== $item) {
|
||||
$data[$key] = $replaced;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
125
database/seeders/AssetMediaImportSeeder.php
Normal file
125
database/seeders/AssetMediaImportSeeder.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use App\Models\CmsBrand;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Übernimmt die Bilder, die Seeder unter public/img/assets/ abgelegt haben (Marken, Magazin,
|
||||
* Slideshow …), in die Mediathek (07.10.2026) – damit sie im Bildauswahl-Dialog erscheinen.
|
||||
*
|
||||
* Name in der Mediathek ist der bisherige Pfad (z. B. `marken/cabinet/ankleide-solitaer-eiche-tano.webp`).
|
||||
* Die Editoren speichern beim Auswählen genau diesen Namen, also bleiben alle Verweise gültig; der
|
||||
* Admin (media_url()) zeigt das Bild aus der Mediathek. Die Datei wird kopiert, das Original unter
|
||||
* public/img/assets/ bleibt liegen – die Website liefert weiter von dort (WebP-Fassungen).
|
||||
*
|
||||
* Übernommen wird nur, was Marken, Magazin oder CMS-Einträge (samt Standardtexten) tatsächlich
|
||||
* verwenden. Bereits vorhandene Einträge werden übersprungen – mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=AssetMediaImportSeeder
|
||||
*/
|
||||
class AssetMediaImportSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$disk = config('flux-cms.media.disk', 'public');
|
||||
$originals = config('flux-cms.media.originals_path', 'cms/media/originals');
|
||||
$service = app(MediaConversionService::class);
|
||||
$imported = 0;
|
||||
|
||||
foreach ($this->referencedAssets() as $value => $collection) {
|
||||
if (CmsMedia::query()->where('filename', $value)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = public_path('img/assets/'.$value);
|
||||
$extension = strtolower(pathinfo($source, PATHINFO_EXTENSION));
|
||||
$path = $originals.'/'.Str::random(40).'.'.$extension;
|
||||
|
||||
Storage::disk($disk)->put($path, file_get_contents($source));
|
||||
|
||||
$isSvg = $extension === 'svg';
|
||||
$dimensions = $isSvg ? false : @getimagesize($source);
|
||||
|
||||
$media = CmsMedia::create([
|
||||
'filename' => $value,
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'type' => 'image',
|
||||
'mime_type' => $isSvg ? 'image/svg+xml' : (mime_content_type($source) ?: 'image/'.$extension),
|
||||
'file_size' => filesize($source),
|
||||
'original_width' => $dimensions ? $dimensions[0] : null,
|
||||
'original_height' => $dimensions ? $dimensions[1] : null,
|
||||
'collection' => $collection,
|
||||
'conversions' => [],
|
||||
]);
|
||||
|
||||
if (! $isSvg) {
|
||||
$service->generateThumbnail($media);
|
||||
}
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->command?->info($imported === 0
|
||||
? 'Mediathek: alle verwendeten Bilder sind bereits übernommen.'
|
||||
: "Mediathek: {$imported} Bilder aus public/img/assets übernommen.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildpfade, die Marken, Magazin oder CMS verwenden und die unter public/img/assets/ liegen,
|
||||
* mit der Sammlung, in die sie kommen (die erste Quelle zählt: Marken, Magazin, Website).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function referencedAssets(): array
|
||||
{
|
||||
$values = [];
|
||||
$collect = function (mixed $value, string $collection) use (&$collect, &$values): void {
|
||||
if (is_array($value)) {
|
||||
array_walk($value, fn ($item) => $collect($item, $collection));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value) || ! preg_match('/\.(jpe?g|png|webp|svg|gif)$/i', $value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$value = ltrim(trim($value), '/');
|
||||
|
||||
if (str_contains($value, '://') || str_starts_with($value, 'storage/') || str_starts_with($value, 'immobile/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_file(public_path('img/assets/'.$value))) {
|
||||
$values[$value] ??= $collection;
|
||||
}
|
||||
};
|
||||
|
||||
foreach (CmsBrand::query()->get() as $brand) {
|
||||
$collect([$brand->logo, $brand->image, $brand->gallery, $brand->sections], 'Marken');
|
||||
}
|
||||
|
||||
foreach (CmsArticle::query()->get() as $article) {
|
||||
$collect($article->toArray(), 'Magazin');
|
||||
}
|
||||
|
||||
foreach (CmsContent::query()->get() as $row) {
|
||||
$collect($row->getTranslations('value'), 'Website');
|
||||
}
|
||||
|
||||
$collect(trans('b2in.themes.'.config('app.theme', 'b2in'), [], 'de'), 'Website');
|
||||
|
||||
ksort($values);
|
||||
|
||||
return $values;
|
||||
}
|
||||
}
|
||||
84
database/seeders/AudienceSwitchProjectTextSeeder.php
Normal file
84
database/seeders/AudienceSwitchProjectTextSeeder.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Projekttext auf der Startseite (Briefing Marcel 24.09.2026, Punkt 7; Nachtrag 19).
|
||||
*
|
||||
* In der Karte „Ausstattung für Ihr Projekt“ (home/audience_switch) wird „Mit deutscher
|
||||
* Vertragssicherheit …“ durch „Mit einem zentralen deutschen Vertragspartner …“ ersetzt.
|
||||
* Abstimmung 01.10.2026: Nach „Projektentwicklungen“ steht ein Punkt statt des Gedankenstrichs.
|
||||
* Der Seeder tauscht nur genau diesen Satz, egal wo er im Sektions-JSON steht; alles andere
|
||||
* bleibt. Steht der Satz nicht mehr drin, passiert nichts. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=AudienceSwitchProjectTextSeeder
|
||||
*/
|
||||
class AudienceSwitchProjectTextSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, string> alter Satz → neuer Satz */
|
||||
public const REPLACEMENTS = [
|
||||
'Mit deutscher Vertragssicherheit und einem eingespielten Herstellernetzwerk.' => 'Mit einem zentralen deutschen Vertragspartner und einem eingespielten Herstellernetzwerk.',
|
||||
'With German contractual reliability and an established manufacturer network.' => 'With a single German contract partner and an established manufacturer network.',
|
||||
'Projektentwicklungen – koordiniert aus einer Hand' => 'Projektentwicklungen. Koordiniert aus einer Hand',
|
||||
'developments – coordinated from a single source' => 'developments. Coordinated from a single source',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'audience_switch')->first();
|
||||
|
||||
if (! $row) {
|
||||
$this->command?->info('Projekttext: Sektion home/audience_switch nicht im CMS – die Lang-Datei gilt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$count = 0;
|
||||
$value = $this->replaceDeep($value, $count);
|
||||
|
||||
if ($count === 0) {
|
||||
$this->command?->info('Projekttext: bereits aktuell, nichts geändert.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
|
||||
$this->command?->info("Projekttext ersetzt ({$count} Stelle(n)).");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function replaceDeep(array $data, int &$count): array
|
||||
{
|
||||
foreach ($data as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$data[$key] = $this->replaceDeep($item, $count);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replaced = strtr($item, self::REPLACEMENTS);
|
||||
|
||||
if ($replaced !== $item) {
|
||||
$data[$key] = $replaced;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
64
database/seeders/BrandGalleryCaptionNormalizerSeeder.php
Normal file
64
database/seeders/BrandGalleryCaptionNormalizerSeeder.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Entfernt den Vermerk „BEISPIEL – “ aus vorhandenen Galerie-Bildunterschriften.
|
||||
*
|
||||
* Der Vermerk stammt aus {@see DemoBrandGallerySeeder} und ist auf Kundenwunsch entfallen. Weil
|
||||
* die Bilder auf Vorschau- und Livesystem bereits in der Datenbank stehen, reicht die Änderung am
|
||||
* Demo-Seeder nicht aus – der wuerde beim erneuten Ausfuehren die gesamte Galerie ueberschreiben
|
||||
* und damit gepflegte Bilder verlieren.
|
||||
*
|
||||
* Dieser Seeder fasst deshalb ausschliesslich den Text der Bildunterschrift an. Bilder, Kategorien
|
||||
* und Reihenfolge bleiben unveraendert. Mehrfaches Ausfuehren ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=BrandGalleryCaptionNormalizerSeeder
|
||||
*/
|
||||
class BrandGalleryCaptionNormalizerSeeder extends Seeder
|
||||
{
|
||||
/** Praefixe, die vorne aus der Bildunterschrift entfernt werden. */
|
||||
private const PREFIXES = ['BEISPIEL – ', 'BEISPIEL - ', 'BEISPIEL: ', 'BEISPIEL '];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$cleaned = 0;
|
||||
|
||||
foreach (CmsBrand::query()->whereNotNull('gallery')->get() as $brand) {
|
||||
$gallery = $brand->gallery ?? [];
|
||||
$changed = false;
|
||||
|
||||
foreach ($gallery as $index => $item) {
|
||||
$caption = (string) ($item['caption'] ?? '');
|
||||
$stripped = $this->stripPrefix($caption);
|
||||
|
||||
if ($stripped !== $caption) {
|
||||
$gallery[$index]['caption'] = $stripped;
|
||||
$changed = true;
|
||||
$cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$brand->gallery = CmsBrand::normalizeGallery($gallery);
|
||||
$brand->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->command?->info("Bildunterschriften bereinigt: {$cleaned}.");
|
||||
}
|
||||
|
||||
private function stripPrefix(string $caption): string
|
||||
{
|
||||
foreach (self::PREFIXES as $prefix) {
|
||||
if (str_starts_with($caption, $prefix)) {
|
||||
return trim(substr($caption, strlen($prefix)));
|
||||
}
|
||||
}
|
||||
|
||||
return $caption;
|
||||
}
|
||||
}
|
||||
162
database/seeders/BrandImageSeeder.php
Normal file
162
database/seeders/BrandImageSeeder.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Trägt Titelbild und Galerie für CABINET, Rotpunkt, Febrü (Nachtrag 16) und Otterpohl (Nachtrag 19) nach.
|
||||
*
|
||||
* Grundlage: dev/2026-08-28/images/ (Zulieferungen vom 22.–24.09.2026). Die Originale sind 1–8 MB
|
||||
* große JPEGs; unter public/img/assets/marken/{cabinet,rotpunkt,februe,otterpohl}/ liegen sie als
|
||||
* WebP mit maximal 1920 px Kantenlänge (Imagick, Qualität 82, ohne Metadaten). Die mit `_title_`
|
||||
* markierte Datei ist das Titelbild (Seitenkopf der Markenseite, Kachel auf der Startseite,
|
||||
* Markenblock auf /marken). Die Galeriebilder der drei Hersteller stehen als **Visualisierung**:
|
||||
* es sind Herstellerdarstellungen, keine von uns ausgeführten Projekte – unter „Referenzen“
|
||||
* erscheinen sie damit nicht. Bei Otterpohl sind die Bilder 2–5 Einblicke in Werkstatt und
|
||||
* Montage (**Aus der Werkstatt**, dazu das frühere Titelbild), alle übrigen ausgeführte Aufträge (**Realisiertes Projekt**);
|
||||
* letztere erscheinen unter „Referenzen“, solange dort keine eigenen Einträge gepflegt sind.
|
||||
*
|
||||
* Verhalten: Das Titelbild wird nur gesetzt, wenn keins gepflegt ist. Galeriebilder werden
|
||||
* nur ergänzt, wenn die Datei noch nicht in der Galerie steht; vorhandene Einträge bleiben
|
||||
* samt Kategorie und Bildunterschrift, wie Marcel sie gepflegt hat. Mehrfaches Ausführen ist
|
||||
* unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=BrandImageSeeder
|
||||
*/
|
||||
class BrandImageSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Galerie: Dateiname → Bildunterschrift (Kategorie Visualisierung) oder
|
||||
* Dateiname → ['caption' => …, 'category' => …] für eine andere Kategorie.
|
||||
*
|
||||
* @var array<string, array{image: string, gallery: array<string, string|array{caption: string, category: string}>}>
|
||||
*/
|
||||
private const IMAGES = [
|
||||
'cabinet' => [
|
||||
'image' => 'marken/cabinet/ankleide-solitaer-eiche-tano.webp',
|
||||
'gallery' => [
|
||||
'marken/cabinet/dachschraegenschrank-filo-cashmere.webp' => 'Dachschrägenschrank in Filo Cashmere',
|
||||
'marken/cabinet/schrank-gegen-schraege-marmor.webp' => 'Schrank gegen die Schräge in Marmoroptik',
|
||||
'marken/cabinet/schlafzimmer-eiche-tano.webp' => 'Schlafzimmerschrank in Eiche Tano',
|
||||
'marken/cabinet/schlafzimmer-velluto-fango.webp' => 'Schlafzimmerschrank in Velluto Fango',
|
||||
'marken/cabinet/schlafzimmer-wandschrank-filo-cashmere.webp' => 'Wandschrank im Schlafzimmer in Filo Cashmere',
|
||||
'marken/cabinet/garderobe-seite-offen.webp' => 'Garderobe mit offener Seite',
|
||||
'marken/cabinet/garderobe-sitznische-filo-cashmere.webp' => 'Garderobe mit Sitznische in Filo Cashmere',
|
||||
'marken/cabinet/drehtuerenschrank-marmor.webp' => 'Drehtürenschrank in Marmoroptik',
|
||||
'marken/cabinet/flur-schrank-eiche-tano.webp' => 'Flurschrank in Eiche Tano',
|
||||
'marken/cabinet/dachschraege-velluto-fango.webp' => 'Schrank unter der Dachschräge in Velluto Fango',
|
||||
'marken/cabinet/ankleide-solitaer-eiche-tano-seite.webp' => 'Ankleide Solitär in Eiche Tano, Seitenansicht',
|
||||
],
|
||||
],
|
||||
'rotpunkt' => [
|
||||
'image' => 'marken/rotpunkt/kueche-01.webp',
|
||||
'gallery' => [
|
||||
'marken/rotpunkt/kueche-01-b.webp' => 'Küche 01, zweite Ansicht',
|
||||
'marken/rotpunkt/kueche-02.webp' => 'Küche 02',
|
||||
'marken/rotpunkt/kueche-03.webp' => 'Küche 03',
|
||||
'marken/rotpunkt/kueche-04.webp' => 'Küche 04',
|
||||
'marken/rotpunkt/kueche-05.webp' => 'Küche 05',
|
||||
'marken/rotpunkt/kueche-06.webp' => 'Küche 06',
|
||||
'marken/rotpunkt/kueche-06-b.webp' => 'Küche 06, zweite Ansicht',
|
||||
'marken/rotpunkt/kueche-07.webp' => 'Küche 07',
|
||||
'marken/rotpunkt/kueche-08.webp' => 'Küche 08',
|
||||
'marken/rotpunkt/kueche-09.webp' => 'Küche 09',
|
||||
'marken/rotpunkt/kueche-10.webp' => 'Küche 10',
|
||||
'marken/rotpunkt/kueche-11.webp' => 'Küche 11',
|
||||
'marken/rotpunkt/kueche-12.webp' => 'Küche 12',
|
||||
],
|
||||
],
|
||||
// Bildunterschriften nach den Febrü-Produktlinien aus den Dateinamen.
|
||||
'februe' => [
|
||||
'image' => 'marken/februe/base-thekenloesung.webp',
|
||||
'gallery' => [
|
||||
'marken/februe/active-2-0-bureo.webp' => 'Active 2.0 mit Bureo',
|
||||
'marken/februe/active-2-0-soul-grossraum.webp' => 'Großraum mit Active 2.0 und Soul',
|
||||
'marken/februe/adesso.webp' => 'Adesso',
|
||||
'marken/februe/arena-stage.webp' => 'Arena Stage',
|
||||
'marken/februe/base.webp' => 'Base',
|
||||
'marken/februe/base-sideboard.webp' => 'Base Sideboard',
|
||||
'marken/februe/bobb-bionics-coworker-container-cube.webp' => 'Bobb, Bionics, CoWorker, Container und Cube',
|
||||
'marken/februe/bobb.webp' => 'Bobb',
|
||||
'marken/februe/brandon.webp' => 'Brandon',
|
||||
'marken/februe/coworker.webp' => 'CoWorker',
|
||||
'marken/februe/corporate.webp' => 'Corporate',
|
||||
'marken/februe/curved-ben-basic.webp' => 'Curved, Ben und Basic',
|
||||
'marken/februe/eline-one-silent-garderobenpaneel.webp' => 'E-Line, One, Silent und Garderobenpaneel',
|
||||
'marken/februe/elements-soul-grossraum.webp' => 'Großraum mit Elements und Soul',
|
||||
'marken/februe/vio.webp' => 'Vio',
|
||||
],
|
||||
],
|
||||
// Otterpohl (Zulieferung 24.09.): Werkstattbilder und Referenzen. Seit 01.10. ist die Treppe mit
|
||||
// Lamellenwand (tittle_otter.png) das Titelbild, das frühere Werkstatt-Titelbild steht in der
|
||||
// Galerie – {@see OtterpohlBrandSeeder::swapTitleImage()} tauscht bestehende Installationen.
|
||||
// Unterschriften ohne Kundennamen – die stehen nur in den Dateinamen der Zulieferung.
|
||||
'tischlerei' => [
|
||||
'image' => OtterpohlBrandSeeder::TITLE_IMAGE,
|
||||
'gallery' => [
|
||||
'marken/otterpohl/referenz-dziuballe-treppe.webp' => ['caption' => 'Treppe mit Holzstufen, Stufenbeleuchtung und Eichenhandlauf', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-luecke-12.webp' => ['caption' => 'Tür und Kommode vor Lamellenwand', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-neugebauer.webp' => ['caption' => 'Gewendelte Treppe mit Eichenstufen', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-luecke-15.webp' => ['caption' => 'Beleuchtete Wandregale über Eck', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-berkemeier-treppe.webp' => ['caption' => 'Treppe mit umlaufender Sockelleiste und Lichtdetail', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-objektbau-luecke-technik.webp' => ['caption' => 'Objektbau: Verkaufsraum mit Theke und Regalwand', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
'marken/otterpohl/referenz-otterpohl-11.webp' => ['caption' => 'Sideboard mit Eichenplatte im Büro', 'category' => CmsBrand::GALLERY_PROJECT],
|
||||
OtterpohlBrandSeeder::FORMER_TITLE_IMAGE => ['caption' => OtterpohlBrandSeeder::FORMER_TITLE_CAPTION, 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
'marken/otterpohl/werkstatt-02.webp' => ['caption' => 'CNC-Bearbeitungszentrum in der Fertigung', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
'marken/otterpohl/werkstatt-03.webp' => ['caption' => 'Einbau einer Tür vor Ort', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
'marken/otterpohl/werkstatt-04.webp' => ['caption' => 'Handwerkzeug in der Werkstatt', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
'marken/otterpohl/werkstatt-05-lift.webp' => ['caption' => 'Anlieferung per Möbellift – Montage inklusive', 'category' => CmsBrand::GALLERY_COMPANY],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach (self::IMAGES as $slug => $data) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$titleSet = false;
|
||||
|
||||
if (trim((string) $brand->image) === '') {
|
||||
$brand->image = $data['image'];
|
||||
$titleSet = true;
|
||||
}
|
||||
|
||||
$gallery = CmsBrand::normalizeGallery($brand->gallery);
|
||||
$known = array_column($gallery, 'image');
|
||||
$added = 0;
|
||||
|
||||
foreach ($data['gallery'] as $image => $entry) {
|
||||
if (in_array($image, $known, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gallery[] = [
|
||||
'image' => $image,
|
||||
'category' => is_array($entry) ? $entry['category'] : CmsBrand::GALLERY_INSPIRATION,
|
||||
'caption' => is_array($entry) ? $entry['caption'] : $entry,
|
||||
];
|
||||
$added++;
|
||||
}
|
||||
|
||||
$brand->gallery = $gallery;
|
||||
$brand->save();
|
||||
|
||||
$this->command?->info(sprintf(
|
||||
'Marke „%s“: Titelbild %s, %d Galeriebilder ergänzt, %d vorhanden.',
|
||||
$slug,
|
||||
$titleSet ? 'gesetzt' : 'behalten',
|
||||
$added,
|
||||
count($gallery) - $added,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
72
database/seeders/BrandLogoSeeder.php
Normal file
72
database/seeders/BrandLogoSeeder.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Traegt die Markenlogos nach.
|
||||
*
|
||||
* Grundlage: dev/2026-08-28/markenlogos/ (Zulieferung vom 03.09.2026). Die Dateien liegen unter
|
||||
* public/img/assets/marken/ und sind dort **optisch aufeinander abgestimmt** – das Frontend
|
||||
* rendert sie in einer gemeinsamen Box, in der jedes Logo seine Zeichenflaeche ausfuellt:
|
||||
*
|
||||
* Rotpunkt Zeichenflaeche = Inhalt → fuellt die Box, gilt als Bezugsgroesse
|
||||
* CABINET Zeichenflaeche rechts +18 % → rendert 15 % kleiner als Rotpunkt
|
||||
* Februe Zeichenflaeche oben/unten +20 % → rendert wie vor der Abstimmung
|
||||
* Otterpohl knapp beschnitten, weiss freigestellt (Zulieferung 17.09.2026, Nachtrag 15)
|
||||
*
|
||||
* Alle drei waren in der Zulieferung sehr unterschiedlich stark umrandet (CABINET nutzte nur
|
||||
* 29 Prozent der Hoehe) und wirkten dadurch ungleich gross. Die Abstimmung steckt bewusst in den
|
||||
* Dateien und nicht im Blade: so gilt sie an allen vier Stellen gleichzeitig – Seitenkopf,
|
||||
* Startseiten-Kachel, /marken und „Weitere Marken“.
|
||||
*
|
||||
* Wird ein Logo im Admin ausgetauscht, sollte die neue Datei genauso knapp beschnitten sein.
|
||||
* Ohne Rand rendert sie in Rotpunkt-Groesse, mit viel Rand entsprechend kleiner.
|
||||
*
|
||||
* {@see CmsBrandSeeder} bringt dieselben Werte fuer frische Installationen mit, ueberspringt
|
||||
* aber bereits angelegte Marken. Dieser Seeder fasst nur das Feld `logo` an und auch nur dann,
|
||||
* wenn es leer ist – ein selbst hochgeladenes Logo bleibt also stehen. Mehrfaches Ausfuehren
|
||||
* ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=BrandLogoSeeder
|
||||
*/
|
||||
class BrandLogoSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private const LOGOS = [
|
||||
'cabinet' => 'marken/cabinet.svg',
|
||||
'rotpunkt' => 'marken/rotpunkt.svg',
|
||||
'februe' => 'marken/februe.png',
|
||||
'tischlerei' => 'marken/otterpohl.png',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$set = 0;
|
||||
$kept = 0;
|
||||
|
||||
foreach (self::LOGOS as $slug => $logo) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – uebersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trim((string) $brand->logo) !== '') {
|
||||
$kept++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand->logo = $logo;
|
||||
$brand->save();
|
||||
$set++;
|
||||
}
|
||||
|
||||
$this->command?->info("Markenlogos gesetzt: {$set}, vorhandene behalten: {$kept}.");
|
||||
}
|
||||
}
|
||||
87
database/seeders/BrandOriginSeeder.php
Normal file
87
database/seeders/BrandOriginSeeder.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Trägt den Fertigungsort der Kernmarken nach (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
|
||||
*
|
||||
* Die Herkunfts-Sektion „Aus der Region gefertigt“ liest Ort, Entfernung und Rolle aus der
|
||||
* Marke. {@see CmsBrandSeeder} bringt dieselben Werte für frische Installationen mit,
|
||||
* überspringt aber bereits angelegte Marken. Dieser Seeder fasst nur die drei Felder an und
|
||||
* auch nur bei Marken, deren Fertigungsort noch leer ist – ein im Admin eingetragener Ort
|
||||
* bleibt stehen. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* Entfernungen gerundet, Straße ab Bielefeld Mitte. ⚠️ Rolle CABINET nach Kevins Verständnis
|
||||
* (Fertigung Köln, Beratung im Store Bielefeld) – von Marcel bestätigen lassen.
|
||||
*
|
||||
* php artisan db:seed --class=BrandOriginSeeder
|
||||
*/
|
||||
class BrandOriginSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, array{origin_city: array<string, string>, origin_distance: array<string, string>, origin_role: array<string, string>}> */
|
||||
private const ORIGINS = [
|
||||
'cabinet' => [
|
||||
'origin_city' => ['de' => 'Köln', 'en' => 'Cologne'],
|
||||
'origin_distance' => ['de' => 'rund 200 km', 'en' => 'about 200 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
|
||||
'en' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
|
||||
],
|
||||
],
|
||||
'rotpunkt' => [
|
||||
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
|
||||
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
|
||||
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
|
||||
],
|
||||
],
|
||||
'februe' => [
|
||||
'origin_city' => ['de' => 'Herford', 'en' => 'Herford'],
|
||||
'origin_distance' => ['de' => 'rund 20 km', 'en' => 'about 20 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
|
||||
'en' => 'Office and contract furniture from their own production in Herford.',
|
||||
],
|
||||
],
|
||||
'tischlerei' => [
|
||||
'origin_city' => ['de' => 'Rheda-Wiedenbrück', 'en' => 'Rheda-Wiedenbrück'],
|
||||
'origin_distance' => ['de' => 'rund 35 km', 'en' => 'about 35 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Tischlerei und Innenausbau aus Rheda-Wiedenbrück – vom einzelnen Möbel bis zum ganzen Raum, inklusive Montage.',
|
||||
'en' => 'Joinery and interior fit-out from Rheda-Wiedenbrück – from a single piece to the whole room, installation included.',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$set = 0;
|
||||
$kept = 0;
|
||||
|
||||
foreach (self::ORIGINS as $slug => $origin) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trim((string) $brand->getTranslation('origin_city', 'de', false)) !== '') {
|
||||
$kept++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand->fill($origin);
|
||||
$brand->save();
|
||||
$set++;
|
||||
}
|
||||
|
||||
$this->command?->info("Fertigungsorte gesetzt: {$set}, vorhandene behalten: {$kept}.");
|
||||
}
|
||||
}
|
||||
438
database/seeders/BrandPageTextSeeder.php
Normal file
438
database/seeders/BrandPageTextSeeder.php
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Ausführliche Markenseiten für CABINET, Rotpunkt Küchen und Febrü.
|
||||
*
|
||||
* Grundlage für Rotpunkt und Febrü sind die abgestimmten Fassungen:
|
||||
* dev/2026-08-28/Rotpunkt_Webseitentext_FINAL.md
|
||||
* dev/2026-08-28/Februe_Webseitentext_FINAL.md
|
||||
*
|
||||
* Die dort mit „AUFKLAPPBARER DETAILBEREICH“ gekennzeichneten Vertiefungen stehen hier als
|
||||
* `detail` am jeweiligen Abschnitt und werden im Frontend aufklappbar dargestellt. Die internen
|
||||
* Quellen- und Umsetzungshinweise sind bewusst nicht übernommen – sie sind laut Zulieferung
|
||||
* nicht zur Veröffentlichung bestimmt.
|
||||
*
|
||||
* ⚠️ Für CABINET lag keine Zulieferung vor. Der Text ist nach dem Vorbild der beiden
|
||||
* abgestimmten Seiten von uns verfasst und beschreibt bewusst nur die eigene Arbeitsweise:
|
||||
* keine Zahlen, Zertifizierungen oder Herstelleraussagen, die niemand belegen kann. Er braucht
|
||||
* die Freigabe durch Marcel Scheibe, bevor er live geht.
|
||||
*
|
||||
* Feedback 17.09.2026 (dev/2026-08-28/feedback-17-09-2026.md) eingearbeitet: CABINET Einstieg
|
||||
* „Bild und grobes Maß“ und die Fragen vorab; Febrü „Kunden – keine Schubladen“, „Aus Herford“
|
||||
* und „Wertschätzung“, doppelter Schlusssatz entfernt; Rotpunkt „Mehr als Küchen“. Die
|
||||
* markeneigenen Handwerk-Abschnitte sind entfallen: das Thema steht als gemeinsamer Abschluss
|
||||
* auf jeder Markenseite (Lang-Sektion `brand_craft`, siehe BrandDetail).
|
||||
*
|
||||
* ⚠️ Überschreibt bei bestehenden Marken **nur** die Felder aus {@see FIELDS}. Bilder, Galerie,
|
||||
* Leistungen und Reihenfolge bleiben unangetastet, damit gepflegte Inhalte erhalten bleiben.
|
||||
*
|
||||
* php artisan db:seed --class=BrandPageTextSeeder
|
||||
*/
|
||||
class BrandPageTextSeeder extends Seeder
|
||||
{
|
||||
/** Felder, die dieser Seeder anfasst. Alles andere bleibt, wie Marcel es gepflegt hat. */
|
||||
private const FIELDS = ['tagline', 'description', 'sections', 'widget_label', 'cta_title', 'cta_text'];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$updated = 0;
|
||||
|
||||
foreach ($this->pages() as $slug => $data) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (self::FIELDS as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$brand->{$field} = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
$brand->save();
|
||||
$updated++;
|
||||
|
||||
$this->command?->info("Markenseite „{$slug}“: ".count($data['sections'] ?? []).' Abschnitte übernommen.');
|
||||
}
|
||||
|
||||
$this->command?->info("Markenseiten aktualisiert: {$updated}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function pages(): array
|
||||
{
|
||||
return [
|
||||
'cabinet' => [
|
||||
'widget_label' => ['de' => 'Persönliche Schrankplanung vereinbaren', 'en' => 'Arrange a personal wardrobe consultation'],
|
||||
'description' => [
|
||||
'de' => '<p>Ein Schrank ist selten der Grund, warum jemand ein Zuhause liebt. Er ist aber sehr oft der Grund, warum ein Raum funktioniert – oder eben nicht. Und Hand aufs Herz: Die schönste Dachschräge nützt wenig, wenn darunter seit dem Einzug drei Kartons stehen, für die nie ein Platz gefunden wurde.</p>'
|
||||
.'<p>CABINET plant Einbauschränke, die sich dem Raum anpassen statt umgekehrt: von Wand zu Wand, vom Boden bis zur Decke, um die Schräge herum und am Heizkörper vorbei.</p>'
|
||||
.'<p>Unsere Aufgabe ist es, herauszufinden, was genau dieser eine Raum leisten muss. Bei uns in der Bielefelder Rathausstraße entsteht daraus nicht einfach ein Schrank, sondern eine Lösung, die den vorhandenen Platz vollständig nutzt.</p>',
|
||||
'en' => '<p>A wardrobe is rarely the reason someone loves a home. It is very often the reason a room works – or does not.</p>',
|
||||
],
|
||||
'cta_title' => ['de' => 'Ihr Raum. Persönlich geplant.', 'en' => 'Your room. Personally planned.'],
|
||||
'cta_text' => [
|
||||
'de' => 'Jeder Raum hat seine Ecken, Schrägen und Eigenheiten. Gemeinsam planen wir eine CABINET-Lösung, die genau dort hineinpasst, im Alltag funktioniert und den vorhandenen Platz vollständig nutzt. Persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
|
||||
'en' => 'Every room has its corners, slopes and quirks. Together we plan a CABINET solution that fits exactly, works day to day and uses the available space completely.',
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Millimeter statt Standardmaß',
|
||||
'paragraphs' => [
|
||||
'Schränke von der Stange sind für den durchschnittlichen Raum gemacht. Nur wohnt niemand im durchschnittlichen Raum. Es gibt die Schräge, den Mauervorsprung, das Fenster an der ungünstigen Stelle und den Heizkörper, den niemand versetzen möchte.',
|
||||
'CABINET plant jedes Element für seinen Platz. Der Schrank endet damit nicht dort, wo das Raster aufhört, sondern dort, wo der Raum aufhört. Was sonst als schwer nutzbare Restfläche übrig bleibt, wird zu Stauraum.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Wo Standardlösungen an ihre Grenzen stoßen',
|
||||
'paragraphs' => [
|
||||
'Planbar sind beispielsweise Schränke unter Dachschrägen, raumhohe Fronten bis unter die Decke, passgenaue Nischenlösungen zwischen zwei Wänden, Übergänge zu vorhandenen Möbeln sowie Aussparungen für Fensterbänke, Vorsprünge und Installationen.',
|
||||
'Auch der Sockel, die Fußleiste und der Abschluss zur Decke gehören zur Planung. Genau an diesen Übergängen entscheidet sich, ob ein Schrank wie ein Möbelstück vor der Wand wirkt oder wie ein selbstverständlicher Teil des Raumes.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Ein Bild und ein grobes Maß reichen für den Anfang',
|
||||
'paragraphs' => [
|
||||
'Am Anfang reicht das grobe Maß. Am Ende entscheidet der Millimeter.',
|
||||
'Sie müssen also nicht erst alles millimetergenau ausmessen, bevor Sie zu uns kommen. Ein paar Fotos, ein Grundriss und die ungefähren Maße reichen, damit wir Sie beraten, planen und Ihnen ein konkretes Angebot erstellen können.',
|
||||
'Danach gehen wir gemeinsam ins Detail: Aufteilung, Ausstattung, Materialien, Farben und natürlich der Preis.',
|
||||
'Wenn Sie das Angebot annehmen und die Anzahlung erfolgt ist, übernehmen wir das professionelle Aufmaß bei Ihnen vor Ort. Ab jetzt zählt jeder Millimeter: Auf Grundlage dieser Maße machen wir die Feinplanung fertig, geben die Fertigung frei und bestellen Ihren Schrank.',
|
||||
],
|
||||
'claim' => 'So läuft’s: Foto / Grundriss + grobe Maße → Beratung → Planung & Angebot → Angebotsannahme → Anzahlung → professionelles Aufmaß → Feinplanung / Fertigungsfreigabe → Bestellung / Fertigung',
|
||||
],
|
||||
[
|
||||
'title' => 'Innen entscheidet sich, ob ein Schrank funktioniert',
|
||||
'paragraphs' => [
|
||||
'Eine schöne Front ist schnell ausgesucht. Ob ein Schrank im Alltag trägt, entscheidet sich dahinter: bei Einteilung, Auszügen, Fachhöhen, Beleuchtung und der Frage, was in welcher Höhe erreichbar sein muss.',
|
||||
'Wir planen die Innenausstattung deshalb nicht nach Katalogseite, sondern nach dem, was tatsächlich untergebracht werden soll.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Die Fragen, die wir vorher stellen',
|
||||
'paragraphs' => [
|
||||
'Wie viel Hängefläche wird wirklich gebraucht und wie viel Fachboden? Wo sollen Schuhe, Koffer, Bettwäsche, Sportsachen oder Weihnachtsdeko hin? Wer nutzt den Schrank – und wie groß ist diese Person?',
|
||||
'Auch die Dinge, die man nicht jeden Tag braucht, gehören in die Planung: Wäschekörbe, Bügelbrett, Staubsauger, Karnevalskostüme oder besondere Sportsachen. Wenn wir vorher wissen, was untergebracht werden soll, können wir den Platz direkt sinnvoll dafür einplanen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Fronten, die zum Raum gehören',
|
||||
'paragraphs' => [
|
||||
'Ein Einbauschrank kann bewusst auffallen oder nahezu verschwinden. Fronten in Farbe, Holzoptik, Glas oder Spiegel, mit Griff oder grifflos, verändern die Wirkung eines Raumes erheblich – gerade weil ein raumhoher Schrank eine große Fläche ist.',
|
||||
'Wir stimmen Fronten, Griffe und Oberflächen auf die vorhandene Architektur ab: auf Bodenbelag, Türen, Licht und angrenzende Möbel. So entsteht kein Fremdkörper, sondern eine ruhige Fläche, die den Raum größer wirken lässt.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Sichtbar oder unsichtbar – beides ist eine Entscheidung',
|
||||
'paragraphs' => [
|
||||
'Eine wandfarbene, grifflose Front tritt zurück und lässt den Raum offener wirken. Eine Spiegelfront bringt Licht in schmale Flure und Ankleiden. Eine dunkle Holzfront setzt dagegen bewusst einen gestalterischen Schwerpunkt.',
|
||||
'Beides ist richtig – nur eben nicht gleichzeitig. Wir klären deshalb früh, welche Rolle der Schrank im Raum übernehmen soll, bevor wir über Dekore sprechen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Mehr als Schlafzimmer',
|
||||
'paragraphs' => [
|
||||
'Maßgefertigter Stauraum ist längst nicht auf die Ankleide beschränkt. Genauso gefragt sind Garderoben im Flur, Hauswirtschaftsräume, Dachgeschosse, Arbeitszimmer, Wohnwände mit Medientechnik und Aktenschränke in Praxen und Kanzleien.',
|
||||
'Materialien, Farben und Gestaltungselemente lassen sich über mehrere Räume hinweg aufgreifen. So entsteht nicht in jedem Zimmer eine neue Idee, sondern ein zusammenhängendes Interior-Konzept mit einer gemeinsamen gestalterischen Sprache.',
|
||||
'Zusammen mit Rotpunkt Küchen, Febrü und weiteren ausgewählten Partnern betrachten wir bei Bedarf den gesamten Innenraum: CABINET übernimmt die millimetergenaue Sonderlösung, Rotpunkt die Küche und Febrü die Arbeitswelt.',
|
||||
],
|
||||
'claim' => 'CABINET für den Millimeter. B2in für das Ganze.',
|
||||
],
|
||||
[
|
||||
'title' => 'Warum CABINET bei uns?',
|
||||
'paragraphs' => [
|
||||
'Bei uns beginnt eine Planung nicht mit einem Prospekt, sondern mit Ihrem Raum und der Frage, wie Sie darin leben möchten. Erst danach sprechen wir über Fronten, Auszüge und Beschläge.',
|
||||
'Wir planen Stauraum, Wege, Licht, Materialien und angrenzende Wohnbereiche als zusammenhängendes Konzept – und begleiten den gesamten Weg von der ersten Idee über das Aufmaß bis zur fertigen Montage.',
|
||||
'Dabei planen wir nicht für den nächsten schnellen Verkauf, sondern für eine möglichst lange und zufriedene Nutzung. Ein Schrank, der zu viel kann, aber nicht gebraucht wird, ist keine gute Lösung – nur eine teure.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Erleben statt nur beschreiben',
|
||||
'paragraphs' => [
|
||||
'Oberflächen, Beschläge und Auszüge lassen sich schlecht beschreiben und gut ausprobieren. In unserem Showroom in Bielefeld können Sie Fronten anfassen, Auszüge öffnen, Innenausstattungen vergleichen und die Unterschiede selbst beurteilen.',
|
||||
'Sie müssen sich also nicht allein auf unser Verkaufsgespräch verlassen, sondern entscheiden auf Grundlage dessen, was Sie tatsächlich gesehen und angefasst haben.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'rotpunkt' => [
|
||||
'tagline' => ['de' => 'Küchen, die Haltung zeigen.', 'en' => 'Kitchens with character.'],
|
||||
'widget_label' => ['de' => 'Persönliche Küchenberatung vereinbaren', 'en' => 'Arrange a personal kitchen consultation'],
|
||||
'description' => [
|
||||
'de' => '<p>Eine Küche ist weit mehr als ein funktionaler Raum. Sie ist Treffpunkt, Arbeitsplatz und Mittelpunkt des täglichen Lebens. Und Hand aufs Herz: Wo finden die besten Gespräche statt – und wo enden die meisten Partys? Meistens in der Küche.</p>'
|
||||
.'<p>Rotpunkt verbindet außergewöhnliche Gestaltungsfreiheit, intelligente Stauraumlösungen und durchdachte Materiallösungen zu Küchen, die nicht nur gut aussehen, sondern dauerhaft zum Leben ihrer Besitzer passen.</p>'
|
||||
.'<p>Unsere Aufgabe ist es, genau herauszuarbeiten, welche Lösung zu Ihnen, Ihrem Alltag und Ihren persönlichen Vorstellungen passt. Bei uns in der Bielefelder Rathausstraße entsteht daraus nicht einfach nur eine Küche. Auf Wunsch denken wir das ganze Zuhause mit und ergänzen es bei Bedarf bis ins Detail handwerklich.</p>',
|
||||
'en' => '<p>A kitchen is far more than a functional room. It is a meeting place, a workspace and the centre of daily life.</p>',
|
||||
],
|
||||
'cta_title' => ['de' => 'Ihre Küche. Persönlich geplant.', 'en' => 'Your kitchen. Personally planned.'],
|
||||
'cta_text' => [
|
||||
'de' => 'Jeder Raum ist anders. Jeder Alltag stellt andere Anforderungen. Gemeinsam entwickeln wir eine Rotpunkt-Küche, die gestalterisch überzeugt, im Alltag funktioniert und dauerhaft zu Ihnen passt. Persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
|
||||
'en' => 'Every room is different. Together we develop a Rotpunkt kitchen that convinces in design, works day to day and suits you for the long run.',
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Aus Ostwestfalen. Für Ihr Zuhause.',
|
||||
'paragraphs' => [
|
||||
'Rotpunkt entwickelt und fertigt seit 1930 hochwertige Küchenmöbel an den ostwestfälischen Standorten Bünde und Preußisch Oldendorf-Getmold.',
|
||||
'Regionale Fertigung, jahrzehntelange Erfahrung und die unmittelbare Nähe zum Hersteller sind für uns keine romantischen Randnotizen, sondern die Grundlage für verlässliche Qualität und persönliche Küchenplanung.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Gestaltungsfreiheit bis ins Detail',
|
||||
'paragraphs' => [
|
||||
'Rotpunkt bietet eine außergewöhnliche Vielfalt an Fronten, Farben, Materialien, Korpusdekoren, Arbeitsplatten, Griffvarianten und grifflosen Lösungen. Mehr als 20 beidseitig ausgeführte Korpusfarben sorgen dafür, dass eine Küche nicht nur außen, sondern auch innen konsequent gestaltet werden kann.',
|
||||
'So planen wir keinen vorgegebenen Küchenblock, sondern eine Lösung, die sich an Raum, Architektur und den tatsächlichen Gewohnheiten ihrer Besitzer orientiert.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'FENIX und authentische Holzoptiken',
|
||||
'paragraphs' => [
|
||||
'FENIX ist eine besonders matte Oberfläche mit geringer Lichtreflexion, angenehm weicher Haptik und ausgeprägten Anti-Fingerprint-Eigenschaften. Oberflächliche Mikrokratzer können thermisch behandelt werden. 27 aufeinander abgestimmte Farben eröffnen eine breite Gestaltungsvielfalt.',
|
||||
'Auch bei ausgewählten Fronten in Holzoptik zeigt sich die Liebe zum Detail. Synchronporen-Dekore stimmen sichtbare Maserung und fühlbare Struktur präzise aufeinander ab. Dadurch entsteht bereits bei pflegeleichten Melamin-Holzreproduktionen eine natürliche Wirkung.',
|
||||
'Durch eine sorgfältige Planung kann die Holzoptik über mehrere Fronten hinweggeführt werden. Das Ergebnis ist ein ruhiges, zusammenhängendes Frontbild – ohne dass dafür zwingend Echtholz eingesetzt werden muss.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Mehr Raum. Mehr Ergonomie. Mehr Küche.',
|
||||
'paragraphs' => [
|
||||
'Besonders hohe Unterschränke, niedrige Sockelhöhen und zusätzliche Schrankbreiten schaffen mehr nutzbaren Stauraum und eröffnen neue planerische Möglichkeiten. Klingt nach Kleinkram? Vielleicht. Im täglichen Gebrauch macht genau dieser Kleinkram einen erstaunlich großen Unterschied.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'NEW DIMENSIONS',
|
||||
'paragraphs' => [
|
||||
'Mit dem Maßkonzept NEW DIMENSIONS sind beispielsweise Unterschrankhöhen von bis zu 910 Millimetern in Verbindung mit einem nur 50 Millimeter hohen Sockel möglich.',
|
||||
'Zusätzliche Schrankbreiten und vielfältige Innenausstattungen helfen uns, den vorhandenen Raum konsequent und ergonomisch zu nutzen. Die Küche wirkt dadurch nicht nur großzügiger – sie ist es tatsächlich.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Nachhaltigkeit mit konkreter Substanz',
|
||||
'paragraphs' => [
|
||||
'Nachhaltigkeit braucht keine großen Versprechen, sondern nachvollziehbare Fakten. Greenline steht bei Rotpunkt für konkrete Materialien, Zertifizierungen und messbare Unterschiede. Deshalb legen wir offen, welche Materialien Rotpunkt verwendet, welche Zertifizierungen dahinterstehen und welchen konkreten Unterschied Greenline für Umwelt und Raumklima macht.',
|
||||
'Die Greenline BioBoard Gen2 besteht aus bis zu 90 Prozent recyceltem Holz. 95 Prozent des Rotpunkt-Küchensortiments sind FSC-zertifiziert. Außerdem sind die Produkte nach JIS F4Star hinsichtlich ihrer Formaldehydemissionen zertifiziert.',
|
||||
'Sie haben bereits eine vergleichbare Küche im Blick, die beim Thema Nachhaltigkeit mehr verspricht? Bringen Sie die Unterlagen gerne mit. Wir schauen gemeinsam genau hin – ehrlich, nachvollziehbar und ohne Verkaufsnebel.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Zahlen, Zertifizierungen und Raumklima',
|
||||
'paragraphs' => [
|
||||
// ⚠️ Waldflächen-Aussage: laut Zulieferung nur mit ausdrücklicher Herstellerbestätigung
|
||||
// veröffentlichen. Steht als Punkt in der Deployment-Checkliste.
|
||||
'Nach Angaben von Rotpunkt bleibt durch den Einsatz der Greenline-Spanplatten jährlich eine Waldfläche von ungefähr 50 Fußballfeldern erhalten.',
|
||||
'Die Greenline BioBoard Gen2 gibt laut Rotpunkt rund 50 Prozent weniger Formaldehyd an die Raumluft ab als herkömmliche Produkte. Das ist insbesondere für ein möglichst gesundes Raumklima relevant.',
|
||||
'Die FSC-Zertifizierung wird unter der Kennung FSC C131864 geführt. Entscheidend ist für uns nicht das grüne Etikett, sondern die konkrete und überprüfbare Aussage dahinter.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Mehr als Küchen',
|
||||
'paragraphs' => [
|
||||
'Die Möglichkeiten von Rotpunkt enden nicht bei der klassischen Küche. Das Möbel- und Planungssystem lässt sich auch für Hauswirtschaftsräume, Badezimmer, Sideboards, Wohnmöbel und weitere Bereiche rund um das Wohnen einsetzen.',
|
||||
'Materialien, Farben und Gestaltungselemente können dabei über mehrere Räume hinweg aufgegriffen werden. So entsteht ein stimmiges Gesamtbild, statt in jedem Raum wieder ganz von vorne anzufangen.',
|
||||
'Bei Bedarf denken wir den Innenraum deshalb auch über die Küche hinaus – von Möbeln und Beleuchtung bis zu Tapeten, Fußböden und, wenn es sein soll, der letzten Blume.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Warum eine Rotpunkt-Küche bei uns?',
|
||||
'paragraphs' => [
|
||||
'Bei uns beginnt eine neue Küche nicht mit einem Prospekt, sondern mit Ihrem Raum und der Frage, wie Sie darin leben möchten.',
|
||||
'Arbeitsabläufe, Ergonomie, Stauraum, Materialien, Beleuchtung, Geräte und angrenzende Wohnbereiche planen wir als zusammenhängendes Konzept. Auch die Partystellplätze wollen wohlüberlegt sein – also jene Orte, an denen Gäste später stehen, sitzen, reden und vielleicht etwas länger bleiben.',
|
||||
'Wir stellen nicht einfach möglichst viele Produkte zusammen. Wir entwickeln eine Küche, die gestalterisch, funktional und wirtschaftlich überzeugt – und begleiten den gesamten Weg von der ersten Idee bis zur professionellen Montage.',
|
||||
'Dabei planen wir nicht für den nächsten schnellen Verkauf, sondern für eine möglichst lange und zufriedene Nutzung. Das ist kurzfristig vielleicht nicht immer die wirtschaftlichste Entscheidung für uns. Für eine ehrliche und dauerhafte Kundenbeziehung ist es die einzig richtige.',
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Erleben statt nur erzählen',
|
||||
'paragraphs' => [
|
||||
'Eine gute Entscheidung entsteht nicht allein am Planungstisch. Auf Wunsch – und manchmal auch mit etwas freundlicher Überzeugungsarbeit – begleiten wir Sie nach Abstimmung in das nahe Rotpunkt-Werk oder in ausgewählte Ausstellungen der Gerätehersteller.',
|
||||
'Dort können Sie Materialien, Fertigung und Technik selbst erleben, Geräte ausprobieren und Fragen direkt vor Ort stellen. Sie müssen sich also nicht allein auf unser Verkaufsgespräch verlassen, sondern erhalten Informationen aus erster Hand, können vergleichen und anschließend mit einem sicheren Gefühl entscheiden.',
|
||||
'Wir begleiten Sie dabei, ordnen die unterschiedlichen Möglichkeiten ein und führen Ihre Entscheidungen zu einem stimmigen Gesamtkonzept zusammen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'februe' => [
|
||||
'tagline' => ['de' => 'Arbeiten, wo man gerne bleibt.', 'en' => 'Work where people like to stay.'],
|
||||
'widget_label' => ['de' => 'Persönliche Arbeitsplatzberatung vereinbaren', 'en' => 'Arrange a personal workspace consultation'],
|
||||
'description' => [
|
||||
'de' => '<p>Ein Büro ist kein Parkplatz für Schreibtische. Es ist ein Raum für Konzentration, Austausch, Bewegung und neue Ideen. Und manchmal entsteht die beste Idee eben nicht am Schreibtisch, sondern zwischen Kaffeemaschine, Lounge und einem guten Gespräch.</p>'
|
||||
.'<p>Febrü verbindet ergonomische Arbeitsplätze mit wohnlichem Design, intelligentem Stauraum, durchdachter Akustik und flexiblen Raumstrukturen.</p>'
|
||||
.'<p>Wir entwickeln daraus Arbeitswelten, die zu den Menschen, ihren Aufgaben und ihrer Umgebung passen – vom einzelnen Homeoffice bis zur vollständigen Unternehmensfläche.</p>',
|
||||
'en' => '<p>An office is not a car park for desks. It is a space for concentration, exchange, movement and new ideas.</p>',
|
||||
],
|
||||
'cta_title' => ['de' => 'Ihr Arbeitsplatz. Persönlich geplant.', 'en' => 'Your workspace. Personally planned.'],
|
||||
'cta_text' => [
|
||||
'de' => 'Ob Homeoffice, Praxis, Kanzlei, wachsendes Unternehmen oder vollständige Arbeitswelt: Wir planen jeden Arbeitsplatz mit demselben Anspruch. Nur der Umfang verändert sich. Gemeinsam entwickeln wir eine Arbeitsumgebung, die gesundes Arbeiten unterstützt, gestalterisch überzeugt und den Menschen zeigt, dass ihre tägliche Leistung wahrgenommen und wertgeschätzt wird – persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
|
||||
'en' => 'Whether a home office, a practice, a law firm or a complete working environment: we plan every workspace to the same standard. Only the scope changes.',
|
||||
],
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Zuhause oder Projekt. Zwei Wege. Derselbe Anspruch.',
|
||||
'paragraphs' => [
|
||||
'Unsere Startseite führt bewusst über zwei Wege: Zuhause und Projekt. Denn ein einzelnes Homeoffice stellt andere Anforderungen als eine Büroetage mit fünfzig Arbeitsplätzen.',
|
||||
'Unterschiedliche Aufgaben brauchen unterschiedliche Planung und natürlich auch unterschiedliche Kalkulationen. Bei uns gibt es trotzdem nur Kunden – mit unterschiedlichen Räumen, Anforderungen und Stückzahlen. Der Anspruch an Produkt, Beratung und Service bleibt derselbe.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Bei uns gibt es Kunden – keine Schubladen',
|
||||
'paragraphs' => [
|
||||
'Ob Privatkunde oder Unternehmen: Für uns gibt es zunächst einmal Kunden mit unterschiedlichen Räumen, Anforderungen und Stückzahlen.',
|
||||
'Natürlich beeinflusst die Menge den Preis. Fünfzig Arbeitsplätze werden anders kalkuliert als ein einzelner Schreibtisch fürs Homeoffice. Der Anspruch an Produkt, Beratung und Service bleibt trotzdem derselbe.',
|
||||
'Febrü bleibt Febrü. B2in bleibt B2in. Und jeder Kunde erhält eine Lösung, die zu seinem tatsächlichen Bedarf passt.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Ob im Kleinen oder im Großen – die Detailtreue entscheidet.',
|
||||
],
|
||||
[
|
||||
'title' => 'Aus Herford. Für moderne Arbeitswelten.',
|
||||
'paragraphs' => [
|
||||
'Febrü entwickelt und produziert hochwertige Büromöbel direkt im ostwestfälischen Herford – nur wenige Kilometer von Bielefeld entfernt. Eine sehr hohe Fertigungstiefe ermöglicht kurze Wege, verlässliche Qualität und hohe Flexibilität bei Materialien, Farben und individuellen Anforderungen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Von einem Schreibtisch bis zum vollständigen Bürotrakt',
|
||||
'paragraphs' => [
|
||||
'Das Sortiment reicht von ergonomischen und höhenverstellbaren Schreibtischen über Konferenz- und Besprechungstische, Bürostühle und Loungeelemente bis zu Empfangslösungen, Stauraum, Akustikelementen, mobilen Medienmöbeln, Schließfachsystemen und flexiblen Raumstrukturen.',
|
||||
'Damit lässt sich ein einzelner Arbeitsplatz genauso überzeugend planen wie eine Praxis, eine Kanzlei, ein Konferenzbereich oder ein vollständiger Bürotrakt.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Made in Herford. Geplant in Bielefeld.',
|
||||
],
|
||||
[
|
||||
'title' => 'Nicht drei Marken. Ein durchgängiger Raum.',
|
||||
'paragraphs' => [
|
||||
'Ein Bürotrakt endet nicht am Schreibtisch. Menschen brauchen Orte zum Arbeiten, für Besprechungen und Rückzug – aber ebenso für Pausen, den gemeinsamen Kaffee, das Mittagessen und den informellen Austausch.',
|
||||
'Genau hier greifen Febrü, Rotpunkt und CABINET bei uns ineinander: Febrü gestaltet die Arbeitswelt, Rotpunkt die Küche im Bürotrakt und CABINET die millimetergenaue Sonderlösung.',
|
||||
'Entscheidend ist nicht, welcher Hersteller welches Möbel liefert. Entscheidend ist, dass am Ende alles zusammengehört. Wir stimmen Arbeitsplätze, Küche, Stauraum, Farben, Materialien, Beleuchtung und Akustik zu einem durchgängigen Konzept aufeinander ab.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Wie die drei Marken zusammenspielen',
|
||||
'paragraphs' => [
|
||||
'Febrü gestaltet ergonomische Arbeitsplätze, Konferenzbereiche, Loungezonen, Akustik, Stauraum und flexible Raumstrukturen.',
|
||||
'Rotpunkt gestaltet Mitarbeiterküchen, Coffee-Points und gemeinsam genutzte Aufenthaltsküchen als selbstverständlichen Bestandteil der Arbeitswelt – und nicht als übrig gebliebene Funktionszeile. Gerade hier entstehen oft Gespräche, Verbindungen und Ideen, für die im offiziellen Meeting kein Platz war.',
|
||||
'CABINET übernimmt dort, wo Nischen vollständig genutzt, Schränke von Wand zu Wand und vom Boden bis zur Decke geplant oder besondere Übergänge zwischen Arbeitsplatz, Stauraum und Architektur geschaffen werden sollen. So entstehen beispielsweise maßgefertigte Garderoben, Aktenschränke, Medienlösungen und Stauraumelemente.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Febrü für die Arbeitswelt. Rotpunkt für die Küche. CABINET für die millimetergenaue Sonderlösung. B2in für das Ganze.',
|
||||
],
|
||||
[
|
||||
'title' => 'Ein Arbeitsplatz. Fünfzig Arbeitsplätze. Ein Anspruch.',
|
||||
'paragraphs' => [
|
||||
'Ob wir über einen einzelnen Arbeitsplatz oder einen vollständigen Bürotrakt sprechen: Das grundsätzliche Problem bleibt dasselbe. Fühlt sich der Mensch, der dort täglich arbeitet, gesehen und wertgeschätzt – oder wurde ihm lediglich irgendwo ein Tisch hingestellt?',
|
||||
'Die Stückzahl verändert den Umfang der Aufgabe, aber nicht ihre Bedeutung. Wir bewerten einen Arbeitsplatz deshalb nicht nach Quadratmetern oder Auftragswert, sondern danach, was er für den Menschen leisten muss.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Wertschätzung ist keine Frage der Stückzahl',
|
||||
'paragraphs' => [
|
||||
'Gerade in Zeiten des Fachkräftemangels können Unternehmen es sich nicht leisten, Arbeitsbedingungen als Nebensache zu behandeln. Wer Top-Leistung erwartet, muss die passenden Voraussetzungen schaffen. Ein Obstkorb und kostenloses Wasser sind freundlich – ersetzen aber keinen guten Arbeitsplatz.',
|
||||
'Besonders deutlich wird das bei Menschen, die Beruf und Familie miteinander organisieren. Teilzeit bedeutet nicht halbe Leistung. Oft wird in einem kürzeren Zeitfenster hoch konzentriert gearbeitet, während gleichzeitig Schule, Termine, Betreuung und der ganz normale Familienalltag organisiert werden müssen.',
|
||||
'Gerade hier darf der Arbeitsplatz kein Provisorium aus Küchentisch, Beistelltisch oder Schlafzimmerecke sein. Er muss sofort funktionieren, Bewegung ermöglichen, technisch durchdacht sein und sich trotzdem vernünftig in das Zuhause einfügen. Für den Menschen, der ihn täglich nutzt, ist ein einzelner Arbeitsplatz keine kleine Lösung. Er ist seine vollständige Arbeitswelt.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Ergonomie, die im Alltag funktioniert',
|
||||
'paragraphs' => [
|
||||
'Ein Arbeitsplatz muss sich dem Menschen anpassen – nicht umgekehrt. Höhenverstellbare Schreibtische und ergonomische Bürostühle schaffen Bewegung und unterstützen eine gesunde Haltung.',
|
||||
'Ergonomie endet aber nicht bei Tisch und Stuhl. Auch Laufwege, Greifräume, Bildschirmposition, Beleuchtung, Stauraum und der Wechsel zwischen Tätigkeiten bestimmen, wie angenehm und produktiv ein Arbeitstag verläuft.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Und nein: Wir meinen nicht nur den Weg zur Kaffeemaschine',
|
||||
'paragraphs' => [
|
||||
'Sinnvolle Laufwege sind Teil einer guten Planung – auch wenn Kaffeemaschine und Kühlschrank zweifellos eine gewisse Bedeutung für den Arbeitsalltag haben.',
|
||||
'Das gilt für vier Stunden ebenso wie für zehn Stunden am Schreibtisch, im Unternehmen genauso wie im privaten Zuhause. Und natürlich müssen die Möbel gestalterisch zum Raum passen. Wer denkt: „Das sieht doch keiner – außer den Mitarbeitern“, hat den ersten entscheidenden Fehler bereits gemacht. Die Mitarbeiter sehen es. Jeden Tag. Und sie arbeiten darin.',
|
||||
],
|
||||
],
|
||||
'quote' => 'Ich bin, sagen wir, alt genug, um beim Aufstehen gelegentlich zu stöhnen. Und ehrlich gesagt fühle ich mich an manchen Tagen noch etwas älter. Mein Arzt sagt, ich solle weniger sitzen. Mein Beruf findet nun einmal zu einem großen Teil am Computer statt. Ergo brauche ich eine Lösung, die mir mehr Bewegung ermöglicht, ohne dass ich dafür meinen Beruf wechseln muss.',
|
||||
'quote_author' => 'Marcel Scheibe, Geschäftsführer von B2in',
|
||||
],
|
||||
[
|
||||
'title' => 'Office meets Interior',
|
||||
'paragraphs' => [
|
||||
'Identische Schreibtischreihen werden einem modernen Arbeitstag nicht mehr gerecht. Menschen benötigen Bereiche für Konzentration, spontane Gespräche, Teamarbeit, Videokonferenzen, vertrauliche Besprechungen und erholsame Pausen.',
|
||||
'Febrü verbindet dafür klassische Arbeitsplatzmöbel mit wohnlichen Loungeelementen, mobilen Multifunktionslösungen, Stauraum, Akustik und flexibler Raumzonierung. So entsteht nicht für jede Person derselbe Platz, sondern für jede Tätigkeit der richtige.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Das Wir und das Ich',
|
||||
'paragraphs' => [
|
||||
'Ja, ja – das könnte man unter dem üblichen Verkaufs-Blabla abheften. Dahinter steht aber eine einfache Wahrheit: Verantwortung lässt sich nicht dauerhaft nach außen delegieren. Was bleibt, sind das Wir und das Ich.',
|
||||
'Ein Unternehmen muss Bedingungen schaffen, unter denen Menschen gemeinsam gut arbeiten, sich austauschen und konzentrieren können. Der Einzelne erhält die Möglichkeit – und trägt auch die Verantwortung –, daraus das Beste zu machen.',
|
||||
'Räume und Möbel schaffen allein noch keine gute Unternehmenskultur. Schlechte Arbeitsbedingungen können eine gute Kultur aber jeden Tag unnötig erschweren.',
|
||||
],
|
||||
],
|
||||
'claim' => 'Das Unternehmen stärkt das Wir. Der Einzelne macht daraus das Beste.',
|
||||
],
|
||||
[
|
||||
'title' => 'Ruhe und Flexibilität sind planbar',
|
||||
'paragraphs' => [
|
||||
'Akustik, mobile Elemente und flexible Raumlösungen helfen dabei, Bereiche für Kommunikation, Konzentration und Rückzug zu schaffen – und sie später an veränderte Teams und Arbeitsweisen anzupassen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Akustik und flexible Raumstrukturen',
|
||||
'paragraphs' => [
|
||||
'Hallende Räume, Telefongespräche und Videokonferenzen können Konzentration und Sprachverständlichkeit erheblich beeinträchtigen. Akustikpaneele, Trennwände, Screens und entsprechend geplante Möbel helfen, Schall zu reduzieren.',
|
||||
'Mit modularen Möbeln und Raum-in-Raum-Lösungen lassen sich Arbeitsbereiche neu ordnen, ohne bei jeder Veränderung gleich feste Wände bauen zu müssen. Das ist im großen Büro wichtig – und im privaten Zuhause häufig noch wichtiger.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Gestaltung, die zur Identität passt',
|
||||
'paragraphs' => [
|
||||
'Ein Büro ist ein sichtbarer Teil der Unternehmenskultur. Farben, Materialien und Raumwirkung beeinflussen, wie Mitarbeitende, Kunden und Bewerber ein Unternehmen wahrnehmen. Im Homeoffice muss der Arbeitsplatz professionell funktionieren und sich zugleich in die vorhandene Einrichtung einfügen.',
|
||||
'Dank der hohen Fertigungstiefe bietet Febrü eine große Auswahl an Materialien, Dekoren, Stoffen und Gestellfarben. Corporate Design muss nicht am Briefpapier enden. Und ein Homeoffice muss nach Feierabend nicht wie ein kleines Finanzamt aussehen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Und ja: Pflanzen gehören dazu',
|
||||
'paragraphs' => [
|
||||
'Eine Pflanze macht aus einem Unternehmen noch kein nachhaltiges Unternehmen. Sie macht aus einem technisch eingerichteten Raum aber häufig erst einen Ort, an dem Menschen gerne arbeiten. Das hat nichts mit Greenwashing zu tun, sondern schlicht mit Atmosphäre und Stil.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Nachhaltigkeit, die länger als bis zum nächsten Umzug hält',
|
||||
'paragraphs' => [
|
||||
'Nachhaltigkeit beginnt bei der Lebensdauer eines Möbelstücks. Viele Febrü-Möbel sind modular aufgebaut, sodass Komponenten ausgetauscht oder ergänzt werden können, statt bei veränderten Anforderungen das gesamte Möbelstück zu ersetzen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Konkrete Standards statt allgemeiner Versprechen',
|
||||
'paragraphs' => [
|
||||
'Febrü produziert mit hoher Fertigungstiefe direkt in Herford. Das sorgt für kurze Wege und kontrollierte Prozesse. Mit laserTEC entstehen hochwertige, fugenlose Kanten ohne zusätzlichen Klebstoff.',
|
||||
// ⚠️ Blauer Engel gilt laut Zulieferung nur für ausgewählte Tischsysteme und
|
||||
// Stauraummöbel. Produktbezug beim Redigieren zwingend beibehalten.
|
||||
'Febrü arbeitet nach ISO 9001 und ISO 14001 und verfügt über einen PEFC-Produktkettennachweis. Ausgewählte Febrü-Tischsysteme und Stauraummöbel tragen den Blauen Engel.',
|
||||
'Nachhaltig ist nicht das Möbelstück mit dem längsten Werbetext. Nachhaltig ist das Möbelstück, das viele Jahre funktioniert und veränderte Anforderungen mitmacht.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Warum Febrü bei uns?',
|
||||
'paragraphs' => [
|
||||
'Bei uns beginnt eine Planung nicht mit einem Möbelkatalog, sondern mit den Menschen und der Frage, wie tatsächlich gearbeitet wird. Wo ist Konzentration gefragt? Wo entstehen Gespräche? Welche Bereiche benötigen Diskretion? Und wie viel Stauraum wird wirklich gebraucht?',
|
||||
'Wir verbinden Ergonomie, Laufwege, Akustik, Stauraum, Beleuchtung, Kommunikation und Rückzug zu einem Gesamtkonzept. Es geht nicht darum, möglichst viele Möbel in einen Raum zu stellen. Es geht darum, eine bessere Arbeitswelt zu schaffen.',
|
||||
],
|
||||
'detail' => [
|
||||
'title' => 'Im Homeoffice stellen sich andere Fragen',
|
||||
'paragraphs' => [
|
||||
'Wie sichtbar darf der Arbeitsplatz sein? Wo verschwinden Technik und Unterlagen nach Feierabend? Und wie lässt sich professionelles Arbeiten so in die vorhandene Einrichtung integrieren, dass das Zuhause ein Zuhause bleibt?',
|
||||
'Auch hier gilt: Ein einzelner Arbeitsplatz ist keine kleinere Aufgabe. Er braucht nur eine andere Antwort als ein vollständiger Bürotrakt.',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'title' => 'Erleben statt nur aus dem Katalog auswählen',
|
||||
'paragraphs' => [
|
||||
'Durch die Nähe zwischen Bielefeld und Herford können Febrü-Produkte nach Abstimmung persönlich erlebt werden. Wir organisieren und begleiten den Besuch der Ausstellung – für einen Schreibtisch genauso wie für einen vollständigen Unternehmensstandort.',
|
||||
'Dort lassen sich Materialien anfassen, Stühle und Tische ausprobieren, Akustiklösungen erleben und unterschiedliche Arbeitswelten vergleichen. Sie müssen sich nicht allein auf ein Verkaufsgespräch verlassen.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -8,19 +8,6 @@ use Illuminate\Support\Facades\Lang;
|
|||
|
||||
class CmsArticleSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Numerische IDs aus resources/lang/{locale}/b2in.php → Slugs für cms_articles.slug
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const ARTICLE_ID_TO_SLUG = [
|
||||
1 => 'escrow-system-dubai-investoren',
|
||||
2 => 'spotlight-al-jaddaf-hotspot',
|
||||
3 => 'turnkey-investments-moeblierung-rendite',
|
||||
4 => 'supply-chain-management-vertragssicherheit',
|
||||
5 => 'local-for-local-regionaler-moebelhandel',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach ($this->getArticles() as $i => $data) {
|
||||
|
|
@ -49,19 +36,28 @@ class CmsArticleSeeder extends Seeder
|
|||
$articlesEn = [];
|
||||
}
|
||||
|
||||
ksort($articlesDe);
|
||||
|
||||
// Kein ksort: die Reihenfolge in der Lang-Datei bestimmt die Reihenfolge im Magazin –
|
||||
// im CMS ueber `order`, im Lang-Fallback ueber die Array-Reihenfolge. Beide Wege stimmen
|
||||
// damit ueberein.
|
||||
$out = [];
|
||||
|
||||
foreach ($articlesDe as $id => $rowDe) {
|
||||
$id = (int) $id;
|
||||
$slug = self::ARTICLE_ID_TO_SLUG[$id] ?? null;
|
||||
if (! is_array($rowDe)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slug = CmsArticle::slugForLangArticle($id, $rowDe);
|
||||
|
||||
if ($slug === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowEn = $articlesEn[$id] ?? $rowDe;
|
||||
|
||||
if (! is_array($rowEn)) {
|
||||
$rowEn = $rowDe;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'slug' => $slug,
|
||||
'title' => [
|
||||
|
|
@ -74,6 +70,7 @@ class CmsArticleSeeder extends Seeder
|
|||
],
|
||||
'image' => (string) ($rowDe['image'] ?? ''),
|
||||
'category' => (string) ($rowDe['category'] ?? ''),
|
||||
'world' => (string) ($rowDe['world'] ?? CmsArticle::WORLD_INTERIOR),
|
||||
'date_label' => (string) ($rowDe['date'] ?? ''),
|
||||
'read_time' => (string) ($rowDe['readTime'] ?? ''),
|
||||
'author' => is_array($rowDe['author'] ?? null) ? $rowDe['author'] : [],
|
||||
|
|
@ -81,7 +78,7 @@ class CmsArticleSeeder extends Seeder
|
|||
'de' => is_array($rowDe['content'] ?? null) ? $rowDe['content'] : [],
|
||||
'en' => is_array($rowEn['content'] ?? null) ? $rowEn['content'] : [],
|
||||
],
|
||||
'is_published' => true,
|
||||
'is_published' => ($rowDe['published'] ?? true) !== false,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
172
database/seeders/CmsBrandSeeder.php
Normal file
172
database/seeders/CmsBrandSeeder.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Legt die Kernmarken an. Texte aus dev/2026-08-28/umsetzung-inhalte.md §5.
|
||||
*
|
||||
* Die vierte Marke ist Otterpohl aus Rheda-Wiedenbrück (Arbeitsplan 14.09.2026, Punkt 1.2;
|
||||
* Zulieferung 17.09.2026). Ihre Texte liegen in {@see OtterpohlBrandSeeder}, der sie bei
|
||||
* bestehenden Installationen auch nachträgt. Der Slug „tischlerei“ bleibt stabil.
|
||||
*
|
||||
* Bestehende Marken werden nicht überschrieben – Marcel pflegt sie im Admin. Für bereits
|
||||
* angelegte Marken setzt {@see BrandLogoSeeder} die Logos nach und {@see BrandOriginSeeder}
|
||||
* den Fertigungsort für die Herkunfts-Sektion (Nachtrag 11).
|
||||
*
|
||||
* Stimmungsbilder und Galerien fehlen als Zulieferung und bleiben leer; die Kacheln und
|
||||
* Detailseiten zeigen so lange einen Platzhalter.
|
||||
*/
|
||||
class CmsBrandSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$created = 0;
|
||||
|
||||
foreach ($this->brands() as $index => $data) {
|
||||
if (CmsBrand::query()->where('slug', $data['slug'])->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CmsBrand::query()->create(array_merge($data, ['order' => $index + 1]));
|
||||
$created++;
|
||||
}
|
||||
|
||||
$this->command?->info("Kernmarken: {$created} angelegt.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function brands(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'slug' => 'cabinet',
|
||||
'logo' => 'marken/cabinet.svg',
|
||||
'name' => ['de' => 'CABINET', 'en' => 'CABINET'],
|
||||
'tagline' => ['de' => 'Einbauschränke nach Maß.', 'en' => 'Fitted wardrobes made to measure.'],
|
||||
'teaser' => [
|
||||
'de' => 'Maßgefertigte Schranklösungen, geplant bis auf den Millimeter – gefertigt für Räume, in denen Standard nicht genügt.',
|
||||
'en' => 'Bespoke wardrobe solutions, planned to the millimetre – built for rooms where standard will not do.',
|
||||
],
|
||||
'summary' => [
|
||||
'de' => 'Wo Standardmaße an ihre Grenzen stoßen, fängt CABINET an. Schranklösungen, die exakt in Ihren Raum geplant werden – von der Dachschräge über die Nische bis zur raumhohen Front. Gefertigt in der Qualität, die man erst beim zweiten Hinschauen ganz erfasst.
|
||||
|
||||
Erleben Sie die Möglichkeiten in unserem Showroom in Bielefeld.',
|
||||
'en' => 'Where standard dimensions reach their limits, CABINET begins. Wardrobe solutions planned precisely for your room – from sloped ceilings and alcoves to full-height fronts. Built to a quality you only fully grasp on second look.
|
||||
|
||||
Experience the possibilities in our Bielefeld showroom.',
|
||||
],
|
||||
'highlights' => [
|
||||
'Millimetergenaue Planung',
|
||||
'Individuelle Fronten und Innenausstattung',
|
||||
'Persönliche Beratung vor Ort',
|
||||
],
|
||||
'description' => [
|
||||
'de' => '<p>CABINET steht für Einbaulösungen, die sich dem Raum anpassen statt umgekehrt. Jedes Element wird für seinen Platz geplant – vom Dachschrägenschrank bis zur raumhohen Garderobe. Gefertigt in Deutschland, montiert von Fachleuten, die den Unterschied zwischen „passt" und „sitzt" kennen.</p>',
|
||||
'en' => '<p>CABINET stands for built-in solutions that adapt to the room rather than the other way round. Every element is planned for its place – from sloped-ceiling wardrobes to full-height hallway storage. Made in Germany, installed by specialists who know the difference between fitting and belonging.</p>',
|
||||
],
|
||||
'services' => [
|
||||
['title' => 'Aufmaß vor Ort', 'description' => 'Nach Angebotsannahme und Anzahlung erfolgt das professionelle Aufmaß bei Ihnen vor Ort. Es bildet die Grundlage für die finale Feinplanung und Fertigungsfreigabe.'],
|
||||
['title' => 'Individuelle Planung', 'description' => 'Grundriss, Nutzung und Stil bestimmen die Lösung, nicht der Katalog.'],
|
||||
['title' => 'Fertigung in Deutschland', 'description' => 'Materialien und Verarbeitung, die auf Dauer angelegt sind.'],
|
||||
['title' => 'Montage durch Fachleute', 'description' => 'Wir liefern und montieren – bis zum letzten Detail.'],
|
||||
],
|
||||
'showroom' => 'Showroom Bielefeld',
|
||||
'origin_city' => ['de' => 'Köln', 'en' => 'Cologne'],
|
||||
'origin_distance' => ['de' => 'rund 200 km', 'en' => 'about 200 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
|
||||
'en' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
|
||||
],
|
||||
'is_published' => true,
|
||||
],
|
||||
[
|
||||
'slug' => 'rotpunkt',
|
||||
'logo' => 'marken/rotpunkt.svg',
|
||||
'name' => ['de' => 'Rotpunkt Küchen', 'en' => 'Rotpunkt Küchen'],
|
||||
'tagline' => ['de' => 'Küchen, die Haltung zeigen.', 'en' => 'Kitchens with character.'],
|
||||
'teaser' => [
|
||||
'de' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant, nachhaltig gedacht und gebaut für den Mittelpunkt Ihres Zuhauses.',
|
||||
'en' => 'Premium kitchens made in Germany – individually planned, sustainably conceived and built for the heart of your home.',
|
||||
],
|
||||
'summary' => [
|
||||
'de' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant und gebaut für den Raum, in dem am meisten passiert. Rotpunkt steht für durchdachte Funktion, langlebige Materialien und einen Anspruch an Nachhaltigkeit, der über das Marketing hinausgeht.
|
||||
|
||||
Beratung und Planung in unserem Küchenstudio.',
|
||||
'en' => 'Premium kitchens made in Germany – individually planned and built for the room where most of life happens. Rotpunkt stands for considered function, durable materials and a commitment to sustainability that goes beyond marketing.
|
||||
|
||||
Consultation and planning in our kitchen studio.',
|
||||
],
|
||||
'highlights' => [
|
||||
'Fertigung in Deutschland',
|
||||
'Individuelle Planung nach Ihrem Grundriss',
|
||||
'Nachhaltige Materialien',
|
||||
],
|
||||
'description' => [
|
||||
'de' => '<p>Die Küche ist der Raum, in dem sich das Leben sammelt. Rotpunkt fertigt sie in Deutschland, mit Blick auf Materialien, die halten, und auf eine Planung, die zum Alltag passt – nicht zum Prospekt. Wir begleiten von der ersten Skizze bis zur fertigen Küche.</p>',
|
||||
'en' => '<p>The kitchen is where life gathers. Rotpunkt builds them in Germany, with an eye on materials that last and on planning that fits everyday life – not a brochure. We accompany you from the first sketch to the finished kitchen.</p>',
|
||||
],
|
||||
'services' => [
|
||||
['title' => 'Küchenplanung', 'description' => 'Gemeinsam entwickeln wir Grundriss, Stauraum und Arbeitswege.'],
|
||||
['title' => 'Nachhaltige Materialien', 'description' => 'Deutsche Fertigung mit Blick auf Herkunft und Langlebigkeit.'],
|
||||
['title' => 'Geräte und Technik', 'description' => 'Passende Ausstattung, abgestimmt auf Ihre Art zu kochen.'],
|
||||
['title' => 'Lieferung und Aufbau', 'description' => 'Koordiniert aus einer Hand, inklusive Anschluss.'],
|
||||
],
|
||||
'showroom' => 'Küchenstudio',
|
||||
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
|
||||
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
|
||||
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
|
||||
],
|
||||
'is_published' => true,
|
||||
],
|
||||
[
|
||||
'slug' => 'februe',
|
||||
'logo' => 'marken/februe.png',
|
||||
'name' => ['de' => 'Febrü', 'en' => 'Febrü'],
|
||||
'tagline' => ['de' => 'Arbeiten, wo man gerne bleibt.', 'en' => 'Work where you like to stay.'],
|
||||
'teaser' => [
|
||||
'de' => 'Möbel für konzentriertes Arbeiten – vom durchdachten Homeoffice bis zur kompletten Büroeinrichtung.',
|
||||
'en' => 'Furniture for focused work – from a well-considered home office to a complete office fit-out.',
|
||||
],
|
||||
'summary' => [
|
||||
'de' => 'Ob Homeoffice oder ganze Büroetage: Febrü macht Möbel für konzentriertes Arbeiten. Höhenverstellbare Tische, durchdachte Stauraumlösungen und Sitzmöbel, die auch nach acht Stunden noch überzeugen – mit einem Design, das ins Zuhause genauso passt wie ins Unternehmen.',
|
||||
'en' => 'Whether a home office or a whole office floor: Febrü makes furniture for focused work. Height-adjustable desks, well-considered storage and seating that still convinces after eight hours – with a design that suits the home as much as the company.',
|
||||
],
|
||||
'highlights' => [
|
||||
'Ergonomie, die man merkt',
|
||||
'Vom einzelnen Arbeitsplatz bis zur Bürofläche',
|
||||
'Für Zuhause und fürs Unternehmen',
|
||||
],
|
||||
'description' => [
|
||||
'de' => '<p>Gutes Arbeiten beginnt beim Möbel. Febrü baut Tische, Stühle und Stauraum, die den Rücken entlasten und den Raum ordnen – zuerst für das Zuhause, in dem immer öfter gearbeitet wird, und ebenso für Büros und Unternehmen.</p>',
|
||||
'en' => '<p>Good work begins with the furniture. Febrü builds desks, chairs and storage that relieve the back and bring order to a room – first for the home, where more and more work happens, and equally for offices and companies.</p>',
|
||||
],
|
||||
'services' => [
|
||||
['title' => 'Homeoffice', 'description' => 'Ein Arbeitsplatz, der sich ins Zuhause fügt, statt es zu besetzen.'],
|
||||
['title' => 'Ergonomie', 'description' => 'Höhenverstellbare Tische und Sitzlösungen für langes Arbeiten.'],
|
||||
['title' => 'Büroeinrichtung', 'description' => 'Vom einzelnen Arbeitsplatz bis zur kompletten Fläche.'],
|
||||
['title' => 'Akustik und Ordnung', 'description' => 'Stauraum und Raumgliederung, die Konzentration ermöglichen.'],
|
||||
],
|
||||
'showroom' => '',
|
||||
'origin_city' => ['de' => 'Herford', 'en' => 'Herford'],
|
||||
'origin_distance' => ['de' => 'rund 20 km', 'en' => 'about 20 km'],
|
||||
'origin_role' => [
|
||||
'de' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
|
||||
'en' => 'Office and contract furniture from their own production in Herford.',
|
||||
],
|
||||
'is_published' => true,
|
||||
],
|
||||
// Vierte Kernmarke Otterpohl (Nachtrag 15). Slug bleibt „tischlerei“, Texte aus OtterpohlBrandSeeder.
|
||||
array_merge(OtterpohlBrandSeeder::data(), [
|
||||
'slug' => OtterpohlBrandSeeder::SLUG,
|
||||
'logo' => OtterpohlBrandSeeder::LOGO,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,33 +3,39 @@
|
|||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
class CmsContentSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Sections to skip (not migrated, handled elsewhere or irrelevant).
|
||||
* Überschreibt bestehende CMS-Einträge mit den Werten aus den Lang-Dateien.
|
||||
*
|
||||
* netzwerk_cabinet_partner / netzwerk_immobilien_hint: siehe NetzwerkPageCmsSeeder
|
||||
* (nur einfügen wenn leer, damit manuelle CMS-Anpassungen nicht überschrieben werden).
|
||||
* Standardmäßig aus, damit ein erneuter Seeder-Lauf die im Admin gepflegten Inhalte
|
||||
* nicht zurücksetzt. Nur einschalten, wenn die Lang-Dateien bewusst als Quelle der
|
||||
* Wahrheit gelten sollen – vorher ein DB-Backup ziehen:
|
||||
*
|
||||
* @var array<string>
|
||||
* $seeder = new CmsContentSeeder;
|
||||
* $seeder->overwriteExisting = true;
|
||||
* $seeder->run();
|
||||
*/
|
||||
protected array $skipSections = [
|
||||
'netzwerk_cabinet_partner',
|
||||
'netzwerk_immobilien_hint',
|
||||
];
|
||||
public bool $overwriteExisting = false;
|
||||
|
||||
/**
|
||||
* Nur diese Section-Keys verarbeiten (leer = alle).
|
||||
*
|
||||
* Erlaubt einen gezielten Lauf für einzelne Sektionen, ohne den Rest anzufassen.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public array $onlySections = [];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
/** @var array<string, array{0: string, 1: string}> $sectionMap */
|
||||
$sectionMap = config('cms_section_map.sections', []);
|
||||
/** @var array<string, string> $langKeys */
|
||||
$langKeys = config('cms_section_map.lang_keys', []);
|
||||
|
||||
/** @var array<string, mixed> $configTheme */
|
||||
$configTheme = config('content.themes.b2in', []);
|
||||
/** @var array<string, mixed> $langDe */
|
||||
$langDe = Lang::get('b2in.themes.b2in', [], 'de');
|
||||
/** @var array<string, mixed> $langEn */
|
||||
|
|
@ -45,29 +51,23 @@ class CmsContentSeeder extends Seeder
|
|||
$langEn = [];
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $themeDe */
|
||||
$themeDe = array_merge($configTheme, $langDe);
|
||||
/** @var array<string, mixed> $themeEn */
|
||||
$themeEn = array_merge($configTheme, $langEn);
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($sectionMap as $sectionKey => $groupKeyPair) {
|
||||
if (in_array($sectionKey, $this->skipSections, true)) {
|
||||
if ($this->onlySections !== [] && ! in_array($sectionKey, $this->onlySections, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$langSectionKey = $langKeys[$sectionKey] ?? $sectionKey;
|
||||
|
||||
if (! array_key_exists($langSectionKey, $themeDe)) {
|
||||
$this->command?->warn("Unmapped or missing theme data for section: {$sectionKey} (lang key: {$langSectionKey}) – skipping.");
|
||||
if (! array_key_exists($sectionKey, $langDe)) {
|
||||
$this->command?->warn("Unmapped or missing theme data for section: {$sectionKey} – skipping.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sectionDataDe = $themeDe[$langSectionKey];
|
||||
$sectionDataEn = $themeEn[$langSectionKey] ?? $sectionDataDe;
|
||||
$sectionDataDe = $langDe[$sectionKey];
|
||||
$sectionDataEn = $langEn[$sectionKey] ?? $sectionDataDe;
|
||||
|
||||
[$group, $key] = $groupKeyPair;
|
||||
$type = $this->detectType($sectionDataDe);
|
||||
|
|
@ -83,6 +83,12 @@ class CmsContentSeeder extends Seeder
|
|||
];
|
||||
|
||||
if ($content) {
|
||||
if (! $this->overwriteExisting) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$content->update([
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
|
|
@ -100,7 +106,15 @@ class CmsContentSeeder extends Seeder
|
|||
}
|
||||
}
|
||||
|
||||
$this->command?->info("CmsContent: {$created} created, {$updated} updated.");
|
||||
$this->command?->info("CmsContent: {$created} created, {$updated} updated, {$skipped} kept (existing CMS content).");
|
||||
|
||||
if ($skipped > 0 && ! $this->overwriteExisting) {
|
||||
$this->command?->comment('Bestehende CMS-Inhalte wurden nicht angetastet. Zum Überschreiben $overwriteExisting = true setzen.');
|
||||
}
|
||||
|
||||
if ($created > 0 || $updated > 0) {
|
||||
app(CmsContentService::class)->clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
private function detectType(mixed $data): string
|
||||
|
|
|
|||
|
|
@ -5,15 +5,168 @@ namespace Database\Seeders;
|
|||
use App\Models\CmsProject;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Lang;
|
||||
|
||||
class CmsProjectSeeder extends Seeder
|
||||
{
|
||||
private const PROJECT_SLUG = 'azizi-creek-views-4';
|
||||
|
||||
/**
|
||||
* Seed the CMS projects table from resources/lang/{locale}/b2in.php
|
||||
* (themes.b2in.immobilien_projects.projects.{slug}).
|
||||
* Projektdaten Azizi Creek Views 4 – bis 06.10.2026 als Sektion themes.b2in.immobilien_projects
|
||||
* in den Lang-Dateien, die keine Seite mehr anzeigt (Archiv: dev/2026-08-28/archiv-2026-10-06/).
|
||||
*
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
private const SOURCE = [
|
||||
'de' => [
|
||||
'slug' => 'azizi-creek-views-4',
|
||||
'title' => 'Azizi Developments: Creek Views 4',
|
||||
'location' => 'Al Jaddaf, Dubai',
|
||||
'status' => 'NEW LAUNCH',
|
||||
'launch_date' => '03.03.2026',
|
||||
'price_from' => '<span class="whitespace-nowrap">ab 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
|
||||
'image' => 'expose/a1/image-4.jpeg',
|
||||
'highlights' => [
|
||||
'Prime Waterfront Views',
|
||||
'1BR: <span class="whitespace-nowrap">ab 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
|
||||
'Exklusives 3BR Penthouse (Single Inventory)',
|
||||
'High Rental Demand & Capital Appreciation',
|
||||
],
|
||||
'quick_facts' => [
|
||||
[
|
||||
'icon' => 'home-modern',
|
||||
'label' => 'Typen',
|
||||
'value' => '1BR & 3BR Penthouse',
|
||||
],
|
||||
[
|
||||
'icon' => 'squares-2x2',
|
||||
'label' => 'Größe',
|
||||
'value' => '543 – 2.346 sqft',
|
||||
],
|
||||
[
|
||||
'icon' => 'building-office-2',
|
||||
'label' => 'Einheiten',
|
||||
'value' => 'Nur 132 (Limited)',
|
||||
],
|
||||
[
|
||||
'icon' => 'user',
|
||||
'label' => 'Entwickler',
|
||||
'value' => 'Azizi Developments',
|
||||
],
|
||||
],
|
||||
'investment_case' => [
|
||||
'title' => 'Starkes Investment, hohe Nachfrage.',
|
||||
'text' => 'Creek Views 4 bietet eine strategische Top-Lage in Al Jaddaf. Die Kombination aus limitiertem Angebot (nur 132 Einheiten) und Premium-Ausstattung macht dieses Projekt zur idealen Wahl für Investoren, die auf Wertsteigerung (Capital Appreciation) und hohe Mietnachfrage abzielen.',
|
||||
'views' => [
|
||||
'Road View',
|
||||
'Sitting & Play Area View',
|
||||
'Neighbour View',
|
||||
],
|
||||
],
|
||||
'gallery' => [
|
||||
'expose/a1/image-4.jpeg',
|
||||
'expose/a1/image-3.jpeg',
|
||||
'expose/a1/image-2.jpeg',
|
||||
'expose/a1/image-5.jpeg',
|
||||
'expose/a1/image-6.jpeg',
|
||||
'expose/a1/image-7.jpeg',
|
||||
],
|
||||
'location_info' => [
|
||||
'title' => 'Strategische Location: Al Jaddaf',
|
||||
'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai',
|
||||
'points' => [
|
||||
'Direkte Anbindung an Dubai Creek und Waterfront',
|
||||
'Wenige Minuten bis Downtown Dubai & Burj Khalifa',
|
||||
'Exzellente Infrastruktur und wachsender Stadtteil',
|
||||
],
|
||||
],
|
||||
'contact' => [
|
||||
'title' => 'Sichern Sie sich eine der 132 Einheiten.',
|
||||
'subtitle' => 'Ihr Ansprechpartner: Marcel Scheibe',
|
||||
'options' => [
|
||||
'' => 'Ich interessiere mich für...',
|
||||
'1br' => '1 Bedroom Apartment',
|
||||
'3br_penthouse' => '3BR Penthouse (Single Unit)',
|
||||
'general' => 'Allgemeine Beratung',
|
||||
],
|
||||
],
|
||||
],
|
||||
'en' => [
|
||||
'slug' => 'azizi-creek-views-4',
|
||||
'title' => 'Azizi Developments: Creek Views 4',
|
||||
'location' => 'Al Jaddaf, Dubai',
|
||||
'status' => 'NEW LAUNCH',
|
||||
'launch_date' => '03.03.2026',
|
||||
'price_from' => '<span class="whitespace-nowrap">from 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
|
||||
'image' => 'expose/a1/image-4.jpeg',
|
||||
'highlights' => [
|
||||
'Prime Waterfront Views',
|
||||
'1BR: <span class="whitespace-nowrap">from 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
|
||||
'Exclusive 3BR Penthouse (Single Inventory)',
|
||||
'High Rental Demand & Capital Appreciation',
|
||||
],
|
||||
'quick_facts' => [
|
||||
[
|
||||
'icon' => 'home-modern',
|
||||
'label' => 'Types',
|
||||
'value' => '1BR & 3BR Penthouse',
|
||||
],
|
||||
[
|
||||
'icon' => 'squares-2x2',
|
||||
'label' => 'Size',
|
||||
'value' => '543 – 2,346 sqft',
|
||||
],
|
||||
[
|
||||
'icon' => 'building-office-2',
|
||||
'label' => 'Units',
|
||||
'value' => 'Only 132 (Limited)',
|
||||
],
|
||||
[
|
||||
'icon' => 'user',
|
||||
'label' => 'Developer',
|
||||
'value' => 'Azizi Developments',
|
||||
],
|
||||
],
|
||||
'investment_case' => [
|
||||
'title' => 'Strong Investment, High Demand.',
|
||||
'text' => 'Creek Views 4 offers a strategic top location in Al Jaddaf. The combination of limited supply (only 132 units) and premium finishes makes this project the ideal choice for investors targeting capital appreciation and high rental demand.',
|
||||
'views' => [
|
||||
'Road View',
|
||||
'Sitting & Play Area View',
|
||||
'Neighbour View',
|
||||
],
|
||||
],
|
||||
'gallery' => [
|
||||
'expose/a1/image-4.jpeg',
|
||||
'expose/a1/image-3.jpeg',
|
||||
'expose/a1/image-2.jpeg',
|
||||
'expose/a1/image-5.jpeg',
|
||||
'expose/a1/image-6.jpeg',
|
||||
'expose/a1/image-7.jpeg',
|
||||
],
|
||||
'location_info' => [
|
||||
'title' => 'Strategic Location: Al Jaddaf',
|
||||
'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai',
|
||||
'points' => [
|
||||
'Direct connection to Dubai Creek and Waterfront',
|
||||
'Just minutes to Downtown Dubai & Burj Khalifa',
|
||||
'Excellent infrastructure and growing district',
|
||||
],
|
||||
],
|
||||
'contact' => [
|
||||
'title' => 'Secure one of the 132 units.',
|
||||
'subtitle' => 'Your contact: Marcel Scheibe',
|
||||
'options' => [
|
||||
'' => 'I am interested in...',
|
||||
'1br' => '1 Bedroom Apartment',
|
||||
'3br_penthouse' => '3BR Penthouse (Single Unit)',
|
||||
'general' => 'General Consultation',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Seed the CMS projects table from {@see self::SOURCE}.
|
||||
*
|
||||
* Zusätzliche Blöcke investor_trust / furniture_benefit sind in den Lang-Dateien
|
||||
* nicht enthalten und werden hier mit DE/EN ergänzt.
|
||||
|
|
@ -33,20 +186,8 @@ class CmsProjectSeeder extends Seeder
|
|||
*/
|
||||
private function getProjects(): array
|
||||
{
|
||||
$key = 'b2in.themes.b2in.immobilien_projects.projects.'.self::PROJECT_SLUG;
|
||||
|
||||
/** @var array<string, mixed> $de */
|
||||
$de = Lang::get($key, [], 'de');
|
||||
/** @var array<string, mixed> $en */
|
||||
$en = Lang::get($key, [], 'en');
|
||||
|
||||
if ($de === [] || ! is_array($de)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! is_array($en)) {
|
||||
$en = [];
|
||||
}
|
||||
$de = self::SOURCE['de'];
|
||||
$en = self::SOURCE['en'];
|
||||
|
||||
$launchDate = isset($de['launch_date']) && is_string($de['launch_date'])
|
||||
? Carbon::createFromFormat('d.m.Y', $de['launch_date'])
|
||||
|
|
|
|||
72
database/seeders/CmsSectionFieldsSeeder.php
Normal file
72
database/seeders/CmsSectionFieldsSeeder.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Macht jedes Feld, das die Website kennt, im CMS bearbeitbar (07.10.2026).
|
||||
*
|
||||
* Ein CMS-Eintrag zeigt im Editor nur die Felder, die in ihm stehen. Kam ein Feld später in die
|
||||
* Lang-Datei (etwa „Zur Marke“ in „Aus der Region gefertigt“), fehlt es in älteren Einträgen und
|
||||
* die Website nimmt still den Standardtext. Der Seeder ergänzt solche Felder der obersten Ebene
|
||||
* aus der Lang-Datei – vorhandene Werte bleiben unangetastet, Felder aus
|
||||
* {@see UnusedCmsContentCleanupSeeder::UNUSED_FIELDS} kommen nicht zurück.
|
||||
* Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=CmsSectionFieldsSeeder
|
||||
*/
|
||||
class CmsSectionFieldsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
/** @var array<string, array{0: string, 1: string}> $sections */
|
||||
$sections = config('cms_section_map.sections', []);
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$added = [];
|
||||
|
||||
foreach ($sections as $sectionKey => [$group, $key]) {
|
||||
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
|
||||
|
||||
if (! $row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$skip = UnusedCmsContentCleanupSeeder::UNUSED_FIELDS["{$group}.{$key}"] ?? [];
|
||||
$value = $row->getTranslations('value');
|
||||
$fields = [];
|
||||
|
||||
foreach ($value as $locale => $content) {
|
||||
$defaults = trans("b2in.themes.{$theme}.{$sectionKey}", [], $locale);
|
||||
|
||||
if (! is_array($content) || ! is_array($defaults)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($defaults as $field => $default) {
|
||||
if (! array_key_exists($field, $content) && ! in_array($field, $skip, true)) {
|
||||
$content[$field] = $default;
|
||||
$fields[$field] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$value[$locale] = $content;
|
||||
}
|
||||
|
||||
if ($fields === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache($group);
|
||||
$added[] = "{$group}.{$key} (".implode(', ', array_keys($fields)).')';
|
||||
}
|
||||
|
||||
$this->command?->info($added === []
|
||||
? 'CMS-Felder: alle Einträge vollständig.'
|
||||
: 'CMS-Felder ergänzt: '.implode('; ', $added).'.');
|
||||
}
|
||||
}
|
||||
125
database/seeders/Concerns/ReplacesCmsTexts.php
Normal file
125
database/seeders/Concerns/ReplacesCmsTexts.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders\Concerns;
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use App\Models\CmsBrand;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Textersetzungen über alle gepflegten Inhalte: CMS-Sektionen (alle Sprachen), Marken und
|
||||
* Magazinbeiträge. Ersetzt wird per strtr() – idempotent, solange der neue Text den alten nicht
|
||||
* enthält. `$exact` ersetzt nur Werte, die vollständig übereinstimmen.
|
||||
*/
|
||||
trait ReplacesCmsTexts
|
||||
{
|
||||
/** @var list<string> */
|
||||
private array $brandFields = [
|
||||
'name', 'tagline', 'teaser', 'summary', 'description', 'widget_label', 'cta_title', 'cta_text',
|
||||
'origin_city', 'origin_distance', 'origin_role', 'highlights', 'services', 'sections', 'gallery',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private array $articleFields = ['title', 'subtitle', 'content'];
|
||||
|
||||
/**
|
||||
* @param array<string, string> $replacements
|
||||
* @param array<string, string> $exact
|
||||
* @return int Anzahl geänderter Textstellen
|
||||
*/
|
||||
protected function replaceCmsTexts(array $replacements, array $exact = []): int
|
||||
{
|
||||
$count = 0;
|
||||
$groups = [];
|
||||
|
||||
foreach (CmsContent::query()->get() as $row) {
|
||||
$changes = 0;
|
||||
$row->value = $this->replaceDeep($row->getTranslations('value'), $replacements, $exact, $changes);
|
||||
|
||||
if ($changes > 0) {
|
||||
$row->save();
|
||||
$groups[$row->group] = true;
|
||||
$count += $changes;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($groups) as $group) {
|
||||
app(CmsContentService::class)->clearCache($group);
|
||||
}
|
||||
|
||||
foreach (CmsBrand::query()->get() as $brand) {
|
||||
$count += $this->replaceInModel($brand, $this->brandFields, $replacements, $exact);
|
||||
}
|
||||
|
||||
foreach (CmsArticle::query()->get() as $article) {
|
||||
$count += $this->replaceInModel($article, $this->articleFields, $replacements, $exact);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $fields
|
||||
* @param array<string, string> $replacements
|
||||
* @param array<string, string> $exact
|
||||
*/
|
||||
private function replaceInModel(Model $model, array $fields, array $replacements, array $exact): int
|
||||
{
|
||||
$changes = 0;
|
||||
$translatable = property_exists($model, 'translatable') ? $model->translatable : [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$isTranslatable = in_array($field, $translatable, true);
|
||||
$value = $isTranslatable ? $model->getTranslations($field) : $model->getAttribute($field);
|
||||
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$before = $changes;
|
||||
$value = $this->replaceDeep($value, $replacements, $exact, $changes);
|
||||
|
||||
if ($changes > $before) {
|
||||
$isTranslatable ? $model->setTranslations($field, $value) : $model->setAttribute($field, $value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
$model->save();
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
* @param array<string, string> $replacements
|
||||
* @param array<string, string> $exact
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function replaceDeep(array $data, array $replacements, array $exact, int &$count): array
|
||||
{
|
||||
foreach ($data as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$data[$key] = $this->replaceDeep($item, $replacements, $exact, $count);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$replaced = $exact[$item] ?? strtr($item, $replacements);
|
||||
|
||||
if ($replaced !== $item) {
|
||||
$data[$key] = $replaced;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
287
database/seeders/ContentCorrections20261008Seeder.php
Normal file
287
database/seeders/ContentCorrections20261008Seeder.php
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsArticle;
|
||||
use Database\Seeders\Concerns\ReplacesCmsTexts;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Korrekturen vom 08.10.2026 (dev/2026-08-28/Korrekturen-2026-10-08.md):
|
||||
*
|
||||
* - Hero-Slideshow: zwei Febrü-Motive an Position 4 und 9.
|
||||
* - „Regional verbunden“: die tatsächlich unterstützten Vereine (Arminia, TC Bad Salzuflen,
|
||||
* TC Hiddenhausen, TC Blau Weiss Halle) mit einheitlichen Kurztexten.
|
||||
* - Ablauf der Maßanfertigung überall gleich: grobe Maße für Beratung, Planung und Angebot –
|
||||
* das professionelle Aufmaß erst nach Angebotsannahme und Anzahlung (CABINET, Herkunft,
|
||||
* Netzwerk, Über uns, FAQ).
|
||||
* - Magazin: Maßanfertigungs-Beitrag neu (aus der Lang-Datei), allgemeiner Abschluss statt
|
||||
* Immobilien-Hinweis, „Supply-Chain“ und „Local for Local“ offline, Datum und Lesezeit deutsch.
|
||||
* - FAQ Projektausstattung ohne Garantie-/Eskalationssprache, Otterpohl „erhöht die
|
||||
* Planungssicherheit deutlich“.
|
||||
*
|
||||
* Die neuen Texte stehen auch in den Lang-Dateien und Quell-Seedern (frische Installation).
|
||||
* Ersetzt wird nur der bisherige Wortlaut – im CMS selbst Umformuliertes bleibt.
|
||||
* Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=ContentCorrections20261008Seeder
|
||||
*/
|
||||
class ContentCorrections20261008Seeder extends Seeder
|
||||
{
|
||||
use ReplacesCmsTexts;
|
||||
|
||||
/** @var array<string, string> Teiltexte, die ersetzt werden (Deutsch und Englisch) */
|
||||
public const REPLACEMENTS = [
|
||||
// CABINET: „Ein Bild und ein grobes Maß reichen für den Anfang“
|
||||
'So läuft’s: Foto + grobe Maße → Beratung → Planung & Angebot → Aufmaß vor Ort → Feinplanung → Bestellung' => 'So läuft’s: Foto / Grundriss + grobe Maße → Beratung → Planung & Angebot → Angebotsannahme → Anzahlung → professionelles Aufmaß → Feinplanung / Fertigungsfreigabe → Bestellung / Fertigung',
|
||||
'Für den Anfang reichen uns ein Bild und ein grobes Maß völlig aus.' => 'Am Anfang reicht das grobe Maß. Am Ende entscheidet der Millimeter.',
|
||||
'Sie müssen also nicht erst alles millimetergenau ausmessen. Ein paar Fotos von der Raumsituation und die ungefähren Maße reichen, damit wir schon eine gute Planung machen und Ihnen ein konkretes Angebot erstellen können.' => 'Sie müssen also nicht erst alles millimetergenau ausmessen, bevor Sie zu uns kommen. Ein paar Fotos, ein Grundriss und die ungefähren Maße reichen, damit wir Sie beraten, planen und Ihnen ein konkretes Angebot erstellen können.',
|
||||
'Wenn alles grundsätzlich passt, kommen wir zum professionellen Aufmaß bei Ihnen vor Ort. Danach machen wir die Feinplanung fertig und geben den Schrank zur Bestellung frei.' => 'Wenn Sie das Angebot annehmen und die Anzahlung erfolgt ist, übernehmen wir das professionelle Aufmaß bei Ihnen vor Ort. Ab jetzt zählt jeder Millimeter: Auf Grundlage dieser Maße machen wir die Feinplanung fertig, geben die Fertigung frei und bestellen Ihren Schrank.',
|
||||
'Wir messen millimetergenau in Ihren Räumen – Grundlage jeder Planung.' => 'Nach Angebotsannahme und Anzahlung erfolgt das professionelle Aufmaß bei Ihnen vor Ort. Es bildet die Grundlage für die finale Feinplanung und Fertigungsfreigabe.',
|
||||
|
||||
// „Aus der Region gefertigt“ – Ablauf am Beispiel CABINET
|
||||
'Beraten, aufgemessen und geplant wird bei uns im CABINET Store Bielefeld.' => 'Beraten und geplant wird bei uns im CABINET Store Bielefeld, aufgemessen nach Ihrer Zusage bei Ihnen vor Ort.',
|
||||
'Im CABINET Store nehmen wir Maß, planen bis ins Detail und stimmen jede Front und jede Innenaufteilung mit Ihnen ab.' => 'Im CABINET Store planen wir mit Ihnen bis ins Detail und stimmen jede Front und jede Innenaufteilung ab – für den Anfang reichen Fotos und grobe Maße. Nach Ihrer Zusage nehmen wir vor Ort exakt Maß.',
|
||||
'CABINET fertigt Ihren Schrank in der eigenen Manufaktur – nach den Maßen aus der Planung.' => 'CABINET fertigt Ihren Schrank in der eigenen Manufaktur – nach den exakten Maßen aus dem Aufmaß vor Ort.',
|
||||
'Consulting, measuring and planning happen with us at the CABINET Store Bielefeld. Assembly takes place on site – with our joinery,' => 'Consulting and planning happen with us at the CABINET Store Bielefeld; the exact measurements are taken at your home once you give the go-ahead. Assembly takes place on site – with the Otterpohl joinery,',
|
||||
'At the CABINET Store we take measurements, plan down to the last detail and agree every front and interior layout with you.' => 'At the CABINET Store we plan with you down to the last detail and agree every front and interior layout – photos and rough measurements are enough to start. Once you give the go-ahead, we take exact measurements on site.',
|
||||
'CABINET builds your wardrobe in its own manufactory – to the measurements from the planning.' => 'CABINET builds your wardrobe in its own manufactory – to the exact measurements taken on site.',
|
||||
|
||||
// Marken: „Mehr als Marken: unser Netzwerk“
|
||||
'Wir kommen zu Ihnen, nehmen Maß und planen mit Ihnen gemeinsam.' => 'Wir planen mit Ihnen gemeinsam – zunächst nach Fotos und groben Maßen. Das exakte Aufmaß vor Ort übernehmen wir nach Ihrer Zusage.',
|
||||
'We come to you, measure up and plan together with you.' => 'We plan together with you – first from photos and rough measurements. We take the exact measurements on site once you give the go-ahead.',
|
||||
|
||||
// Über uns: Zitat im Seitenkopf
|
||||
'ein Ansprechpartner, vom ersten Aufmaß bis nach der Montage.' => 'ein Ansprechpartner, vom ersten Gespräch bis nach der Montage.',
|
||||
'one point of contact, from the first measurement to after installation.' => 'one point of contact, from the first conversation to after installation.',
|
||||
|
||||
// Magazin: Abschluss jeder Beitragsseite
|
||||
'Entdecken Sie mehr über <span class="text-white font-medium">Immobilien-Investments</span> und das <span class="text-white font-medium">B2in-Ökosystem</span>.' => 'Weitere Einblicke rund um Einrichtung, Planung und Projekte finden Sie im <span class="text-white font-medium">B2in Magazin</span>.',
|
||||
'Discover more about <span class="text-white font-medium">real estate investments</span> and the <span class="text-white font-medium">B2in ecosystem</span>.' => 'Further insights into interiors, planning and projects can be found in the <span class="text-white font-medium">B2in Magazine</span>.',
|
||||
|
||||
// Otterpohl: Montage
|
||||
'Das garantiert Ihnen absolute Planungssicherheit.' => 'Das erhöht die Planungssicherheit deutlich.',
|
||||
];
|
||||
|
||||
/** @var array<string, string> Ganze Werte, die nur als Ganzes getauscht werden */
|
||||
public const EXACT = [
|
||||
'Aufmaß & Planung' => 'Planung & Aufmaß',
|
||||
'Measuring & planning' => 'Planning & measuring',
|
||||
|
||||
// FAQ „Wie läuft eine Beratung bei Ihnen ab?“
|
||||
'Am Anfang steht ein Gespräch – im Showroom, bei Ihnen zu Hause oder telefonisch. Wir schauen uns an, welche Räume es betrifft, wie Sie sie nutzen und was Ihnen wichtig ist. Danach folgt das Aufmaß vor Ort, anschließend die Planung. Sie sehen Ihre Lösung, bevor etwas gefertigt wird. Termine vereinbaren Sie über das Formular auf dieser Seite oder direkt telefonisch.' => self::FAQ_CONSULTATION_DE,
|
||||
'It starts with a conversation – in the showroom, at your home or by phone. We look at the rooms involved, how you use them and what matters to you. Measuring up on site follows, then the planning. You see your solution before anything is made. Appointments can be arranged through the form on this page or by phone.' => self::FAQ_CONSULTATION_EN,
|
||||
|
||||
// FAQ „Was kostet eine Maßanfertigung …?“
|
||||
'Das hängt vom Raum, den Materialien und der Ausstattung ab – eine seriöse Zahl lässt sich erst nach dem Aufmaß nennen. Was sich sagen lässt: Maßanfertigung nutzt Flächen, die ein Standardmöbel liegen lässt – Schrägen, Nischen, ungewöhnliche Höhen. Sie zahlen für den Raum, den Sie sonst verschenken. Das Angebot bekommen Sie vor der Fertigung, verbindlich und aufgeschlüsselt.' => self::FAQ_COST_DE,
|
||||
'That depends on the room, the materials and the fittings – a serious figure is only possible after measuring up. What can be said: bespoke uses the space standard furniture leaves behind – slopes, niches, unusual heights. You pay for the space you would otherwise give away. You receive the quote before anything is made, binding and itemised.' => self::FAQ_COST_EN,
|
||||
|
||||
// FAQ „Was bedeutet Projektausstattung bei B2in?“
|
||||
'Für Objekte, Büros und Projektentwicklungen übernehmen wir die vollständige Ausstattung – koordiniert aus einer Hand. Wir steuern die Beschaffung von Möbeln und Innenausstattung operativ und strategisch. Das bedeutet: Wir sichern Lieferverträge ab, überwachen Meilensteine direkt bei den Herstellern und eskalieren bei Abweichungen sofort auf Managementebene. Unser Ziel ist absolute Vertragssicherheit und termingerechte Lieferung ohne Reibungsverluste.' => self::FAQ_PROJECT_DE,
|
||||
'For properties, offices and developments we take on the complete fit-out, coordinated from a single source. We manage the procurement of furniture and interiors both operationally and strategically: we secure supply contracts, monitor milestones directly with the manufacturers and escalate deviations immediately. The goal is contractual reliability and on-time delivery without friction.' => self::FAQ_PROJECT_EN,
|
||||
];
|
||||
|
||||
public const FAQ_CONSULTATION_DE = 'Am Anfang steht ein Gespräch – im Showroom, telefonisch oder auf Wunsch bei Ihnen vor Ort. Für eine erste Planung reichen uns in der Regel Fotos, ein Grundriss und grobe Maße. Damit können wir bereits Ideen entwickeln, die Planung konkretisieren und Ihnen ein entsprechendes Angebot erstellen. Wenn Ihnen die Planung gefällt und Sie unser Angebot annehmen, erfolgt nach der Anzahlung das professionelle Aufmaß bei Ihnen vor Ort. Auf Grundlage dieser exakten Maße erstellen wir die Feinplanung und geben die Möbel anschließend zur Fertigung frei.';
|
||||
|
||||
public const FAQ_CONSULTATION_EN = 'It starts with a conversation – in the showroom, by phone or, if you like, at your home. For a first plan, photos, a floor plan and rough measurements are usually all we need. With these we can develop ideas, refine the planning and prepare a quote. If you like the plan and accept our quote, we take the professional measurements on site once the deposit has been paid. Based on these exact measurements we complete the detailed planning and then release the furniture for production.';
|
||||
|
||||
public const FAQ_COST_DE = 'Das hängt vom Raum, den Materialien und der Ausstattung ab. Bereits auf Basis von Fotos, Grundriss und groben Maßen können wir eine konkrete Planung und ein entsprechendes Angebot erstellen. Das professionelle Aufmaß erfolgt nach Angebotsannahme und Anzahlung und dient anschließend der finalen Feinplanung und Fertigungsfreigabe; zeigen sich dabei vor Ort relevante Abweichungen, stimmen wir Anpassungen mit Ihnen ab. Maßanfertigung nutzt Flächen, die ein Standardmöbel liegen lässt – Schrägen, Nischen, ungewöhnliche Höhen. Sie zahlen für den Raum, den Sie sonst verschenken.';
|
||||
|
||||
public const FAQ_COST_EN = 'That depends on the room, the materials and the fittings. Based on photos, a floor plan and rough measurements we can already prepare a concrete plan and a matching quote. The professional measurement on site follows once the quote is accepted and the deposit paid, and serves the final detailed planning and production release; if relevant deviations show up on site, we agree any adjustments with you. Bespoke uses the space standard furniture leaves behind – slopes, niches, unusual heights. You pay for the space you would otherwise give away.';
|
||||
|
||||
public const FAQ_PROJECT_DE = 'Für Objekte, Büros und Projektentwicklungen übernehmen wir die vollständige Einrichtung – koordiniert aus einer Hand, von der Küche bis zur letzten Blume. Sie haben einen zentralen deutschen Vertragspartner und einen festen Ansprechpartner, der Planung, Hersteller und Gewerke zusammenhält. Dahinter steht ein eingespieltes Herstellernetzwerk, dessen Abläufe wir kennen – so bleibt auch ein großes Projekt übersichtlich.';
|
||||
|
||||
public const FAQ_PROJECT_EN = 'For properties, offices and developments we take on the complete fit-out – coordinated from a single source, from the kitchen to the last flower. You have one central German contracting partner and a fixed point of contact who keeps planning, manufacturers and trades together. Behind this is a well-established network of manufacturers whose processes we know – so even a large project stays manageable.';
|
||||
|
||||
/** @var list<string> Beiträge aus der früheren Ausrichtung – offline, bis es neue gibt */
|
||||
public const UNPUBLISHED_ARTICLES = [
|
||||
'supply-chain-management-vertragssicherheit',
|
||||
'local-for-local-regionaler-moebelhandel',
|
||||
];
|
||||
|
||||
public const REWRITTEN_ARTICLE = 'massanfertigung-warum-der-millimeter-zaehlt';
|
||||
|
||||
/** @var list<string> Bisherige Vereinsnamen – solange einer davon drinsteht, kommt die neue Liste */
|
||||
public const FORMER_SPONSORS = ['TC Bad Waldliesborn-Horn', 'TC Hövelhof'];
|
||||
|
||||
private const MONTHS = [
|
||||
'januar' => 'Januar', 'january' => 'Januar', 'februar' => 'Februar', 'february' => 'Februar',
|
||||
'märz' => 'März', 'march' => 'März', 'april' => 'April', 'mai' => 'Mai', 'may' => 'Mai',
|
||||
'juni' => 'Juni', 'june' => 'Juni', 'juli' => 'Juli', 'july' => 'Juli', 'august' => 'August',
|
||||
'september' => 'September', 'oktober' => 'Oktober', 'october' => 'Oktober', 'november' => 'November',
|
||||
'dezember' => 'Dezember', 'december' => 'Dezember',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$texts = $this->replaceCmsTexts(self::REPLACEMENTS, self::EXACT);
|
||||
$slides = $this->addSlides();
|
||||
$sponsors = $this->replaceSponsors();
|
||||
$articles = $this->updateArticles();
|
||||
|
||||
$this->command?->info(sprintf(
|
||||
'Korrekturen 08.10.: %d Textstelle(n), %d Slide(s), Vereine %s, %d Beitrag/Beiträge aktualisiert.',
|
||||
$texts, $slides, $sponsors ? 'neu' : 'unverändert', $articles,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt die Slides der Lang-Datei, die im CMS fehlen, an derselben Stelle ein (hinter dem Bild,
|
||||
* das in der Lang-Datei davor steht). Fehlt dieses Bild, bleibt die Liste unangetastet.
|
||||
*/
|
||||
private function addSlides(): int
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first();
|
||||
|
||||
if (! $row) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$added = 0;
|
||||
|
||||
foreach ($value as $locale => $content) {
|
||||
$defaults = trans('b2in.themes.b2in.hero.slides', [], $locale);
|
||||
|
||||
if (! is_array($content) || ! is_array($content['slides'] ?? null) || ! is_array($defaults)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($defaults as $index => $slide) {
|
||||
$images = array_column($content['slides'], 'image');
|
||||
|
||||
if (in_array($slide['image'], $images, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nur wenn das Nachbarbild aus der Lang-Datei in der Liste steht – eine ganz eigene
|
||||
// Liste im CMS bleibt so, wie sie ist.
|
||||
$previous = $index > 0 ? array_search($defaults[$index - 1]['image'], $images, true) : false;
|
||||
|
||||
if ($previous === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
array_splice($content['slides'], $previous + 1, 0, [$slide]);
|
||||
$added++;
|
||||
}
|
||||
|
||||
$value[$locale] = $content;
|
||||
}
|
||||
|
||||
if ($added > 0) {
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
private function replaceSponsors(): bool
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'sponsoring')->first();
|
||||
|
||||
if (! $row) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$changed = false;
|
||||
|
||||
foreach ($value as $locale => $content) {
|
||||
$names = array_column((array) ($content['logos'] ?? []), 'name');
|
||||
$defaults = trans('b2in.themes.b2in.sponsoring.logos', [], $locale);
|
||||
|
||||
if (! is_array($content) || ! is_array($defaults)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (array_intersect($names, self::FORMER_SPONSORS) !== []) {
|
||||
$value[$locale]['logos'] = $defaults;
|
||||
$changed = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Vereinsseiten nachtragen, wo der Verein schon steht, aber noch keine URL hat.
|
||||
$urls = array_column($defaults, 'url', 'name');
|
||||
|
||||
foreach ((array) ($content['logos'] ?? []) as $index => $logo) {
|
||||
$url = $urls[$logo['name'] ?? ''] ?? null;
|
||||
|
||||
if ($url !== null && blank($logo['url'] ?? null)) {
|
||||
$value[$locale]['logos'][$index]['url'] = $url;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
}
|
||||
|
||||
return $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maßanfertigungs-Beitrag aus der Lang-Datei, zwei Beiträge offline, Datum und Lesezeit deutsch.
|
||||
*/
|
||||
private function updateArticles(): int
|
||||
{
|
||||
$count = 0;
|
||||
$source = collect(trans('b2in.articles', [], 'de'))->firstWhere('slug', self::REWRITTEN_ARTICLE);
|
||||
$sourceEn = collect(trans('b2in.articles', [], 'en'))->firstWhere('slug', self::REWRITTEN_ARTICLE);
|
||||
|
||||
foreach (CmsArticle::query()->get() as $article) {
|
||||
if (in_array($article->slug, self::UNPUBLISHED_ARTICLES, true)) {
|
||||
$article->is_published = false;
|
||||
}
|
||||
|
||||
if ($article->slug === self::REWRITTEN_ARTICLE && is_array($source)
|
||||
&& str_contains((string) $article->getTranslation('title', 'de'), 'Warum der Millimeter über den Raum entscheidet')) {
|
||||
foreach (['title', 'subtitle', 'content'] as $field) {
|
||||
$article->setTranslation($field, 'de', $source[$field]);
|
||||
|
||||
if (is_array($sourceEn)) {
|
||||
$article->setTranslation($field, 'en', $sourceEn[$field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$article->date_label = self::germanDate((string) $article->date_label);
|
||||
$article->read_time = self::germanReadTime((string) $article->read_time);
|
||||
|
||||
if ($article->isDirty()) {
|
||||
$article->save();
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** „August 26, 2026“ / „März 10, 2026“ → „26. August 2026“; schon Deutsches bleibt. */
|
||||
public static function germanDate(string $value): string
|
||||
{
|
||||
if (! preg_match('/^\s*(\p{L}+)\s+(\d{1,2}),\s*(\d{4})\s*$/u', $value, $match)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$month = self::MONTHS[mb_strtolower($match[1])] ?? null;
|
||||
|
||||
return $month === null ? $value : "{$match[2]}. {$month} {$match[3]}";
|
||||
}
|
||||
|
||||
/** „5 min read“ → „5 Min. Lesezeit“; schon Deutsches bleibt. */
|
||||
public static function germanReadTime(string $value): string
|
||||
{
|
||||
return preg_match('/^\s*(\d+)\s*min(?:\.|utes?)?\s*read\s*$/i', $value, $match)
|
||||
? "{$match[1]} Min. Lesezeit"
|
||||
: $value;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,14 +14,20 @@ class DatabaseSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Kevin Adametz',
|
||||
'email' => 'kevin.adametz@me.com',
|
||||
'password' => Hash::make('xunfew-0Jygjy-minnyt'),
|
||||
'name' => config('seeding.admin_name'),
|
||||
'email' => config('seeding.admin_email'),
|
||||
'password' => Hash::make(config('seeding.admin_password')),
|
||||
]);
|
||||
|
||||
$this->call(TestDisplaySeeder::class);
|
||||
// CMS-Inhalte: legen nur an, was fehlt – bestehende Redaktion bleibt unangetastet.
|
||||
$this->call([
|
||||
CmsContentSeeder::class,
|
||||
CmsLegalSeeder::class,
|
||||
CmsArticleSeeder::class,
|
||||
CmsBrandSeeder::class,
|
||||
CmsProjectSeeder::class,
|
||||
TestDisplaySeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
database/seeders/DemoBrandGalleryCleanupSeeder.php
Normal file
52
database/seeders/DemoBrandGalleryCleanupSeeder.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Entfernt die Demobilder aus den Markengalerien (Nachtrag 17).
|
||||
*
|
||||
* {@see DemoBrandGallerySeeder} hatte Stockmotive und KI-Renderings der alten Dubai-Seite als
|
||||
* Beispiele in die Galerien von CABINET, Rotpunkt und Febrü gelegt. Seit den echten Bildern
|
||||
* (Nachtrag 16) sollen sie weg. Erkannt werden sie am Dateinamen aus dem Demo-Seeder – nicht an
|
||||
* der Bildunterschrift, denn den Vermerk „BEISPIEL“ hat {@see BrandGalleryCaptionNormalizerSeeder}
|
||||
* auf der Vorschau bereits entfernt. Alle anderen Galeriebilder bleiben samt Kategorie,
|
||||
* Unterschrift und Reihenfolge. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=DemoBrandGalleryCleanupSeeder
|
||||
*/
|
||||
class DemoBrandGalleryCleanupSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$demoImages = [];
|
||||
|
||||
foreach (DemoBrandGallerySeeder::GALLERIES as $items) {
|
||||
foreach ($items as $item) {
|
||||
$demoImages[] = $item[0];
|
||||
}
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
|
||||
foreach (CmsBrand::query()->whereNotNull('gallery')->get() as $brand) {
|
||||
$gallery = CmsBrand::normalizeGallery($brand->gallery);
|
||||
$kept = array_values(array_filter(
|
||||
$gallery,
|
||||
fn (array $item) => ! in_array($item['image'], $demoImages, true),
|
||||
));
|
||||
|
||||
if (count($kept) === count($gallery)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$removed += count($gallery) - count($kept);
|
||||
$brand->gallery = $kept;
|
||||
$brand->save();
|
||||
}
|
||||
|
||||
$this->command?->info("Demobilder aus den Galerien entfernt: {$removed}.");
|
||||
}
|
||||
}
|
||||
70
database/seeders/DemoBrandGallerySeeder.php
Normal file
70
database/seeders/DemoBrandGallerySeeder.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\CmsBrand;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Demo-Galerien zum Ausprobieren der Marken-Detailseiten.
|
||||
*
|
||||
* NUR fuer lokale Tests. Bewusst NICHT in {@see DatabaseSeeder} registriert.
|
||||
*
|
||||
* ⚠️ Die verwendeten Bilder stammen aus dem Bestand der alten Dubai-Seite – es sind Stockmotive
|
||||
* und KI-Renderings, kein einziges davon ist ein echtes CABINET-, Rotpunkt- oder Februe-Projekt.
|
||||
* Der frueher vorangestellte Vermerk „BEISPIEL“ in den Bildunterschriften ist auf Kundenwunsch
|
||||
* entfallen. Die Unterscheidung traegt jetzt allein die Kategorie (Badge „Realisiertes Projekt“
|
||||
* bzw. „Visualisierung“) – die Motive der Kategorie `project` muessen deshalb vor dem Livegang
|
||||
* durch echte Referenzen ersetzt werden. Steht als Punkt in der Deployment-Checkliste.
|
||||
*
|
||||
* Wieder entfernen – nur die Demobilder, gepflegte Bilder bleiben:
|
||||
* php artisan db:seed --class=DemoBrandGalleryCleanupSeeder
|
||||
*/
|
||||
class DemoBrandGallerySeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Galerie je Marken-Slug: [Dateiname, Kategorie, Bildunterschrift].
|
||||
*
|
||||
* @var array<string, list<array{0: string, 1: string, 2: string}>>
|
||||
*/
|
||||
public const GALLERIES = [
|
||||
'cabinet' => [
|
||||
['b2a.webp', CmsBrand::GALLERY_PROJECT, 'Wohnwand mit Einbauregal, Bielefeld'],
|
||||
['hero-room.webp', CmsBrand::GALLERY_PROJECT, 'Wohnbereich mit Raumteiler'],
|
||||
['stileigentum.webp', CmsBrand::GALLERY_INSPIRATION, 'Entwurf Wohnzimmer mit Sideboard'],
|
||||
['best-of-two-worlds.jpg', CmsBrand::GALLERY_INSPIRATION, 'Visualisierung offener Wohnraum'],
|
||||
],
|
||||
'rotpunkt' => [
|
||||
['best-of-two-world.jpg', CmsBrand::GALLERY_PROJECT, 'Offener Wohn- und Kochbereich'],
|
||||
['style2own.webp', CmsBrand::GALLERY_INSPIRATION, 'Entwurf heller Wohnbereich'],
|
||||
['magazin-3.webp', CmsBrand::GALLERY_INSPIRATION, 'Visualisierung Wohnraum mit Ausblick'],
|
||||
],
|
||||
'februe' => [
|
||||
['accommodation-2.webp', CmsBrand::GALLERY_PROJECT, 'Empfangsbereich eines Buerogebaeudes'],
|
||||
['accommodation-1.webp', CmsBrand::GALLERY_PROJECT, 'Grossraumbuero mit Arbeitsplaetzen'],
|
||||
['partner-hero.webp', CmsBrand::GALLERY_INSPIRATION, 'Entwurf Besprechungsbereich'],
|
||||
['ecosystem-hero.webp', CmsBrand::GALLERY_INSPIRATION, 'Visualisierung Projektraum'],
|
||||
],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach (self::GALLERIES as $slug => $items) {
|
||||
$brand = CmsBrand::query()->where('slug', $slug)->first();
|
||||
|
||||
if (! $brand) {
|
||||
$this->command?->warn("Marke „{$slug}“ nicht gefunden – uebersprungen.");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$brand->gallery = CmsBrand::normalizeGallery(array_map(
|
||||
fn (array $item) => ['image' => $item[0], 'category' => $item[1], 'caption' => $item[2]],
|
||||
$items,
|
||||
));
|
||||
$brand->save();
|
||||
|
||||
$this->command?->info("Galerie fuer „{$brand->slug}“ gesetzt: ".count($brand->gallery).' Bilder.');
|
||||
}
|
||||
}
|
||||
}
|
||||
58
database/seeders/DisplayInteriorBrandingSeeder.php
Normal file
58
database/seeders/DisplayInteriorBrandingSeeder.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use App\Models\DisplayVersion;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Vereinheitlicht das Branding aller B2in-Display-Module auf das Interior-Logo.
|
||||
*
|
||||
* Ausgangslage waren vier verschiedene Logo-Quellen nebeneinander: zwei Dateien aus der
|
||||
* Medienbibliothek, ein relativer und ein absoluter Pfad auf die Cabinet-Assets. Drei Module
|
||||
* zeigten dabei auf `b2in.test` – eine lokale Entwicklungsdomain, die auf dem Live-Display
|
||||
* nicht auflösbar ist.
|
||||
*
|
||||
* Gesetzt wird das Logo passend zum Theme des Moduls (dunkel = negativ/weiss, hell = positiv)
|
||||
* sowie der neue Claim. Alle anderen Einstellungen bleiben unangetastet.
|
||||
*
|
||||
* Module, die schon eines der beiden Interior-Logos tragen, bleiben ganz unberührt (08.10.2026):
|
||||
* Sie sind entweder neu angelegt (Standardwerte) oder bereits umgestellt, und ein inzwischen im
|
||||
* Display-CMS gepflegter Claim soll beim Livegang nicht zurückgesetzt werden.
|
||||
*
|
||||
* php artisan db:seed --class=DisplayInteriorBrandingSeeder
|
||||
*/
|
||||
class DisplayInteriorBrandingSeeder extends Seeder
|
||||
{
|
||||
private const LOGO_DARK = '/_cabinet/assets/b2in-interior-logo-negative.svg';
|
||||
|
||||
private const LOGO_LIGHT = '/_cabinet/assets/b2in-interior-logo-positive.svg';
|
||||
|
||||
private const CLAIM = 'Premium-Einrichtung. Persönlich gemacht.';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$updated = 0;
|
||||
|
||||
foreach (DisplayVersion::query()->where('type', DisplayVersionType::B2in)->get() as $module) {
|
||||
$settings = $module->settings ?? [];
|
||||
|
||||
if (in_array($settings['header_logo_url'] ?? null, [self::LOGO_DARK, self::LOGO_LIGHT], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$settings['header_logo_url'] = ($settings['theme'] ?? 'dark') === 'light'
|
||||
? self::LOGO_LIGHT
|
||||
: self::LOGO_DARK;
|
||||
|
||||
$settings['header_claim'] = self::CLAIM;
|
||||
|
||||
$module->settings = $settings;
|
||||
$module->save();
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$this->command?->info("Display-Branding vereinheitlicht: {$updated} B2in-Module.");
|
||||
}
|
||||
}
|
||||
77
database/seeders/DisplayMediaUrlNormalizerSeeder.php
Normal file
77
database/seeders/DisplayMediaUrlNormalizerSeeder.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Macht die im Display-CMS gespeicherten Medien-URLs host-unabhängig.
|
||||
*
|
||||
* Problem: Bilder, Videos und QR-Codes lagen als absolute URLs mit fest verdrahtetem Host in
|
||||
* den Moduleinstellungen und Item-Inhalten – teils `b2in.test` (lokale Entwicklungsdomain),
|
||||
* teils `b2in.eu`. Der Player gibt absolute URLs unverändert zurück, deshalb lädt ein auf
|
||||
* `b2in.test` verweisendes Medium auf dem Live-Display nicht (und umgekehrt).
|
||||
*
|
||||
* Relative Pfade löst der Player über `resolveDisplayUrl()` gegen die Portal-Basis auf und
|
||||
* funktionieren damit auf beiden Systemen. Umgeschrieben wird deshalb Host + Schema zu einem
|
||||
* reinen Pfad.
|
||||
*
|
||||
* Bewusst **nicht** angefasst: Ziel-Links wie Footer-URL oder QR-Ziel (z. B.
|
||||
* `cabinet-bielefeld.de`) – die müssen absolut bleiben. Der Filter greift nur bei einem
|
||||
* B2in-Host **und** einem Pfad unterhalb von /storage/.
|
||||
*
|
||||
* php artisan db:seed --class=DisplayMediaUrlNormalizerSeeder
|
||||
*/
|
||||
class DisplayMediaUrlNormalizerSeeder extends Seeder
|
||||
{
|
||||
private const ASSET_URL_PATTERN = '#^https?://(?:[a-z0-9-]+\.)*b2in\.(?:test|eu|de)(/storage/[^\s"]*)$#i';
|
||||
|
||||
private int $rewritten = 0;
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach (DisplayVersion::all() as $module) {
|
||||
$settings = $this->normalize($module->settings ?? []);
|
||||
|
||||
if ($settings !== ($module->settings ?? [])) {
|
||||
$module->settings = $settings;
|
||||
$module->save();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (DisplayVersionItem::all() as $item) {
|
||||
$content = $this->normalize($item->content ?? []);
|
||||
|
||||
if ($content !== ($item->content ?? [])) {
|
||||
$item->content = $content;
|
||||
$item->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->command?->info("Display-Medien-URLs host-unabhängig gemacht: {$this->rewritten} Verweise.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $value
|
||||
* @return array<mixed>
|
||||
*/
|
||||
private function normalize(array $value): array
|
||||
{
|
||||
foreach ($value as $key => $entry) {
|
||||
if (is_array($entry)) {
|
||||
$value[$key] = $this->normalize($entry);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($entry) && preg_match(self::ASSET_URL_PATTERN, $entry, $matches) === 1) {
|
||||
$value[$key] = $matches[1];
|
||||
$this->rewritten++;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
74
database/seeders/HeroTrustItemsSeeder.php
Normal file
74
database/seeders/HeroTrustItemsSeeder.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Vertrauenspunkte unten im Hero (Abstimmung 07.10.2026): Die Startseite bekommt „Deutsche
|
||||
* Manufakturen“, „Planung bis Montage“ und „Zwei Showrooms in Bielefeld“ – ersetzt werden nur die
|
||||
* bisherigen Standardpunkte. Der Hero auf /marken bekommt „Maßarbeit vom Handwerk“, „Werk und
|
||||
* Ausstellung erlebbar“ und „Küche, Einbau, Arbeitsplatz“, wenn dort noch keine gepflegt sind.
|
||||
* Die Texte kommen aus der Lang-Datei (`stats`). Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=HeroTrustItemsSeeder
|
||||
*/
|
||||
class HeroTrustItemsSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, list<string>> Bisherige Standardpunkte der Startseite je Sprache */
|
||||
public const FORMER_HOME_STATS = [
|
||||
'de' => ['Premium-Marken', 'Maßgefertigte Lösungen', 'Persönliche Beratung'],
|
||||
'en' => ['Premium brands', 'Bespoke solutions', 'Personal consulting'],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$updated = array_filter([
|
||||
$this->apply('home', 'hero', 'hero', fn (mixed $current, string $locale) => $current === (self::FORMER_HOME_STATS[$locale] ?? null)),
|
||||
$this->apply('marken', 'hero', 'marken_hero', fn (mixed $current) => ! filled($current)),
|
||||
]);
|
||||
|
||||
$this->command?->info($updated === []
|
||||
? 'Hero-Vertrauenspunkte: bereits aktuell, nichts geändert.'
|
||||
: 'Hero-Vertrauenspunkte gesetzt: '.implode(', ', $updated).'.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(mixed, string): bool $shouldReplace
|
||||
*/
|
||||
private function apply(string $group, string $key, string $langSection, callable $shouldReplace): ?string
|
||||
{
|
||||
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
|
||||
|
||||
if (! $row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$changed = false;
|
||||
|
||||
foreach ($value as $locale => $content) {
|
||||
$stats = trans("b2in.themes.b2in.{$langSection}.stats", [], $locale);
|
||||
|
||||
if (! is_array($content) || ! is_array($stats) || ! $shouldReplace($content['stats'] ?? null, $locale)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content['stats'] = $stats;
|
||||
$value[$locale] = $content;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (! $changed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache($group);
|
||||
|
||||
return "{$group}/{$key}";
|
||||
}
|
||||
}
|
||||
89
database/seeders/HomeHeroSlidesSeeder.php
Normal file
89
database/seeders/HomeHeroSlidesSeeder.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Slideshow im Hero der Startseite (Abstimmung 07.10.2026).
|
||||
*
|
||||
* Trägt die neun Bilder aus dev/2026-08-28/images/slideshow (als WebP unter
|
||||
* public/img/assets/b2in/slideshow/) samt Wechselintervall in den CMS-Eintrag home/hero ein –
|
||||
* je Sprache aus resources/lang/{de,en}/b2in.php. Steht dort schon eine Liste `slides`, bleibt sie,
|
||||
* wie sie gepflegt ist – nur fehlende Texte für die Infokarte (`caption`) kommen bei den gelieferten
|
||||
* Bildern dazu. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=HomeHeroSlidesSeeder
|
||||
*/
|
||||
class HomeHeroSlidesSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first();
|
||||
|
||||
if (! $row) {
|
||||
$this->command?->info('Hero-Slideshow: home/hero nicht im CMS – die Lang-Datei gilt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$changed = false;
|
||||
|
||||
foreach ($value as $locale => $content) {
|
||||
$defaults = trans('b2in.themes.b2in.hero', [], $locale);
|
||||
|
||||
if (! is_array($content) || ! is_array($defaults) || ! is_array($defaults['slides'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists('slides', $content)) {
|
||||
$value[$locale] = $content + ['interval' => $defaults['interval'] ?? 7, 'slides' => $defaults['slides']];
|
||||
$changed = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$withCaptions = $this->addMissingCaptions((array) $content['slides'], $defaults['slides']);
|
||||
|
||||
if ($withCaptions !== $content['slides']) {
|
||||
$value[$locale]['slides'] = $withCaptions;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $changed) {
|
||||
$this->command?->info('Hero-Slideshow: Slides bereits gepflegt, nichts geändert.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
|
||||
$this->command?->info('Hero-Slideshow: Bilder bzw. Texte der Infokarte eingetragen.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergänzt `caption` bei Slides, die eines der gelieferten Bilder zeigen und noch keinen Text haben.
|
||||
*
|
||||
* @param array<int, mixed> $slides
|
||||
* @param array<int, array<string, mixed>> $defaults
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function addMissingCaptions(array $slides, array $defaults): array
|
||||
{
|
||||
$captions = array_column($defaults, 'caption', 'image');
|
||||
|
||||
return array_map(function ($slide) use ($captions) {
|
||||
if (! is_array($slide) || array_key_exists('caption', $slide) || ! isset($captions[$slide['image'] ?? ''])) {
|
||||
return $slide;
|
||||
}
|
||||
|
||||
return $slide + ['caption' => $captions[$slide['image']]];
|
||||
}, $slides);
|
||||
}
|
||||
}
|
||||
121
database/seeders/HomeHeroTextSeeder.php
Normal file
121
database/seeders/HomeHeroTextSeeder.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Hero der Startseite (Abstimmung 01.10.2026, Briefing Marcel 24.09.2026 Punkte 3 und 5).
|
||||
*
|
||||
* Setzt im CMS-Eintrag home/hero die neue Überschrift „Räume aus einer Hand. Mit starken Marken
|
||||
* und persönlichen Lösungen.“ und entfernt den zweiten Button „Termin anfragen“ (der feste
|
||||
* Termin-Button unten rechts bleibt). Die Überschrift wird nur ersetzt, solange noch die alte
|
||||
* „B2in Interior – Premium-Einrichtung …“ drinsteht – eine eigene Formulierung im CMS bleibt.
|
||||
*
|
||||
* Abstimmung 07.10.2026: Zeile „B2in Interior“ über der Überschrift (`eyebrow`, nur wenn noch
|
||||
* keine gepflegt ist), Untertitel „Wir vereinen …“ (ersetzt nur den bisherigen) und das erste
|
||||
* Slideshow-Bild trägt in der Karte „B2in Interior“ statt „Rotpunkt Küchen“.
|
||||
* Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=HomeHeroTextSeeder
|
||||
*/
|
||||
class HomeHeroTextSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, array{old: string, new: string}> */
|
||||
public const TITLES = [
|
||||
'de' => [
|
||||
'old' => 'Premium-Einrichtung von starken Marken',
|
||||
'new' => '<span class="text-secondary">Räume aus einer Hand.</span><br>Mit starken Marken und persönlichen Lösungen.',
|
||||
],
|
||||
'en' => [
|
||||
'old' => 'Premium furnishing from strong brands',
|
||||
'new' => '<span class="text-secondary">Spaces from a single source.</span><br>With strong brands and personal solutions.',
|
||||
],
|
||||
];
|
||||
|
||||
/** @var array<string, array{old: string, new: string}> */
|
||||
public const SUBTITLES = [
|
||||
'de' => [
|
||||
'old' => 'Von der einzelnen Maßanfertigung bis zur vollständigen Ausstattung',
|
||||
'new' => 'Wir vereinen CABINET, Rotpunkt Küchen, Febrü und ein Netzwerk regionaler Experten, um individuelle Interior-Lösungen für Ihr Zuhause oder Ihr Projekt zu realisieren.',
|
||||
],
|
||||
'en' => [
|
||||
'old' => 'From a single bespoke piece to a complete fit-out',
|
||||
'new' => 'We bring together CABINET, Rotpunkt Küchen, Febrü and a network of regional experts to create individual interior solutions for your home or your project.',
|
||||
],
|
||||
];
|
||||
|
||||
public const EYEBROW = 'B2in Interior';
|
||||
|
||||
public const FIRST_SLIDE_IMAGE = 'b2in/slideshow/rotpunkt-fenix-house.webp';
|
||||
|
||||
public const FIRST_SLIDE_LABEL = ['old' => 'Rotpunkt Küchen', 'new' => 'B2in Interior'];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'hero')->first();
|
||||
|
||||
if (! $row) {
|
||||
$this->command?->info('Hero: Sektion home/hero nicht im CMS – die Lang-Datei gilt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$changed = false;
|
||||
|
||||
foreach ($value as $locale => $content) {
|
||||
if (! is_array($content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = self::TITLES[$locale] ?? null;
|
||||
|
||||
if ($title && str_contains((string) ($content['title'] ?? ''), $title['old'])) {
|
||||
$content['title'] = $title['new'];
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
$subtitle = self::SUBTITLES[$locale] ?? null;
|
||||
|
||||
if ($subtitle && str_contains((string) ($content['subtitle'] ?? ''), $subtitle['old'])) {
|
||||
$content['subtitle'] = $subtitle['new'];
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (! filled($content['eyebrow'] ?? null)) {
|
||||
$content['eyebrow'] = self::EYEBROW;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
foreach ((array) ($content['slides'] ?? []) as $index => $slide) {
|
||||
if (is_array($slide) && ($slide['image'] ?? null) === self::FIRST_SLIDE_IMAGE
|
||||
&& ($slide['label'] ?? null) === self::FIRST_SLIDE_LABEL['old']) {
|
||||
$content['slides'][$index]['label'] = self::FIRST_SLIDE_LABEL['new'];
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('cta2_text', $content) || array_key_exists('cta2_link', $content)) {
|
||||
unset($content['cta2_text'], $content['cta2_link']);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
$value[$locale] = $content;
|
||||
}
|
||||
|
||||
if (! $changed) {
|
||||
$this->command?->info('Hero: bereits aktuell, nichts geändert.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
|
||||
$this->command?->info('Hero: Texte aktualisiert (Überschrift, Zeile darüber, Untertitel, erste Slide).');
|
||||
}
|
||||
}
|
||||
58
database/seeders/HomeVisionTitleSeeder.php
Normal file
58
database/seeders/HomeVisionTitleSeeder.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Startseite, „Gebaut auf Expertise und Vertrauen“ (Abstimmung 07.10.2026): „Expertise und
|
||||
* Vertrauen“ in der Akzentfarbe. Ersetzt nur die bisherige Überschrift ohne Hervorhebung –
|
||||
* eine eigene Formulierung im CMS bleibt. Mehrfaches Ausführen ist unbedenklich.
|
||||
*
|
||||
* php artisan db:seed --class=HomeVisionTitleSeeder
|
||||
*/
|
||||
class HomeVisionTitleSeeder extends Seeder
|
||||
{
|
||||
/** @var array<string, array{old: string, new: string}> */
|
||||
public const TITLES = [
|
||||
'de' => ['old' => 'Gebaut auf Expertise und Vertrauen', 'new' => 'Gebaut auf <span class="text-secondary">Expertise und Vertrauen</span>'],
|
||||
'en' => ['old' => 'Built on Expertise and Trust', 'new' => 'Built on <span class="text-secondary">Expertise and Trust</span>'],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$row = CmsContent::query()->where('group', 'home')->where('key', 'vision_section')->first();
|
||||
|
||||
if (! $row) {
|
||||
$this->command?->info('Vision: Sektion nicht im CMS – die Lang-Datei gilt.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$value = $row->getTranslations('value');
|
||||
$changed = false;
|
||||
|
||||
foreach ($value as $locale => $content) {
|
||||
$title = self::TITLES[$locale] ?? null;
|
||||
|
||||
if (is_array($content) && $title && ($content['title'] ?? null) === $title['old']) {
|
||||
$value[$locale]['title'] = $title['new'];
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $changed) {
|
||||
$this->command?->info('Vision: bereits aktuell, nichts geändert.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$row->value = $value;
|
||||
$row->save();
|
||||
app(CmsContentService::class)->clearCache('home');
|
||||
|
||||
$this->command?->info('Vision: Überschrift mit Hervorhebung gesetzt.');
|
||||
}
|
||||
}
|
||||
66
database/seeders/InteriorRelaunchCmsSeeder.php
Normal file
66
database/seeders/InteriorRelaunchCmsSeeder.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Einmaliger Relaunch-Schritt: schiebt die in AP1 neu getexteten Sektionen ins CMS.
|
||||
*
|
||||
* Hintergrund: cms_theme_section() bevorzugt das CMS gegenüber der Lang-Datei. Die hier
|
||||
* gelisteten Sektionen liegen bereits mit den alten Texten im CMS und würden die neuen
|
||||
* Inhalte sonst überdecken. Der reguläre CmsContentSeeder fasst bestehende Einträge
|
||||
* bewusst nicht mehr an (Redaktionsschutz) – deshalb dieser gezielte Lauf.
|
||||
*
|
||||
* ⚠️ Überschreibt genau diese Sektionen mit dem Stand aus resources/lang/{de,en}/b2in.php.
|
||||
* Andere CMS-Inhalte bleiben unangetastet. Vorher ein DB-Backup ziehen.
|
||||
*
|
||||
* php artisan db:seed --class=InteriorRelaunchCmsSeeder
|
||||
*/
|
||||
class InteriorRelaunchCmsSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Section-Keys aus config/cms_section_map.php, die AP1 neu getextet hat.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const RELAUNCH_SECTIONS = [
|
||||
// Global
|
||||
'announcement_bar',
|
||||
'header',
|
||||
'footer',
|
||||
'cta_section',
|
||||
|
||||
// Startseite
|
||||
'hero',
|
||||
'founder_bar',
|
||||
'audience_switch',
|
||||
'core_brands',
|
||||
'vision_section',
|
||||
'sponsoring',
|
||||
'immobilien_teaser',
|
||||
|
||||
// Unterseite /marken
|
||||
'marken_hero',
|
||||
'marken_network',
|
||||
'marken_growth',
|
||||
'marken_cta',
|
||||
|
||||
// Unterseiten – Durchgang auf Alt-Begriffe
|
||||
'about_hero',
|
||||
'our_story',
|
||||
'faq',
|
||||
'contact_form',
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$seeder = new CmsContentSeeder;
|
||||
$seeder->setCommand($this->command);
|
||||
$seeder->onlySections = self::RELAUNCH_SECTIONS;
|
||||
$seeder->overwriteExisting = true;
|
||||
$seeder->run();
|
||||
|
||||
$this->command?->info('Interior-Relaunch: '.count(self::RELAUNCH_SECTIONS).' Sektionen aus den Lang-Dateien übernommen.');
|
||||
}
|
||||
}
|
||||
69
database/seeders/InteriorReleaseSeeder.php
Normal file
69
database/seeders/InteriorReleaseSeeder.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
/**
|
||||
* Alle Inhaltsschritte des Interior-Relaunchs in einem Lauf (08.10.2026).
|
||||
*
|
||||
* Bringt eine Datenbank mit dem alten Website-Stand auf den Stand der Vorschau: zuerst auf der
|
||||
* Vorschau mit frischer Kopie der Live-Datenbank (Generalprobe), nach der Freigabe identisch auf
|
||||
* Live. Displays, Playlists, Display-Mediathek und hochgeladene Medien bleiben, wie sie sind –
|
||||
* angefasst werden nur das Display-Branding (nur Module ohne Interior-Logo) und absolute
|
||||
* Medien-URLs (werden host-unabhängig).
|
||||
*
|
||||
* Reihenfolge wie Abschnitt 8.2 und „Seeder für das nächste Deployment“ im Entwicklungsplan.
|
||||
* Jeder Schritt ist idempotent, ein zweiter Lauf ändert nichts mehr. Ausgeführt von
|
||||
* `dev/betrieb/deploy.sh vX.Y.Z --migrations --seed=InteriorReleaseSeeder` im Wartungsmodus.
|
||||
*
|
||||
* php artisan db:seed --class=InteriorReleaseSeeder --force
|
||||
*/
|
||||
class InteriorReleaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* @var list<class-string<Seeder>>
|
||||
*/
|
||||
public const STEPS = [
|
||||
// Fehlende CMS-Einträge aus den Lang-Dateien anlegen – vorhandene bleiben unberührt.
|
||||
CmsContentSeeder::class,
|
||||
CmsBrandSeeder::class,
|
||||
// Die in AP1 neu getexteten Sektionen überschreiben (alter Website-Stand).
|
||||
InteriorRelaunchCmsSeeder::class,
|
||||
CmsArticleSeeder::class,
|
||||
// Displays: nur Branding und Medien-URLs.
|
||||
DisplayInteriorBrandingSeeder::class,
|
||||
DisplayMediaUrlNormalizerSeeder::class,
|
||||
// Marken
|
||||
BrandPageTextSeeder::class,
|
||||
BrandGalleryCaptionNormalizerSeeder::class,
|
||||
WorldSplitCmsSeeder::class,
|
||||
OriginSectionCmsSeeder::class,
|
||||
BrandOriginSeeder::class,
|
||||
OtterpohlBrandSeeder::class,
|
||||
BrandImageSeeder::class,
|
||||
DemoBrandGalleryCleanupSeeder::class,
|
||||
// Texte und Korrekturen
|
||||
WhatsappNumberSeeder::class,
|
||||
AudienceSwitchProjectTextSeeder::class,
|
||||
HomeHeroTextSeeder::class,
|
||||
UnusedCmsContentCleanupSeeder::class,
|
||||
AboutPageTextSeeder::class,
|
||||
ProofreadingCorrectionsSeeder::class,
|
||||
LegalTextUpdateSeeder::class,
|
||||
ReferencesPageSwitchSeeder::class,
|
||||
HomeHeroSlidesSeeder::class,
|
||||
PageHeroEyebrowSeeder::class,
|
||||
HeroTrustItemsSeeder::class,
|
||||
HomeVisionTitleSeeder::class,
|
||||
ContentCorrections20261008Seeder::class,
|
||||
// Zuletzt: fehlende Felder ergänzen, verwendete Bilder in die Mediathek.
|
||||
CmsSectionFieldsSeeder::class,
|
||||
AssetMediaImportSeeder::class,
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(self::STEPS);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue