Compare commits
No commits in common. "v2.0.2" and "master" have entirely different histories.
780 changed files with 3911 additions and 46338 deletions
30
.env.example
30
.env.example
|
|
@ -80,33 +80,3 @@ DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
|
||||||
# Nützlich in der Entwicklung, um Seed-Daten und manuelle Testeinträge zu erhalten.
|
# Nützlich in der Entwicklung, um Seed-Daten und manuelle Testeinträge zu erhalten.
|
||||||
# Hinweis: Migrationen müssen mindestens einmal für die Test-DB ausgeführt werden: DB_DATABASE=testing sail artisan migrate
|
# Hinweis: Migrationen müssen mindestens einmal für die Test-DB ausgeführt werden: DB_DATABASE=testing sail artisan migrate
|
||||||
# TESTING_PRESERVE_DATABASE=false
|
# TESTING_PRESERVE_DATABASE=false
|
||||||
|
|
||||||
# Seed-Admin (DatabaseSeeder)
|
|
||||||
SEED_ADMIN_NAME="Admin"
|
|
||||||
SEED_ADMIN_EMAIL=admin@b2in.test
|
|
||||||
SEED_ADMIN_PASSWORD=
|
|
||||||
|
|
||||||
# --- Version (leer lassen: kommt aus der Datei RELEASE, die dev/betrieb/deploy.sh schreibt)
|
|
||||||
APP_VERSION=
|
|
||||||
|
|
||||||
# --- GlitchTip (errors.adametz.media, Projekte server-b2in / browser-b2in) – siehe dev/betrieb/README.md
|
|
||||||
SENTRY_LARAVEL_DSN=
|
|
||||||
SENTRY_BROWSER_DSN=
|
|
||||||
SENTRY_ENVIRONMENT=production # Vorschau: preview, lokal: local
|
|
||||||
SENTRY_RELEASE=
|
|
||||||
|
|
||||||
# --- Betriebs-Alerts (Mail und ntfy-Push)
|
|
||||||
MONITORING_ALERTS_ENABLED=true
|
|
||||||
MONITORING_ALERT_EMAIL=
|
|
||||||
MONITORING_NTFY_URL=https://push.adametz.media
|
|
||||||
MONITORING_NTFY_TOPIC=b2in-ops
|
|
||||||
MONITORING_NTFY_TOKEN=
|
|
||||||
MONITORING_NTFY_PRIORITY=4
|
|
||||||
|
|
||||||
# --- Tiefer Health-Check /health (Bearer-Token für Uptime Kuma) – je Umgebung ein eigener Wert
|
|
||||||
MONITORING_HEALTH_TOKEN=
|
|
||||||
MONITORING_QUEUE_BACKLOG_SECONDS=300
|
|
||||||
MONITORING_DISK_MIN_FREE_PERCENT=10
|
|
||||||
|
|
||||||
# --- Nur lokal: Lesezugriff auf die GlitchTip-API (project:read, event:read) – nie committen
|
|
||||||
GLITCHTIP_API_TOKEN=
|
|
||||||
|
|
|
||||||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -1,17 +1,12 @@
|
||||||
# Laravel
|
# Laravel
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/node_modules
|
/node_modules
|
||||||
# public/build ist versioniert (Git-Deploy: auf dem Server gibt es kein Node). Nur die
|
/public/build
|
||||||
# Ausgabe des alten Standard-Builds (`npm run build`) liegt direkt darunter und wird nicht gebraucht.
|
|
||||||
/public/build/assets/
|
|
||||||
/public/build/manifest.json
|
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/public/vendor
|
/public/vendor
|
||||||
/public/flux
|
/public/flux
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
# schreibt dev/betrieb/deploy.sh beim Deploy (Version des ausgelieferten Tags)
|
|
||||||
/RELEASE
|
|
||||||
/storage/app
|
/storage/app
|
||||||
/storage/framework
|
/storage/framework
|
||||||
/storage/language
|
/storage/language
|
||||||
|
|
@ -55,31 +50,11 @@ yarn-error.log
|
||||||
Network Trash Folder
|
Network Trash Folder
|
||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
# macOS-Ordnersymbol heißt „Icon\r“ – ohne [\r] träfe das Muster bei core.ignorecase auch resources/views/flux/icon/
|
Icon
|
||||||
Icon[\r]
|
|
||||||
|
|
||||||
|
|
||||||
# Project specific
|
# Project specific
|
||||||
_static/
|
_static/
|
||||||
_work/
|
_work/
|
||||||
_storage/
|
_storage/
|
||||||
dev/immobilien 07-05-2026/AZIZI SharePoint
|
dev/immobilien 07-05-2026/AZIZI SharePoint
|
||||||
# Deployment-Archive werden bei Bedarf neu erzeugt
|
|
||||||
dev/**/deploy/*.tar.gz
|
|
||||||
|
|
||||||
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
|
|
||||||
/_b2in_interior_shop/
|
|
||||||
/docs/
|
|
||||||
|
|
||||||
# Original-Bildzulieferungen (mehrere MB je Datei) bleiben ausserhalb der Versionierung;
|
|
||||||
# die optimierten WebP-Fassungen liegen unter public/img/assets/marken/.
|
|
||||||
/dev/2026-08-28/images/
|
|
||||||
|
|
||||||
# Display-Player (public/_cabinet): Laufzeitdateien und Dateien, die nur auf dem Server liegen.
|
|
||||||
# Sie dürfen beim Git-Deploy weder überschrieben noch als Abweichung gemeldet werden.
|
|
||||||
/public/_cabinet/clicks.log
|
|
||||||
/public/_cabinet/logs/*.log
|
|
||||||
/public/_cabinet/offers/player.html
|
|
||||||
/public/_cabinet/offers/logo-cabinet-300.png
|
|
||||||
# Statisch veröffentlichte Flux-Skripte (alter Pfad), nur auf dem Server
|
|
||||||
/public/_flux/
|
|
||||||
|
|
@ -128,7 +128,7 @@ Domains are configured in `config/domains.php` with environment variables:
|
||||||
|
|
||||||
Domain-specific configurations include:
|
Domain-specific configurations include:
|
||||||
- **portal.b2in.test**: Admin backend with Flux UI theme
|
- **portal.b2in.test**: Admin backend with Flux UI theme
|
||||||
- **b2in.test**: two color worlds on one theme – Interior (default: Tanne `#202c2b`, Creme `#f7f4ef`, Sand `#d9cbbf`, Ocker `#d39e46`, Clay `#a38f82`, Anthrazit `#2d2926`; Inter/EB Garamond) and Immobilien (`body.world-immobilien`: Anthracite/Dynamic Blue, Inter/IBM Plex Sans). The world comes from `b2in_world()` (app/helpers.php), the logo per world from `ThemeHelper::getLogoPath()`
|
- **b2in.test**: Anthracite/Dynamic Blue color scheme, Inter/IBM Plex Sans fonts
|
||||||
- **b2a.test**: Azur Blue/Liberty Red color scheme, Inter/Merriweather fonts
|
- **b2a.test**: Azur Blue/Liberty Red color scheme, Inter/Merriweather fonts
|
||||||
- **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts
|
- **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts
|
||||||
- **style2own.test**: Imperial Blue/Sand Gold color scheme, Inter/EB Garamond fonts
|
- **style2own.test**: Imperial Blue/Sand Gold color scheme, Inter/EB Garamond fonts
|
||||||
|
|
@ -142,11 +142,6 @@ For local development, add domains to your hosts file:
|
||||||
127.0.0.1 style2own.test
|
127.0.0.1 style2own.test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment & Monitoring
|
|
||||||
- Deploys run via Git tags only: `dev/betrieb/deploy.sh vX.Y.Z` on the server; remotes: `origin` (forge.adametz.media, deploy source), `NAS` (backup only). Runbook: `dev/betrieb/README.md`.
|
|
||||||
- `public/build` is versioned: build locally (`npm run build:web && npm run build:portal`) and commit before tagging.
|
|
||||||
- Errors go to GlitchTip (`errors.adametz.media`, org `adametz-media`, projects `server-b2in` = 8, `browser-b2in` = 7); read-only API token as `GLITCHTIP_API_TOKEN` in `.env`. `/health` (token) for Uptime Kuma, alerts via ntfy topic `b2in-ops`.
|
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide
|
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide
|
||||||
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide
|
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide
|
||||||
|
|
@ -165,7 +160,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.4.26
|
- php - 8.4.18
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Services\Monitoring\HealthProbe;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minütlicher Herzschlag des Schedulers. Bleibt er aus,
|
|
||||||
* melden `/health` und der Systemstatus den Scheduler als ausgefallen —
|
|
||||||
* `onFailure` der einzelnen Tasks greift in diesem Fall nicht mehr.
|
|
||||||
*/
|
|
||||||
class MonitoringHeartbeat extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'monitoring:heartbeat';
|
|
||||||
|
|
||||||
protected $description = 'Schreibt den Herzschlag des Schedulers (Grundlage für /health und den Systemstatus).';
|
|
||||||
|
|
||||||
public function handle(HealthProbe $probe): int
|
|
||||||
{
|
|
||||||
$probe->recordHeartbeat();
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Jobs\Monitoring\QueueProbe;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Sleep;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft, ob ein Queue-Worker läuft: stellt einen Test-Job ein und wartet,
|
|
||||||
* bis er abgeholt ist (Aufruf am Ende von dev/betrieb/deploy.sh).
|
|
||||||
*
|
|
||||||
* Hintergrund: `queue:restart` beendet den Worker, neu starten muss ihn
|
|
||||||
* Supervisor. Läuft Supervisor nicht, bleibt die Queue still stehen.
|
|
||||||
*/
|
|
||||||
class MonitoringQueueProbe extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'monitoring:queue-probe
|
|
||||||
{--wait=60 : Sekunden, die auf den Worker gewartet wird}';
|
|
||||||
|
|
||||||
protected $description = 'Prüft mit einem Test-Job, ob ein Queue-Worker läuft.';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$token = Str::random(16);
|
|
||||||
$wait = max(0, (int) $this->option('wait'));
|
|
||||||
|
|
||||||
QueueProbe::dispatch($token);
|
|
||||||
|
|
||||||
for ($second = 0; $second <= $wait; $second++) {
|
|
||||||
if (Cache::has(QueueProbe::cacheKey($token))) {
|
|
||||||
Cache::forget(QueueProbe::cacheKey($token));
|
|
||||||
$this->info(sprintf('Queue-Worker läuft (Test-Job nach %d s abgeholt).', $second));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($second < $wait) {
|
|
||||||
Sleep::for(1)->second();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->error(sprintf('Kein Queue-Worker hat den Test-Job innerhalb von %d s abgeholt.', $wait));
|
|
||||||
$this->line('Worker prüfen: läuft Supervisor (bzw. der Worker im Server-Panel)? Notfalls: nohup php artisan queue:work --tries=3 --max-time=3600 &');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,20 +5,11 @@ namespace App\Helpers;
|
||||||
class ThemeHelper
|
class ThemeHelper
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Logo-Pfad für das aktuelle Theme.
|
* Get the logo path based on the current theme
|
||||||
*
|
|
||||||
* Für b2in hängt die Datei zusätzlich an der Farbwelt: die Interior-Welt trägt das Logo
|
|
||||||
* mit dem Zusatz „Interior“ und ockerfarbenem Bogen, die Immobilien-Welt das Logo ohne
|
|
||||||
* Zusatz mit blauem Bogen. Ohne Angabe wird die Welt aus der aktuellen Route gelesen.
|
|
||||||
*/
|
*/
|
||||||
public static function getLogoPath(string $type = 'positive', ?string $world = null): string
|
public static function getLogoPath(string $type = 'positive'): string
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'b2in');
|
$theme = config('app.theme', 'b2in');
|
||||||
|
|
||||||
if ($theme === 'b2in' && ($world ?? b2in_world()) === 'immobilien') {
|
|
||||||
return self::getLogoPathForBrand('b2in-immobilien', $type);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::getLogoPathForBrand($theme, $type);
|
return self::getLogoPathForBrand($theme, $type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,25 +22,21 @@ class ThemeHelper
|
||||||
|
|
||||||
$logoMap = [
|
$logoMap = [
|
||||||
'b2in' => [
|
'b2in' => [
|
||||||
'positive' => 'img/logos/b2in-interior-logo-positive.svg',
|
|
||||||
'negative' => 'img/logos/b2in-interior-logo-negative.svg',
|
|
||||||
],
|
|
||||||
'b2in-immobilien' => [
|
|
||||||
'positive' => 'img/logos/b2in-logo-positive.svg',
|
'positive' => 'img/logos/b2in-logo-positive.svg',
|
||||||
'negative' => 'img/logos/b2in-logo-negative.svg',
|
'negative' => 'img/logos/b2in-logo-negative.svg'
|
||||||
],
|
],
|
||||||
'b2a' => [
|
'b2a' => [
|
||||||
'positive' => 'img/logos/b2a-logo-positiv.svg',
|
'positive' => 'img/logos/b2a-logo-positiv.svg',
|
||||||
'negative' => 'img/logos/b2a-logo-negativ.svg',
|
'negative' => 'img/logos/b2a-logo-negativ.svg'
|
||||||
],
|
],
|
||||||
'stileigentum' => [
|
'stileigentum' => [
|
||||||
'positive' => 'img/logos/stileigentum-logo-positiv.svg',
|
'positive' => 'img/logos/stileigentum-logo-positiv.svg',
|
||||||
'negative' => 'img/logos/stileigentum-logo-negativ.svg',
|
'negative' => 'img/logos/stileigentum-logo-negativ.svg'
|
||||||
],
|
],
|
||||||
'style2own' => [
|
'style2own' => [
|
||||||
'positive' => 'img/logos/style2own-logo-positiv.svg',
|
'positive' => 'img/logos/style2own-logo-positiv.svg',
|
||||||
'negative' => 'img/logos/style2own-logo-negativ.svg',
|
'negative' => 'img/logos/style2own-logo-negativ.svg'
|
||||||
],
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
return $logoMap[$brand][$type] ?? $logoMap['b2in'][$type];
|
return $logoMap[$brand][$type] ?? $logoMap['b2in'][$type];
|
||||||
|
|
@ -61,7 +48,6 @@ class ThemeHelper
|
||||||
public static function getFaviconPath(): string
|
public static function getFaviconPath(): string
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'b2in');
|
$theme = config('app.theme', 'b2in');
|
||||||
|
|
||||||
return "img/favicons/{$theme}-favicon.ico";
|
return "img/favicons/{$theme}-favicon.ico";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,7 +57,6 @@ class ThemeHelper
|
||||||
public static function getThemeCssPath(): string
|
public static function getThemeCssPath(): string
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'b2in');
|
$theme = config('app.theme', 'b2in');
|
||||||
|
|
||||||
return "resources/css/web/theme-{$theme}.css";
|
return "resources/css/web/theme-{$theme}.css";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,7 +66,6 @@ class ThemeHelper
|
||||||
public static function getDomainConfig(): array
|
public static function getDomainConfig(): array
|
||||||
{
|
{
|
||||||
$theme = config('app.theme', 'b2in');
|
$theme = config('app.theme', 'b2in');
|
||||||
|
|
||||||
return config("domains.domains.{$theme}", []);
|
return config("domains.domains.{$theme}", []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +75,6 @@ class ThemeHelper
|
||||||
public static function getPrimaryColor(): string
|
public static function getPrimaryColor(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['color_scheme']['primary'] ?? '#2b3f51';
|
return $config['color_scheme']['primary'] ?? '#2b3f51';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +84,6 @@ class ThemeHelper
|
||||||
public static function getSecondaryColor(): string
|
public static function getSecondaryColor(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['color_scheme']['secondary'] ?? '#20a0da';
|
return $config['color_scheme']['secondary'] ?? '#20a0da';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,7 +93,6 @@ class ThemeHelper
|
||||||
public static function getPrimaryFont(): string
|
public static function getPrimaryFont(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['font_family']['primary'] ?? 'Inter';
|
return $config['font_family']['primary'] ?? 'Inter';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +102,6 @@ class ThemeHelper
|
||||||
public static function getSecondaryFont(): string
|
public static function getSecondaryFont(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['font_family']['secondary'] ?? 'IBM Plex Sans';
|
return $config['font_family']['secondary'] ?? 'IBM Plex Sans';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,14 +111,12 @@ class ThemeHelper
|
||||||
public static function getDomainName(): string
|
public static function getDomainName(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['domain_name'] ?? 'B2IN';
|
return $config['domain_name'] ?? 'B2IN';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getDomainUrl(): string
|
public static function getDomainUrl(): string
|
||||||
{
|
{
|
||||||
$config = self::getDomainConfig();
|
$config = self::getDomainConfig();
|
||||||
|
|
||||||
return $config['url'] ?? config('app.url');
|
return $config['url'] ?? config('app.url');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +127,7 @@ class ThemeHelper
|
||||||
{
|
{
|
||||||
$brand = $brand ?? config('app.theme', 'b2in');
|
$brand = $brand ?? config('app.theme', 'b2in');
|
||||||
$config = config("domains.domains.{$brand}", []);
|
$config = config("domains.domains.{$brand}", []);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'primary' => $config['color_scheme']['primary'] ?? '#2b3f51',
|
'primary' => $config['color_scheme']['primary'] ?? '#2b3f51',
|
||||||
'secondary' => $config['color_scheme']['secondary'] ?? '#20a0da',
|
'secondary' => $config['color_scheme']['secondary'] ?? '#20a0da',
|
||||||
|
|
@ -163,14 +141,14 @@ class ThemeHelper
|
||||||
public static function getBrandName(?string $brand = null): string
|
public static function getBrandName(?string $brand = null): string
|
||||||
{
|
{
|
||||||
$brand = $brand ?? config('app.theme', 'b2in');
|
$brand = $brand ?? config('app.theme', 'b2in');
|
||||||
|
|
||||||
$brandNames = [
|
$brandNames = [
|
||||||
'b2in' => 'B2IN',
|
'b2in' => 'B2IN',
|
||||||
'b2a' => 'B2A',
|
'b2a' => 'B2A',
|
||||||
'stileigentum' => 'StilEigentum',
|
'stileigentum' => 'StilEigentum',
|
||||||
'style2own' => 'Style2Own',
|
'style2own' => 'Style2Own',
|
||||||
];
|
];
|
||||||
|
|
||||||
return $brandNames[$brand] ?? strtoupper($brand);
|
return $brandNames[$brand] ?? strtoupper($brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,7 +158,6 @@ class ThemeHelper
|
||||||
public static function getBrandConfig(?string $brand = null): array
|
public static function getBrandConfig(?string $brand = null): array
|
||||||
{
|
{
|
||||||
$brand = $brand ?? config('app.theme', 'b2in');
|
$brand = $brand ?? config('app.theme', 'b2in');
|
||||||
|
|
||||||
return config("domains.domains.{$brand}", []);
|
return config("domains.domains.{$brand}", []);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Display;
|
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Öffentliche, schreibgeschützte Einbettung eines Displays auf der Website.
|
|
||||||
*
|
|
||||||
* Bewusst getrennt von {@see \App\Http\Controllers\Api\DisplayPreviewController}: Die
|
|
||||||
* Preview-Routen arbeiten mit `preview_token` und zeigen den **Entwurf**. Nach außen darf weder
|
|
||||||
* ein Token noch ein unveröffentlichter Stand gelangen. Diese Route kennt nur die numerische ID
|
|
||||||
* und liefert ausschließlich veröffentlichte Playlists aktiver Displays.
|
|
||||||
*/
|
|
||||||
class DisplayEmbedController extends Controller
|
|
||||||
{
|
|
||||||
public function show(Display $display): BinaryFileResponse
|
|
||||||
{
|
|
||||||
abort_unless($display->is_active, 404);
|
|
||||||
|
|
||||||
$playlist = $display->livePlaylist()->withCount('modules')->first();
|
|
||||||
|
|
||||||
abort_if($playlist === null || $playlist->modules_count === 0, 404);
|
|
||||||
|
|
||||||
// Der Player liest die ID aus dem Pfad (/display/{id}) und holt sich die Konfiguration
|
|
||||||
// über die öffentliche Route /api/display/{display}/config – dieselbe Quelle wie das
|
|
||||||
// physische Display. Keine doppelte Pflege.
|
|
||||||
return response()->file(public_path('_cabinet/display/index.html'), [
|
|
||||||
'Cache-Control' => 'no-cache, must-revalidate',
|
|
||||||
'X-Robots-Tag' => 'noindex',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Services\Monitoring\HealthProbe;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tiefer Health-Check für den externen Monitor.
|
|
||||||
*
|
|
||||||
* `/up` zeigt nur, dass die App bootet. `/health` prüft zusätzlich DB,
|
|
||||||
* Cache, Queue-Rückstau, Scheduler-Herzschlag und Plattenplatz: 200 solange
|
|
||||||
* nichts kritisch ist, sonst 503. Zugang nur mit MONITORING_HEALTH_TOKEN
|
|
||||||
* (Bearer oder `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht.
|
|
||||||
*/
|
|
||||||
class HealthController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request, HealthProbe $probe): JsonResponse
|
|
||||||
{
|
|
||||||
$token = (string) config('monitoring.health.token');
|
|
||||||
$given = (string) ($request->bearerToken() ?? $request->query('token', ''));
|
|
||||||
|
|
||||||
abort_if($token === '' || ! hash_equals($token, $given), 404);
|
|
||||||
|
|
||||||
$checks = $probe->checks();
|
|
||||||
$status = HealthProbe::worstStatus($checks);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'status' => $status,
|
|
||||||
'version' => config('app.version'),
|
|
||||||
'checked_at' => now()->toIso8601String(),
|
|
||||||
'checks' => collect($checks)->mapWithKeys(fn (array $check): array => [
|
|
||||||
$check['key'] => ['status' => $check['status'], 'detail' => $check['detail']],
|
|
||||||
]),
|
|
||||||
], $status === HealthProbe::StatusCritical ? 503 : 200)
|
|
||||||
->header('Cache-Control', 'no-store');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -32,12 +32,6 @@ class BasicAuthMiddleware
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip Basic Auth für den Health-Check: Uptime Kuma schickt seinen eigenen
|
|
||||||
// Authorization-Header (Bearer-Token), /health prüft ihn selbst.
|
|
||||||
if ($path === 'health' || $path === 'up') {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
||||||
if (str_starts_with($path, 'flux/')) {
|
if (str_starts_with($path, 'flux/')) {
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use App\Support\Monitoring\ErrorReportScrubber;
|
|
||||||
use App\Support\Monitoring\ErrorSignal;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
use function Illuminate\Support\defer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
|
|
||||||
* obwohl keine Exception gemeldet wird:
|
|
||||||
*
|
|
||||||
* - 404 mit Referrer der eigenen Domain — irgendwo bei uns steht ein toter Link
|
|
||||||
* - 403/429 für angemeldete Nutzer — die Oberfläche führt sie dorthin
|
|
||||||
* - 419 — Formular oder Livewire-Komponente abgelaufen, Eingaben sind verloren
|
|
||||||
*
|
|
||||||
* Gesendet wird nach der Antwort (`defer()`), der Nutzer wartet nicht auf GlitchTip.
|
|
||||||
*/
|
|
||||||
class ReportErrorSignals
|
|
||||||
{
|
|
||||||
public function __construct(private readonly ErrorSignal $errorSignal) {}
|
|
||||||
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$response = $next($request);
|
|
||||||
|
|
||||||
$signal = $this->signalFor($request, $response->getStatusCode());
|
|
||||||
|
|
||||||
if ($signal !== null) {
|
|
||||||
$location = $this->locationOf($request);
|
|
||||||
$context = [
|
|
||||||
'status' => $response->getStatusCode(),
|
|
||||||
'method' => $request->method(),
|
|
||||||
'path' => '/'.ltrim($request->path(), '/'),
|
|
||||||
'referer' => $this->refererPath($request),
|
|
||||||
'livewire' => $request->hasHeader('X-Livewire'),
|
|
||||||
];
|
|
||||||
|
|
||||||
defer(fn () => $this->errorSignal->report($signal, $location, $context));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function signalFor(Request $request, int $status): ?string
|
|
||||||
{
|
|
||||||
return match (true) {
|
|
||||||
$status === 404 && $this->hasInternalReferer($request) => ErrorSignal::DeadInternalLink,
|
|
||||||
$status === 403 && $request->user() !== null => ErrorSignal::AccessDenied,
|
|
||||||
$status === 419 => ErrorSignal::PageExpired,
|
|
||||||
$status === 429 && $request->user() !== null => ErrorSignal::Throttled,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Livewire-Requests laufen alle über denselben Update-Endpunkt — der Ort
|
|
||||||
* ist dort die Seite, auf der der Nutzer stand.
|
|
||||||
*/
|
|
||||||
private function locationOf(Request $request): string
|
|
||||||
{
|
|
||||||
if ($request->hasHeader('X-Livewire')) {
|
|
||||||
return 'livewire '.($this->refererPath($request) ?? '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
$routeName = $request->route()?->getName();
|
|
||||||
|
|
||||||
return $routeName !== null
|
|
||||||
? $routeName
|
|
||||||
: '/'.ltrim($request->path(), '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasInternalReferer(Request $request): bool
|
|
||||||
{
|
|
||||||
$referer = $request->headers->get('referer');
|
|
||||||
|
|
||||||
return is_string($referer) && parse_url($referer, PHP_URL_HOST) === $request->getHost();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function refererPath(Request $request): ?string
|
|
||||||
{
|
|
||||||
$referer = $request->headers->get('referer');
|
|
||||||
|
|
||||||
if (! is_string($referer) || $referer === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = parse_url($referer, PHP_URL_PATH);
|
|
||||||
|
|
||||||
return is_string($path) ? ErrorReportScrubber::scrubPath($path) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs\Monitoring;
|
|
||||||
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test-Job für `monitoring:queue-probe`: tut nichts außer zu quittieren,
|
|
||||||
* dass ein Worker ihn abgeholt hat. Läuft auf der Standard-Queue —
|
|
||||||
* der Worker muss sie bedienen.
|
|
||||||
*/
|
|
||||||
class QueueProbe implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public int $tries = 1;
|
|
||||||
|
|
||||||
public function __construct(public string $token) {}
|
|
||||||
|
|
||||||
public static function cacheKey(string $token): string
|
|
||||||
{
|
|
||||||
return 'monitoring:queue-probe:'.$token;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
Cache::put(self::cacheKey($this->token), now()->toIso8601String(), now()->addMinutes(10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -98,35 +98,13 @@ class CabinetInfoTablet extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeit-Properties, die beim Speichern aus dem DOM uebernommen werden duerfen.
|
* @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
|
||||||
*
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function hourProperties(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'hoursMondayOpen', 'hoursMondayClose',
|
|
||||||
'hoursTuesdayOpen', 'hoursTuesdayClose',
|
|
||||||
'hoursWednesdayOpen', 'hoursWednesdayClose',
|
|
||||||
'hoursThursdayOpen', 'hoursThursdayClose',
|
|
||||||
'hoursFridayOpen', 'hoursFridayClose',
|
|
||||||
'hoursSaturdayOpen', 'hoursSaturdayClose',
|
|
||||||
'hoursSundayOpen', 'hoursSundayClose',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Der Flux-Zeit-Picker meldet das Leeren per X nicht zuverlaessig an wire:model.
|
|
||||||
* Deshalb liest das Formular die Werte beim Absenden aus dem DOM und uebergibt sie hier.
|
|
||||||
* Nur die Zeitfelder duerfen so gesetzt werden – alles andere kommt aus wire:model.
|
|
||||||
*
|
|
||||||
* @param array<string, string|null> $hours Zeit-Picker-Werte aus dem DOM
|
|
||||||
*/
|
*/
|
||||||
public function save(array $hours = []): void
|
public function save(array $hours = []): void
|
||||||
{
|
{
|
||||||
foreach ($this->hourProperties() as $prop) {
|
foreach ($hours as $prop => $value) {
|
||||||
if (array_key_exists($prop, $hours)) {
|
if (property_exists($this, $prop)) {
|
||||||
$this->{$prop} = is_string($hours[$prop]) ? $hours[$prop] : '';
|
$this->{$prop} = $value ?? '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,7 @@ class DisplayVersionEditor extends Component
|
||||||
|
|
||||||
public string $mediaSubline = '';
|
public string $mediaSubline = '';
|
||||||
|
|
||||||
/** Anzeigedauer eines Bildes in Sekunden als Eingabetext, Komma oder Punkt erlaubt („1,5“). */
|
public int $mediaDuration = 10;
|
||||||
public string $mediaDuration = '10';
|
|
||||||
|
|
||||||
public bool $mediaIsActive = true;
|
public bool $mediaIsActive = true;
|
||||||
|
|
||||||
|
|
@ -248,12 +247,6 @@ class DisplayVersionEditor extends Component
|
||||||
|
|
||||||
public function saveItem(): void
|
public function saveItem(): void
|
||||||
{
|
{
|
||||||
if ($this->itemType === 'media' && $this->mediaType === 'image' && self::parseDuration($this->mediaDuration) === null) {
|
|
||||||
$this->addError('mediaDuration', 'Bitte eine Dauer zwischen 0,5 und 120 Sekunden angeben, z. B. 1,5.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$content = $this->buildItemContent();
|
$content = $this->buildItemContent();
|
||||||
$isActive = $this->getActiveFlag();
|
$isActive = $this->getActiveFlag();
|
||||||
|
|
||||||
|
|
@ -286,41 +279,6 @@ class DisplayVersionEditor extends Component
|
||||||
$this->refreshModulePreview();
|
$this->refreshModulePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sekunden aus der Eingabe lesen: „1,5“, „1.5“ und „10“ sind gültig, gerundet auf Zehntel.
|
|
||||||
* Erlaubt sind 0,5 bis 120 Sekunden; alles andere ergibt null.
|
|
||||||
*/
|
|
||||||
public static function parseDuration(string|int|float $input): ?float
|
|
||||||
{
|
|
||||||
$normalized = str_replace(',', '.', trim((string) $input));
|
|
||||||
|
|
||||||
if ($normalized === '' || ! is_numeric($normalized)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$seconds = round((float) $normalized, 1);
|
|
||||||
|
|
||||||
if ($seconds < 0.5 || $seconds > 120) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sekunden für die Anzeige: ganze Zahlen ohne Nachkommastelle, sonst mit Komma („1,5“).
|
|
||||||
*/
|
|
||||||
public static function formatDuration(int|float|string|null $seconds): string
|
|
||||||
{
|
|
||||||
$seconds = (float) str_replace(',', '.', (string) ($seconds ?? 10));
|
|
||||||
|
|
||||||
if (floor($seconds) === $seconds) {
|
|
||||||
return (string) (int) $seconds;
|
|
||||||
}
|
|
||||||
|
|
||||||
return str_replace('.', ',', (string) round($seconds, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteItem(int $id): void
|
public function deleteItem(int $id): void
|
||||||
{
|
{
|
||||||
DisplayVersionItem::findOrFail($id)->delete();
|
DisplayVersionItem::findOrFail($id)->delete();
|
||||||
|
|
@ -479,7 +437,7 @@ class DisplayVersionEditor extends Component
|
||||||
$this->mediaUrl = $content['media_url'] ?? '';
|
$this->mediaUrl = $content['media_url'] ?? '';
|
||||||
$this->mediaHeadline = $content['headline'] ?? '';
|
$this->mediaHeadline = $content['headline'] ?? '';
|
||||||
$this->mediaSubline = $content['subline'] ?? '';
|
$this->mediaSubline = $content['subline'] ?? '';
|
||||||
$this->mediaDuration = self::formatDuration($content['duration_seconds'] ?? 10);
|
$this->mediaDuration = $content['duration_seconds'] ?? 10;
|
||||||
$this->mediaIsActive = $isActive;
|
$this->mediaIsActive = $isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -543,7 +501,7 @@ class DisplayVersionEditor extends Component
|
||||||
'media_url' => $this->mediaUrl,
|
'media_url' => $this->mediaUrl,
|
||||||
'headline' => $this->mediaHeadline,
|
'headline' => $this->mediaHeadline,
|
||||||
'subline' => $this->mediaSubline,
|
'subline' => $this->mediaSubline,
|
||||||
'duration_seconds' => self::parseDuration($this->mediaDuration) ?? 10,
|
'duration_seconds' => $this->mediaDuration,
|
||||||
],
|
],
|
||||||
'slide' => [
|
'slide' => [
|
||||||
'type' => 'detail',
|
'type' => 'detail',
|
||||||
|
|
@ -705,7 +663,7 @@ class DisplayVersionEditor extends Component
|
||||||
$this->mediaUrl = '';
|
$this->mediaUrl = '';
|
||||||
$this->mediaHeadline = '';
|
$this->mediaHeadline = '';
|
||||||
$this->mediaSubline = '';
|
$this->mediaSubline = '';
|
||||||
$this->mediaDuration = '10';
|
$this->mediaDuration = 10;
|
||||||
$this->mediaIsActive = true;
|
$this->mediaIsActive = true;
|
||||||
$this->slideShowLogo = true;
|
$this->slideShowLogo = true;
|
||||||
$this->slideLogoUrl = '';
|
$this->slideLogoUrl = '';
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,9 @@ class AboutHero extends Component
|
||||||
{
|
{
|
||||||
public $content = [];
|
public $content = [];
|
||||||
|
|
||||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_about_hero). */
|
public function mount()
|
||||||
public string $section = 'about_hero';
|
|
||||||
|
|
||||||
public function mount(string $section = 'about_hero')
|
|
||||||
{
|
{
|
||||||
$this->section = $section;
|
$this->content = cms_theme_section('about_hero');
|
||||||
$this->content = cms_theme_section($this->section);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class AudienceSwitch extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('audience_switch');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.audience-switch');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class BrandDetail extends Component
|
|
||||||
{
|
|
||||||
public string $slug = '';
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $brand = [];
|
|
||||||
|
|
||||||
/** @var array<int, array<string, mixed>> */
|
|
||||||
public array $otherBrands = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Galerie getrennt nach realisierten Projekten und Inspirationen.
|
|
||||||
*
|
|
||||||
* @var array{projects: array<int, array<string, mixed>>, inspirations: array<int, array<string, mixed>>}
|
|
||||||
*/
|
|
||||||
public array $gallery = ['projects' => [], 'inspirations' => []];
|
|
||||||
|
|
||||||
public function mount(string $slug): void
|
|
||||||
{
|
|
||||||
$brands = CmsBrand::forFrontend();
|
|
||||||
|
|
||||||
abort_unless(isset($brands[$slug]), 404);
|
|
||||||
|
|
||||||
$this->slug = $slug;
|
|
||||||
$this->brand = $brands[$slug];
|
|
||||||
$this->gallery = CmsBrand::groupGallery($this->brand['gallery'] ?? []);
|
|
||||||
$this->otherBrands = collect($brands)
|
|
||||||
->reject(fn (array $brand, string $key) => $key === $slug)
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$craft = $this->craftSection($slug, $brands);
|
|
||||||
|
|
||||||
if ($craft !== null) {
|
|
||||||
$this->brand['sections'][] = $craft;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gemeinsamer Handwerk-Abschluss (Lang-Sektion `brand_craft`, Nachtrag 15): steht als letzter
|
|
||||||
* Abschnitt auf jeder Markenseite – außer auf der Seite der Handwerksmarke selbst. Der Link
|
|
||||||
* dorthin erscheint nur, wenn diese Marke veröffentlicht ist und eine Detailseite hat.
|
|
||||||
*
|
|
||||||
* @param array<string, array<string, mixed>> $brands
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private function craftSection(string $slug, array $brands): ?array
|
|
||||||
{
|
|
||||||
$content = cms_theme_section('brand_craft');
|
|
||||||
|
|
||||||
if (! is_array($content)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$craftSlug = (string) ($content['brand_slug'] ?? '');
|
|
||||||
|
|
||||||
if ($craftSlug !== '' && $craftSlug === $slug) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sections = CmsBrand::normalizeSections([$content]);
|
|
||||||
|
|
||||||
if ($sections === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$section = $sections[0];
|
|
||||||
$section['anchor'] = 'handwerk';
|
|
||||||
|
|
||||||
if ($craftSlug !== '' && ! empty($brands[$craftSlug]['has_detail_page'])) {
|
|
||||||
$section['link'] = route('marken.show', ['slug' => $craftSlug]);
|
|
||||||
$section['link_text'] = (string) ($content['link_text'] ?? $brands[$craftSlug]['name'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $section;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.brand-detail');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class BrandOverview extends Component
|
|
||||||
{
|
|
||||||
/** @var array<int, array<string, mixed>> */
|
|
||||||
public array $brands = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$this->brands = array_values(CmsBrand::forFrontend());
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.brand-overview');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -12,13 +12,8 @@ class CTASection extends Component
|
||||||
|
|
||||||
public $section = '';
|
public $section = '';
|
||||||
|
|
||||||
public function mount($bg = 'cta-band', $section = 'cta_section')
|
public function mount($bg = 'bg-secondary', $section = 'cta_section')
|
||||||
{
|
{
|
||||||
// Der Standard-Abschluss hat je Welt eigene Texte und fuehrt zur Kontaktseite der Welt.
|
|
||||||
if ($section === 'cta_section' && b2in_world() === 'immobilien') {
|
|
||||||
$section = 'cta_section_immobilien';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->section = $section;
|
$this->section = $section;
|
||||||
$this->content = cms_theme_section($this->section);
|
$this->content = cms_theme_section($this->section);
|
||||||
$this->bg = $bg;
|
$this->bg = $bg;
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class CoreBrands extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
/** @var array<int, array<string, mixed>> */
|
|
||||||
public array $brands = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('core_brands');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->brands = array_values(CmsBrand::forFrontend());
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.core-brands');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use App\Models\Display;
|
|
||||||
use App\Models\DisplayPlaylist;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spiegelt ein Showroom-Display im Hochformat (9:16) auf der Website.
|
|
||||||
*
|
|
||||||
* Quelle ist dasselbe veröffentlichte Playlist wie beim physischen Display – Marcel pflegt
|
|
||||||
* weiterhin nur im Display-CMS, hier entsteht keine zweite Pflegestelle.
|
|
||||||
*/
|
|
||||||
class DisplayFeed extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public ?int $displayId = null;
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public bool $showLink = true;
|
|
||||||
|
|
||||||
public function mount(string $bg = '', bool $showLink = true): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('display_feed');
|
|
||||||
$content = is_array($section) ? $section : [];
|
|
||||||
|
|
||||||
$this->bg = $bg;
|
|
||||||
$this->showLink = $showLink && $this->linkTargetVisible($content);
|
|
||||||
$this->displayId = $this->resolveDisplayId($content);
|
|
||||||
|
|
||||||
// Ohne Display bleiben auch die Texte leer – sie hätten sonst nichts zu beschriften und
|
|
||||||
// stünden trotzdem im Livewire-Snapshot der Seite.
|
|
||||||
$this->content = $this->displayId === null ? [] : $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Im CMS hinterlegtes Display, sonst das erste aktive mit veröffentlichter Playlist.
|
|
||||||
*
|
|
||||||
* Ohne passendes Display rendert die Sektion nichts – lieber keine Sektion als ein
|
|
||||||
* Rahmen, in dem sich ewig ein Ladebalken dreht.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $content
|
|
||||||
*/
|
|
||||||
private function resolveDisplayId(array $content): ?int
|
|
||||||
{
|
|
||||||
$configured = $content['display_id'] ?? null;
|
|
||||||
|
|
||||||
$query = Display::query()
|
|
||||||
->where('is_active', true)
|
|
||||||
->whereHas('playlists', fn ($q) => $q->where('status', DisplayPlaylist::STATUS_PUBLISHED)
|
|
||||||
->whereHas('modules'));
|
|
||||||
|
|
||||||
if (is_numeric($configured) && (int) $configured > 0) {
|
|
||||||
$display = (clone $query)->whereKey((int) $configured)->first();
|
|
||||||
|
|
||||||
if ($display !== null) {
|
|
||||||
return $display->id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->orderBy('id')->value('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.display-feed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Der Button führt standardmäßig auf /referenzen – ist die Seite im CMS ausgeblendet, entfällt er.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $content
|
|
||||||
*/
|
|
||||||
private function linkTargetVisible(array $content): bool
|
|
||||||
{
|
|
||||||
$path = rtrim(parse_url((string) ($content['link_url'] ?? '/referenzen'), PHP_URL_PATH) ?? '', '/');
|
|
||||||
|
|
||||||
return $path !== '/referenzen' || references_page_enabled();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,12 +8,8 @@ class FounderBar extends Component
|
||||||
{
|
{
|
||||||
public array $content = [];
|
public array $content = [];
|
||||||
|
|
||||||
/** Hintergrundklasse der Sektion; unter den Interior-Heroes (Startseite, Marken, Über uns) `bg-accent`. */
|
public function mount(): void
|
||||||
public string $bg = 'bg-background';
|
|
||||||
|
|
||||||
public function mount(string $bg = 'bg-background'): void
|
|
||||||
{
|
{
|
||||||
$this->bg = $bg;
|
|
||||||
$this->content = cms_theme_section('founder_bar');
|
$this->content = cms_theme_section('founder_bar');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,86 +4,16 @@ namespace App\Livewire\Web\Components\Sections;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/**
|
|
||||||
* Hero der Startseite: Text links, rechts eine ruhige Slideshow als Hauptelement des Rahmens
|
|
||||||
* (Abstimmung 07.10.2026, Vorbild dev/2026-08-28/Farbwelt und Stil.png).
|
|
||||||
*
|
|
||||||
* Die Bilder kommen aus der Liste `slides` der Sektion `home/hero` (je `image`, `label`, `caption`,
|
|
||||||
* `alt`, `active`) und laufen in dieser Reihenfolge. Die Infokarte über dem Bild zeigt je Slide
|
|
||||||
* Marke (`label`) und Motiv (`caption`), ohne Angaben die festen Texte `card_title`/`card_text`.
|
|
||||||
* Ist die Liste leer oder alles deaktiviert, steht das einzelne Bild `image` da wie früher.
|
|
||||||
*/
|
|
||||||
class Hero extends Component
|
class Hero extends Component
|
||||||
{
|
{
|
||||||
/** @var array<string, mixed> */
|
public $content = [];
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
/** @var array<int, array{image: string, alt: string, label: string, caption: string}> */
|
public function mount()
|
||||||
public array $slides = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
{
|
||||||
$section = cms_theme_section('hero');
|
$this->content = cms_theme_section('hero');
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->slides = self::activeSlides($this->content['slides'] ?? []) ?: $this->singleImage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function render()
|
||||||
* Gepflegte Slides ohne Bild oder mit `active` = aus fallen weg.
|
|
||||||
*
|
|
||||||
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
|
|
||||||
*/
|
|
||||||
public static function activeSlides(mixed $list): array
|
|
||||||
{
|
|
||||||
$slides = [];
|
|
||||||
|
|
||||||
foreach ((array) $list as $slide) {
|
|
||||||
if (! is_array($slide) || trim((string) ($slide['image'] ?? '')) === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('active', $slide) && ! filter_var($slide['active'], FILTER_VALIDATE_BOOLEAN)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = trim((string) ($slide['label'] ?? $slide['title'] ?? ''));
|
|
||||||
$caption = trim((string) ($slide['caption'] ?? ''));
|
|
||||||
|
|
||||||
$slides[] = [
|
|
||||||
'image' => (string) $slide['image'],
|
|
||||||
'alt' => trim((string) ($slide['alt'] ?? '')) ?: implode(' – ', array_filter([$label, $caption])),
|
|
||||||
'label' => $label,
|
|
||||||
'caption' => $caption,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $slides;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
|
|
||||||
*/
|
|
||||||
private function singleImage(): array
|
|
||||||
{
|
|
||||||
$image = trim((string) ($this->content['image'] ?? ''));
|
|
||||||
|
|
||||||
return $image === '' ? [] : [[
|
|
||||||
'image' => $image,
|
|
||||||
'alt' => (string) ($this->content['image_alt'] ?? ''),
|
|
||||||
'label' => '',
|
|
||||||
'caption' => '',
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wechsel alle `interval` Sekunden, begrenzt auf 3–30 s (Standard 7 s). */
|
|
||||||
public function getIntervalMsProperty(): int
|
|
||||||
{
|
|
||||||
$seconds = (float) ($this->content['interval'] ?? 7);
|
|
||||||
|
|
||||||
return (int) round(max(3, min(30, $seconds)) * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): \Illuminate\View\View
|
|
||||||
{
|
{
|
||||||
return view('livewire.web.components.sections.hero');
|
return view('livewire.web.components.sections.hero');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@ namespace App\Livewire\Web\Components\Sections;
|
||||||
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
/**
|
|
||||||
* Hero der Themes b2a, Stileigentum und Style2own (Stand vor dem B2in-Entwurf vom 24.09.2026).
|
|
||||||
* Ohne gepflegte Sektion `hero_slider` bleibt der Bereich leer.
|
|
||||||
*/
|
|
||||||
class HeroSlider extends Component
|
class HeroSlider extends Component
|
||||||
{
|
{
|
||||||
public $content = [];
|
public $content = [];
|
||||||
|
|
@ -16,8 +12,7 @@ class HeroSlider extends Component
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$section = cms_theme_section('hero_slider');
|
$this->content = cms_theme_section('hero_slider');
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function nextSlide()
|
public function nextSlide()
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class ImmobilienTeaser extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('immobilien_teaser');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.immobilien-teaser');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
namespace App\Livewire\Web\Components\Sections;
|
||||||
|
|
||||||
use App\Models\CmsArticle;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class MagazinDetail extends Component
|
class MagazinDetail extends Component
|
||||||
{
|
{
|
||||||
public $slug;
|
public $articleId;
|
||||||
|
|
||||||
public $article;
|
public $article;
|
||||||
|
|
||||||
|
|
@ -15,41 +14,25 @@ class MagazinDetail extends Component
|
||||||
|
|
||||||
public $content;
|
public $content;
|
||||||
|
|
||||||
/** Rueckweg zur Magazinliste der Welt, zu der der Beitrag gehoert. */
|
public function mount($id = 1)
|
||||||
public string $listUrl = '';
|
|
||||||
|
|
||||||
public function mount($slug)
|
|
||||||
{
|
{
|
||||||
$this->slug = (string) $slug;
|
$this->articleId = (int) $id;
|
||||||
$this->loadArticle();
|
$this->loadArticle();
|
||||||
$this->listUrl = $this->articleWorld() === CmsArticle::WORLD_IMMOBILIEN
|
|
||||||
? route('immobilien.magazin')
|
|
||||||
: route('magazin');
|
|
||||||
$this->loadRelatedArticles();
|
$this->loadRelatedArticles();
|
||||||
$this->loadThemeContent();
|
$this->loadThemeContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function articleWorld(): string
|
|
||||||
{
|
|
||||||
return (string) ($this->article['world'] ?? CmsArticle::WORLD_INTERIOR);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loadArticle()
|
private function loadArticle()
|
||||||
{
|
{
|
||||||
$articles = $this->getArticlesData();
|
$articles = $this->getArticlesData();
|
||||||
|
$this->article = $articles[$this->articleId] ?? $articles[1];
|
||||||
// Unbekannte Slugs haben frueher stillschweigend Artikel 1 ausgeliefert – mit Status 200.
|
|
||||||
// Das erzeugt Duplicate Content unter beliebig vielen URLs.
|
|
||||||
abort_unless(isset($articles[$this->slug]), 404);
|
|
||||||
|
|
||||||
$this->article = $articles[$this->slug];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadRelatedArticles()
|
private function loadRelatedArticles()
|
||||||
{
|
{
|
||||||
$this->relatedArticles = collect($this->getArticlesData())
|
$articles = $this->getArticlesData();
|
||||||
->filter(fn (array $article) => ($article['world'] ?? CmsArticle::WORLD_INTERIOR) === $this->articleWorld())
|
$this->relatedArticles = collect($articles)
|
||||||
->reject(fn (array $article, string $slug) => $slug === $this->slug)
|
->filter(fn ($article, $key) => $key != $this->articleId)
|
||||||
->take(2)
|
->take(2)
|
||||||
->values()
|
->values()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
@ -60,18 +43,9 @@ class MagazinDetail extends Component
|
||||||
$this->content = cms_theme_section('magazin_detail');
|
$this->content = cms_theme_section('magazin_detail');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var array<string, array<string, mixed>>|null */
|
private function getArticlesData()
|
||||||
private ?array $articlesCache = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wird beim Rendern zweimal gebraucht (Artikel + verwandte Artikel) – deshalb gemerkt,
|
|
||||||
* damit die Detailseite nur eine Abfrage auslöst.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function getArticlesData(): array
|
|
||||||
{
|
{
|
||||||
return $this->articlesCache ??= CmsArticle::forFrontend();
|
return trans('b2in.articles');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
namespace App\Livewire\Web\Components\Sections;
|
||||||
|
|
||||||
use App\Models\CmsArticle;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class MagazinList extends Component
|
class MagazinList extends Component
|
||||||
|
|
@ -11,41 +10,34 @@ class MagazinList extends Component
|
||||||
|
|
||||||
public $content = [];
|
public $content = [];
|
||||||
|
|
||||||
/** Farbwelt der Liste: `interior` (/magazin) oder `immobilien` (/immobilien/magazin). */
|
public function mount()
|
||||||
public string $world = CmsArticle::WORLD_INTERIOR;
|
|
||||||
|
|
||||||
public function mount(string $world = CmsArticle::WORLD_INTERIOR)
|
|
||||||
{
|
{
|
||||||
$this->world = in_array($world, CmsArticle::WORLDS, true) ? $world : CmsArticle::WORLD_INTERIOR;
|
|
||||||
$this->loadPosts();
|
$this->loadPosts();
|
||||||
$this->loadThemeContent();
|
$this->loadThemeContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadPosts()
|
private function loadPosts()
|
||||||
{
|
{
|
||||||
$this->posts = collect(CmsArticle::forFrontend($this->world))->map(function (array $article) {
|
$articles = trans('b2in.articles');
|
||||||
|
$this->posts = collect($articles)->map(function ($article) {
|
||||||
return [
|
return [
|
||||||
'slug' => $article['slug'],
|
'id' => $article['id'],
|
||||||
'title' => $article['title'],
|
'title' => $article['title'],
|
||||||
'excerpt' => $article['content']['intro'] ?? '',
|
'excerpt' => $article['content']['intro'], // Using intro as excerpt
|
||||||
'image' => $article['image'],
|
'image' => $article['image'],
|
||||||
'date' => $article['date'],
|
'date' => $article['date'],
|
||||||
'readTime' => $article['readTime'],
|
'readTime' => $article['readTime'],
|
||||||
];
|
];
|
||||||
})->values()->all();
|
})->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loadThemeContent()
|
private function loadThemeContent()
|
||||||
{
|
{
|
||||||
$this->content = cms_theme_section(
|
$this->content = cms_theme_section('magazin_list');
|
||||||
$this->world === CmsArticle::WORLD_IMMOBILIEN ? 'magazin_list_immobilien' : 'magazin_list'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
return view('livewire.web.components.sections.magazin-list', [
|
return view('livewire.web.components.sections.magazin-list');
|
||||||
'posts' => $this->posts,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class MarkenCta extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('marken_cta');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.marken-cta');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class MarkenGrowth extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('marken_growth');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.marken-growth');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class MarkenNetwork extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('marken_network');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.marken-network');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
|
|
||||||
* (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
|
|
||||||
*
|
|
||||||
* Überschrift, Karte, Drehscheibe und Ablauf-Block kommen aus dem CMS (Fallback Lang-Datei).
|
|
||||||
* Die Standorte kommen aus den veröffentlichten Marken: Ort, Entfernung und Rolle pflegt
|
|
||||||
* Marcel in der Markenverwaltung. Die Positionen in der schematischen Karte gehören zur
|
|
||||||
* Zeichnung und bleiben hier im Code.
|
|
||||||
*/
|
|
||||||
class OriginSection extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
|
|
||||||
*
|
|
||||||
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
|
|
||||||
*
|
|
||||||
* @var array<string, array{x: int, y: int, label: string}>
|
|
||||||
*/
|
|
||||||
public const MAP_POSITIONS = [
|
|
||||||
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
|
|
||||||
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
|
|
||||||
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
|
|
||||||
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('origin_section');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Veröffentlichte Marken mit gepflegtem Fertigungsort, in der Reihenfolge der Marken.
|
|
||||||
*
|
|
||||||
* Eine unveröffentlichte Marke fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
|
|
||||||
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
|
|
||||||
*
|
|
||||||
* @return list<array{slug: string, city: string, brand: string, role: string, distance: string, link: ?string, map: ?array{x: int, y: int, label: string}}>
|
|
||||||
*/
|
|
||||||
public function getLocationsProperty(): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
|
||||||
$city = trim((string) ($brand['origin_city'] ?? ''));
|
|
||||||
$name = trim((string) ($brand['name'] ?? ''));
|
|
||||||
|
|
||||||
if ($city === '' || $name === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'slug' => (string) $slug,
|
|
||||||
'city' => $city,
|
|
||||||
'brand' => $name,
|
|
||||||
'role' => trim((string) ($brand['origin_role'] ?? '')),
|
|
||||||
'distance' => trim((string) ($brand['origin_distance'] ?? '')),
|
|
||||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
|
||||||
'map' => self::MAP_POSITIONS[$slug] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.origin-section');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,13 +8,9 @@ class OurStory extends Component
|
||||||
{
|
{
|
||||||
public $content = [];
|
public $content = [];
|
||||||
|
|
||||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_story). */
|
public function mount()
|
||||||
public string $section = 'our_story';
|
|
||||||
|
|
||||||
public function mount(string $section = 'our_story')
|
|
||||||
{
|
{
|
||||||
$this->section = $section;
|
$this->content = cms_theme_section('our_story');
|
||||||
$this->content = cms_theme_section($this->section);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,9 @@ class OurValues extends Component
|
||||||
{
|
{
|
||||||
public $content = [];
|
public $content = [];
|
||||||
|
|
||||||
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_values). */
|
public function mount()
|
||||||
public string $section = 'our_values';
|
|
||||||
|
|
||||||
public function mount(string $section = 'our_values')
|
|
||||||
{
|
{
|
||||||
$this->section = $section;
|
$this->content = cms_theme_section('our_values');
|
||||||
$this->content = cms_theme_section($this->section);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seitenkopf für Unterseiten: Zeile über der Überschrift, Überschrift, Text und optional ein Bild
|
|
||||||
* daneben (Breadcrumb seit 07.10.2026 entfernt).
|
|
||||||
*
|
|
||||||
* Liegt auf derselben abgesetzten grauen Fläche wie der Hero der Startseite, damit sich der
|
|
||||||
* Kopf klar von den Inhaltsblöcken darunter abhebt.
|
|
||||||
*/
|
|
||||||
class PageHero extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $section = 'marken_hero', string $bg = ''): void
|
|
||||||
{
|
|
||||||
$content = cms_theme_section($section);
|
|
||||||
$this->content = is_array($content) ? $content : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.page-hero');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use App\Models\CmsReference;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Realisierte Projekte auf /referenzen.
|
|
||||||
*
|
|
||||||
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
|
|
||||||
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
|
|
||||||
* die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien, über alle Marken
|
|
||||||
* hinweg gesammelt. Visualisierungen und Entwürfe bleiben in beiden Fällen draußen.
|
|
||||||
*/
|
|
||||||
class ReferenceProjects extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
/** @var array<int, array<string, mixed>> */
|
|
||||||
public array $projects = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public int $limit = 0;
|
|
||||||
|
|
||||||
public function mount(string $bg = '', int $limit = 0): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('reference_projects');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
$this->limit = $limit;
|
|
||||||
|
|
||||||
$projects = $this->fromReferences();
|
|
||||||
|
|
||||||
if ($projects === []) {
|
|
||||||
$projects = $this->fromBrandGalleries();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function fromReferences(): array
|
|
||||||
{
|
|
||||||
$references = CmsReference::query()->published()->ordered()->get();
|
|
||||||
|
|
||||||
if ($references->isEmpty()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$brands = CmsBrand::forFrontend();
|
|
||||||
$projects = [];
|
|
||||||
|
|
||||||
foreach ($references as $reference) {
|
|
||||||
$gallery = CmsReference::normalizeGallery($reference->gallery);
|
|
||||||
$image = (string) ($reference->image ?: ($gallery[0]['image'] ?? ''));
|
|
||||||
|
|
||||||
if ($image === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$projects[] = [
|
|
||||||
'key' => 'ref-'.$reference->id,
|
|
||||||
'image' => $image,
|
|
||||||
'caption' => (string) $reference->title,
|
|
||||||
'meta' => implode(' · ', array_filter([$reference->location, $reference->year])),
|
|
||||||
'description' => (string) $reference->description,
|
|
||||||
'gallery' => array_values(array_filter($gallery, fn (array $item) => $item['image'] !== $image)),
|
|
||||||
'brands' => $reference->resolveBrands($brands),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $projects;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function fromBrandGalleries(): array
|
|
||||||
{
|
|
||||||
$projects = [];
|
|
||||||
|
|
||||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
|
||||||
foreach (CmsBrand::groupGallery($brand['gallery'] ?? [])['projects'] as $index => $item) {
|
|
||||||
$projects[] = [
|
|
||||||
'key' => $slug.'-'.$index,
|
|
||||||
'image' => $item['image'],
|
|
||||||
'caption' => $item['caption'],
|
|
||||||
'meta' => '',
|
|
||||||
'description' => '',
|
|
||||||
'gallery' => [],
|
|
||||||
'brands' => [[
|
|
||||||
'slug' => $slug,
|
|
||||||
'name' => (string) ($brand['name'] ?? ''),
|
|
||||||
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
|
|
||||||
]],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $projects;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.reference-projects');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Sections;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Sponsoring extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public string $bg = '';
|
|
||||||
|
|
||||||
public function mount(string $bg = ''): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('sponsoring');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->bg = $bg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.sections.sponsoring');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,13 +10,9 @@ class VisionSection extends Component
|
||||||
|
|
||||||
public $bg = 'bg-background';
|
public $bg = 'bg-background';
|
||||||
|
|
||||||
/** Inhalt in weißer Box mit kleinerem Bild (Startseite, 07.10.2026); die anderen Themes bleiben offen. */
|
public function mount($bg = 'bg-background')
|
||||||
public bool $boxed = false;
|
|
||||||
|
|
||||||
public function mount($bg = 'bg-background', bool $boxed = false)
|
|
||||||
{
|
{
|
||||||
$this->bg = $bg;
|
$this->bg = $bg;
|
||||||
$this->boxed = $boxed;
|
|
||||||
$this->content = cms_theme_section('vision_section');
|
$this->content = cms_theme_section('vision_section');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
20
app/Livewire/Web/Components/UI/AnnouncementBar.php
Normal file
20
app/Livewire/Web/Components/UI/AnnouncementBar.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Web\Components\Ui;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class AnnouncementBar extends Component
|
||||||
|
{
|
||||||
|
public array $content = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->content = cms_theme_section('announcement_bar');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.web.components.ui.announcement-bar');
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/Livewire/Web/Components/UI/ContactForm.php
Normal file
115
app/Livewire/Web/Components/UI/ContactForm.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Web\Components\Ui;
|
||||||
|
|
||||||
|
use Acme\ContactForm\ContactFormService;
|
||||||
|
use Acme\ContactForm\SpamDetector;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ContactForm extends Component
|
||||||
|
{
|
||||||
|
public string $firstName = '';
|
||||||
|
|
||||||
|
public string $lastName = '';
|
||||||
|
|
||||||
|
public string $company = '';
|
||||||
|
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
public string $phone = '';
|
||||||
|
|
||||||
|
public string $subject = '';
|
||||||
|
|
||||||
|
public string $message = '';
|
||||||
|
|
||||||
|
public bool $privacy = false;
|
||||||
|
|
||||||
|
public bool $success = false;
|
||||||
|
|
||||||
|
/** @var string Hidden honeypot field */
|
||||||
|
public string $website = '';
|
||||||
|
|
||||||
|
public ?int $formLoadedAt = null;
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
public array $content = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->content = cms_theme_section('contact_form');
|
||||||
|
$this->formLoadedAt = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function getSubjectsProperty(): array
|
||||||
|
{
|
||||||
|
return $this->content['form']['subjects'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array<string, mixed>> */
|
||||||
|
public function getContactInfoProperty(): array
|
||||||
|
{
|
||||||
|
return $this->content['contact_info'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array<string, mixed>> */
|
||||||
|
public function getSocialMediaProperty(): array
|
||||||
|
{
|
||||||
|
return $this->content['social_media']['platforms'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit(ContactFormService $service): void
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'firstName' => ['required', 'string', 'max:255'],
|
||||||
|
'lastName' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email:rfc', 'max:255'],
|
||||||
|
'subject' => ['required', 'string'],
|
||||||
|
'message' => ['required', 'string', 'max:2000'],
|
||||||
|
'company' => ['nullable', 'string', 'max:255'],
|
||||||
|
'phone' => ['nullable', 'string', 'max:255'],
|
||||||
|
'privacy' => ['accepted'],
|
||||||
|
'website' => ['nullable', 'string', 'max:0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$spamDetector = SpamDetector::fromConfig();
|
||||||
|
$payload = [
|
||||||
|
'first_name' => $this->firstName,
|
||||||
|
'last_name' => $this->lastName,
|
||||||
|
'email' => $this->email,
|
||||||
|
'subject' => $this->subject,
|
||||||
|
'message' => $this->message,
|
||||||
|
'company' => $this->company,
|
||||||
|
'phone' => $this->phone,
|
||||||
|
'website' => $this->website,
|
||||||
|
'ip' => request()->ip(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
'is_spam' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
|
||||||
|
|
||||||
|
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
|
||||||
|
$subjectLine = $subjectLabel
|
||||||
|
? 'Kontaktanfrage B2in: ' . $subjectLabel
|
||||||
|
: __('contact-form::contact-form.default_subject');
|
||||||
|
$service->handle($payload, $subjectLine, 'contact-form');
|
||||||
|
|
||||||
|
$this->success = true;
|
||||||
|
$this->firstName = '';
|
||||||
|
$this->lastName = '';
|
||||||
|
$this->company = '';
|
||||||
|
$this->email = '';
|
||||||
|
$this->phone = '';
|
||||||
|
$this->subject = '';
|
||||||
|
$this->message = '';
|
||||||
|
$this->privacy = false;
|
||||||
|
$this->website = '';
|
||||||
|
$this->formLoadedAt = time();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): \Illuminate\View\View
|
||||||
|
{
|
||||||
|
return view('livewire.web.components.ui.contact-form');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Livewire/Web/Components/UI/Footer.php
Normal file
13
app/Livewire/Web/Components/UI/Footer.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Web\Components\Ui;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Footer extends Component
|
||||||
|
{
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.web.components.ui.footer');
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Livewire/Web/Components/UI/Header.php
Normal file
86
app/Livewire/Web/Components/UI/Header.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Web\Components\Ui;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Header extends Component
|
||||||
|
{
|
||||||
|
public $isMobileMenuOpen = false;
|
||||||
|
public $domainName;
|
||||||
|
public $domainUrl;
|
||||||
|
public $content = [];
|
||||||
|
public $currentLocale;
|
||||||
|
public $availableLocales = [
|
||||||
|
'de' => 'DE',
|
||||||
|
'en' => 'EN',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
|
||||||
|
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
|
||||||
|
$this->currentLocale = app()->getLocale();
|
||||||
|
|
||||||
|
$this->content = cms_theme_section('header');
|
||||||
|
|
||||||
|
// Ensure required keys exist
|
||||||
|
if (!isset($this->content['portal_login'])) {
|
||||||
|
$this->content['portal_login'] = 'Portal Login';
|
||||||
|
}
|
||||||
|
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
|
||||||
|
$this->content['navigation'] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function switchLanguage(string $locale): mixed
|
||||||
|
{
|
||||||
|
if (array_key_exists($locale, $this->availableLocales)) {
|
||||||
|
app()->setLocale($locale);
|
||||||
|
session(['locale' => $locale]);
|
||||||
|
$this->currentLocale = $locale;
|
||||||
|
|
||||||
|
$referer = request()->header('Referer') ?? '/';
|
||||||
|
|
||||||
|
return redirect()->to($referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleMobileMenu(): void
|
||||||
|
{
|
||||||
|
$this->isMobileMenuOpen = ! $this->isMobileMenuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeMobileMenu(): void
|
||||||
|
{
|
||||||
|
$this->isMobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
public function isActiveRoute($url)
|
||||||
|
{
|
||||||
|
$currentPath = request()->path();
|
||||||
|
$currentPath = '/' . ltrim($currentPath, '/');
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if ($currentPath === $url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for home route
|
||||||
|
if ($url === '/' && ($currentPath === '/' || $currentPath === '')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current path starts with the navigation URL (for sub-pages)
|
||||||
|
if ($url !== '/' && str_starts_with($currentPath, rtrim($url, '/'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.web.components.ui.header');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Ui;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class AnnouncementBar extends Component
|
|
||||||
{
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
/** Farbwelt der Seite – jede Welt hat ihre eigene Meldung. */
|
|
||||||
public string $world = 'interior';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->world = b2in_world();
|
|
||||||
|
|
||||||
// Interior: global.announcement_bar, Immobilien: global.announcement_bar_immobilien.
|
|
||||||
$section = cms_theme_section($this->world === 'immobilien' ? 'announcement_bar_immobilien' : 'announcement_bar');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schluessel, unter dem sich der Browser das Wegklicken merkt.
|
|
||||||
*
|
|
||||||
* Leitet sich aus dem Inhalt ab, nicht nur aus der gepflegten `id`: Wird die Leiste im CMS
|
|
||||||
* neu belegt, ohne die `id` zu aendern, bekaemen Wiederkehrer die neue Meldung sonst nie
|
|
||||||
* zu sehen, weil ihr alter Dismiss-Eintrag im localStorage weitergilt.
|
|
||||||
*/
|
|
||||||
public function getDismissKeyProperty(): string
|
|
||||||
{
|
|
||||||
$fingerprint = implode('|', [
|
|
||||||
$this->content['id'] ?? 'default',
|
|
||||||
$this->content['badge'] ?? '',
|
|
||||||
$this->content['text'] ?? '',
|
|
||||||
$this->content['link_url'] ?? '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return 'announcement_dismissed_'.substr(md5($fingerprint), 0, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.ui.announcement-bar');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Ui;
|
|
||||||
|
|
||||||
use Acme\ContactForm\ContactFormService;
|
|
||||||
use Acme\ContactForm\SpamDetector;
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schwebende Terminanfrage – auf allen Seiten unten rechts.
|
|
||||||
*
|
|
||||||
* Setzt wie die übrigen Formulare der Seite auf {@see ContactFormService} und {@see SpamDetector}
|
|
||||||
* auf, statt auf Acme\ContactForm\Livewire\ContactForm: dessen mitgelieferte View bringt ein
|
|
||||||
* eigenes (blaues) Layout mit, das nicht zum Auftritt passt. Honeypot, Rate-Limit und
|
|
||||||
* Spam-Erkennung kommen so trotzdem aus dem Paket.
|
|
||||||
*/
|
|
||||||
class AppointmentWidget extends Component
|
|
||||||
{
|
|
||||||
/** Feste Anliegen neben den gepflegten Marken. */
|
|
||||||
public const EXTRA_TOPICS = [
|
|
||||||
'projektausstattung' => 'Projektausstattung',
|
|
||||||
'immobilien' => 'Immobilien',
|
|
||||||
'sonstiges' => 'Sonstiges',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Farbwelt der Seite – in der Immobilien-Welt ist das Anliegen vorbelegt. */
|
|
||||||
public string $world = 'interior';
|
|
||||||
|
|
||||||
public string $name = '';
|
|
||||||
|
|
||||||
public string $email = '';
|
|
||||||
|
|
||||||
public string $phone = '';
|
|
||||||
|
|
||||||
public string $topic = '';
|
|
||||||
|
|
||||||
public string $preferredDate = '';
|
|
||||||
|
|
||||||
public string $message = '';
|
|
||||||
|
|
||||||
public bool $privacy = false;
|
|
||||||
|
|
||||||
public bool $success = false;
|
|
||||||
|
|
||||||
/** @var string Honeypot – muss leer bleiben. */
|
|
||||||
public string $website = '';
|
|
||||||
|
|
||||||
public ?int $formLoadedAt = null;
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('appointment_widget');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->formLoadedAt = time();
|
|
||||||
$this->world = b2in_world();
|
|
||||||
|
|
||||||
if ($this->world === 'immobilien') {
|
|
||||||
$this->topic = 'immobilien';
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->applyBrandContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auf einer Markenseite trägt der Button die Beschriftung dieser Marke.
|
|
||||||
*
|
|
||||||
* Die Zulieferung sieht ausdrücklich keinen zweiten CTA-Button vor, sondern eine
|
|
||||||
* kontextbezogene Beschriftung des vorhandenen Widgets. Da das Widget im Layout steckt und
|
|
||||||
* nicht je Seite eingebunden wird, liest es den Kontext aus der Route.
|
|
||||||
*/
|
|
||||||
private function applyBrandContext(): void
|
|
||||||
{
|
|
||||||
$route = request()->route();
|
|
||||||
|
|
||||||
if ($route?->getName() !== 'marken.show') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slug = (string) $route->parameter('slug');
|
|
||||||
$brand = CmsBrand::forFrontend()[$slug] ?? null;
|
|
||||||
$label = is_array($brand) ? trim((string) ($brand['widget_label'] ?? '')) : '';
|
|
||||||
|
|
||||||
if ($label !== '') {
|
|
||||||
$this->content['button_label'] = $label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auswahl „Anliegen": gepflegte Kernmarken zuerst, danach die festen Einträge.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function getTopicsProperty(): array
|
|
||||||
{
|
|
||||||
$brands = [];
|
|
||||||
|
|
||||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
|
||||||
$brands[$slug] = (string) ($brand['name'] ?? $slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $brands + self::EXTRA_TOPICS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWhatsappUrlProperty(): string
|
|
||||||
{
|
|
||||||
return whatsapp_link();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function submit(ContactFormService $service): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'name' => ['required', 'string', 'max:255'],
|
|
||||||
'email' => ['required', 'email:rfc', 'max:255'],
|
|
||||||
'phone' => ['nullable', 'string', 'max:80'],
|
|
||||||
'topic' => ['nullable', 'string', 'max:255'],
|
|
||||||
'preferredDate' => ['nullable', 'date', 'after_or_equal:today'],
|
|
||||||
'message' => ['nullable', 'string', 'max:2000'],
|
|
||||||
'privacy' => ['accepted'],
|
|
||||||
'website' => ['nullable', 'string', 'max:0'],
|
|
||||||
], [
|
|
||||||
'preferredDate.after_or_equal' => __('ui.appointment.date_in_past'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$topicLabel = $this->topics[$this->topic] ?? '';
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'name' => $this->name,
|
|
||||||
'email' => $this->email,
|
|
||||||
'phone' => $this->phone,
|
|
||||||
'topic' => $topicLabel,
|
|
||||||
'preferred_date' => $this->preferredDate,
|
|
||||||
'message' => $this->message,
|
|
||||||
'website' => $this->website,
|
|
||||||
'source' => request()->path(),
|
|
||||||
'ip' => request()->ip(),
|
|
||||||
'user_agent' => request()->userAgent(),
|
|
||||||
'is_spam' => false,
|
|
||||||
];
|
|
||||||
|
|
||||||
$payload['is_spam'] = SpamDetector::fromConfig()->detect($payload, $this->formLoadedAt);
|
|
||||||
|
|
||||||
$subject = ($this->world === 'immobilien' ? 'Terminanfrage B2in Immobilien' : 'Terminanfrage B2in Interior')
|
|
||||||
.($topicLabel !== '' ? ': '.$topicLabel : '');
|
|
||||||
$service->handle($payload, $subject, 'appointment-widget');
|
|
||||||
|
|
||||||
$this->success = true;
|
|
||||||
$this->reset(['name', 'email', 'phone', 'topic', 'preferredDate', 'message', 'privacy', 'website']);
|
|
||||||
$this->topic = $this->world === 'immobilien' ? 'immobilien' : '';
|
|
||||||
$this->formLoadedAt = time();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function startOver(): void
|
|
||||||
{
|
|
||||||
$this->success = false;
|
|
||||||
$this->formLoadedAt = time();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): \Illuminate\View\View
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.ui.appointment-widget');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Ui;
|
|
||||||
|
|
||||||
use Acme\ContactForm\ContactFormService;
|
|
||||||
use Acme\ContactForm\SpamDetector;
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class ContactForm extends Component
|
|
||||||
{
|
|
||||||
public string $firstName = '';
|
|
||||||
|
|
||||||
public string $lastName = '';
|
|
||||||
|
|
||||||
public string $company = '';
|
|
||||||
|
|
||||||
public string $email = '';
|
|
||||||
|
|
||||||
public string $phone = '';
|
|
||||||
|
|
||||||
public string $subject = '';
|
|
||||||
|
|
||||||
public string $message = '';
|
|
||||||
|
|
||||||
public bool $privacy = false;
|
|
||||||
|
|
||||||
public bool $success = false;
|
|
||||||
|
|
||||||
/** @var string Hidden honeypot field */
|
|
||||||
public string $website = '';
|
|
||||||
|
|
||||||
public ?int $formLoadedAt = null;
|
|
||||||
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
/** Farbwelt der Seite (`interior` | `immobilien`). */
|
|
||||||
public string $world = 'interior';
|
|
||||||
|
|
||||||
public function mount(string $world = 'interior'): void
|
|
||||||
{
|
|
||||||
$this->world = $world === 'immobilien' ? 'immobilien' : 'interior';
|
|
||||||
$this->content = $this->loadContent();
|
|
||||||
$this->formLoadedAt = time();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inhalte der Welt. Die Immobilien-Welt liegt als Overlay über der Interior-Sektion:
|
|
||||||
* `contact_form_immobilien` trägt nur, was abweicht (Einstieg, Betreffe) – Adresse,
|
|
||||||
* Beschriftungen und Social Media erbt sie von `contact_form`.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function loadContent(): array
|
|
||||||
{
|
|
||||||
$base = cms_theme_section('contact_form');
|
|
||||||
$base = is_array($base) ? $base : [];
|
|
||||||
|
|
||||||
if ($this->world !== 'immobilien') {
|
|
||||||
return $base;
|
|
||||||
}
|
|
||||||
|
|
||||||
$overlay = cms_theme_section('contact_form_immobilien');
|
|
||||||
|
|
||||||
if (! is_array($overlay)) {
|
|
||||||
return $base;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_replace_recursive($base, $overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Betreffe in Gruppen: Interior (mit den gepflegten Kernmarken vorneweg), Immobilien, B2in.
|
|
||||||
* Die Welt der Seite bestimmt die Reihenfolge – ihre Gruppe steht oben.
|
|
||||||
*
|
|
||||||
* Ohne `subject_groups` (aeltere CMS-Staende) faellt das Formular auf die flache Liste zurueck.
|
|
||||||
*
|
|
||||||
* @return array<string, array{label: string, subjects: array<string, string>}>
|
|
||||||
*/
|
|
||||||
public function getSubjectGroupsProperty(): array
|
|
||||||
{
|
|
||||||
$groups = $this->content['form']['subject_groups'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($groups) || $groups === []) {
|
|
||||||
$flat = $this->content['form']['subjects'] ?? [];
|
|
||||||
unset($flat['']);
|
|
||||||
|
|
||||||
return $flat === [] ? [] : ['all' => ['label' => '', 'subjects' => $flat]];
|
|
||||||
}
|
|
||||||
|
|
||||||
$brands = [];
|
|
||||||
|
|
||||||
foreach (CmsBrand::forFrontend() as $slug => $brand) {
|
|
||||||
$brands[$slug] = (string) ($brand['name'] ?? $slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($groups as $key => $group) {
|
|
||||||
$subjects = is_array($group['subjects'] ?? null) ? $group['subjects'] : [];
|
|
||||||
|
|
||||||
if ($key === 'interior') {
|
|
||||||
$subjects = $brands + $subjects;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[$key] = [
|
|
||||||
'label' => (string) ($group['label'] ?? ''),
|
|
||||||
'subjects' => $subjects,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$first = $this->world === 'immobilien' ? 'immobilien' : 'interior';
|
|
||||||
|
|
||||||
if (isset($out[$first])) {
|
|
||||||
$out = [$first => $out[$first]] + $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alle waehlbaren Betreffe als flache Liste (Schluessel → Beschriftung).
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function getSubjectsProperty(): array
|
|
||||||
{
|
|
||||||
$flat = [];
|
|
||||||
|
|
||||||
foreach ($this->getSubjectGroupsProperty() as $group) {
|
|
||||||
$flat += $group['subjects'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $flat;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSubjectPlaceholderProperty(): string
|
|
||||||
{
|
|
||||||
return (string) ($this->content['form']['subjects'][''] ?? $this->content['form']['subject_placeholder'] ?? __('ui.contact_form.subject_placeholder'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int, array<string, mixed>> */
|
|
||||||
public function getContactInfoProperty(): array
|
|
||||||
{
|
|
||||||
return $this->content['contact_info'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function submit(ContactFormService $service): void
|
|
||||||
{
|
|
||||||
$this->validate([
|
|
||||||
'firstName' => ['required', 'string', 'max:255'],
|
|
||||||
'lastName' => ['required', 'string', 'max:255'],
|
|
||||||
'email' => ['required', 'email:rfc', 'max:255'],
|
|
||||||
'subject' => ['required', 'string', Rule::in(array_keys($this->subjects))],
|
|
||||||
'message' => ['required', 'string', 'max:2000'],
|
|
||||||
'company' => ['nullable', 'string', 'max:255'],
|
|
||||||
'phone' => ['nullable', 'string', 'max:255'],
|
|
||||||
'privacy' => ['accepted'],
|
|
||||||
'website' => ['nullable', 'string', 'max:0'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$spamDetector = SpamDetector::fromConfig();
|
|
||||||
$payload = [
|
|
||||||
'first_name' => $this->firstName,
|
|
||||||
'last_name' => $this->lastName,
|
|
||||||
'email' => $this->email,
|
|
||||||
'subject' => $this->subject,
|
|
||||||
'message' => $this->message,
|
|
||||||
'company' => $this->company,
|
|
||||||
'phone' => $this->phone,
|
|
||||||
'website' => $this->website,
|
|
||||||
'ip' => request()->ip(),
|
|
||||||
'user_agent' => request()->userAgent(),
|
|
||||||
'is_spam' => false,
|
|
||||||
];
|
|
||||||
|
|
||||||
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
|
|
||||||
|
|
||||||
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
|
|
||||||
$prefix = $this->world === 'immobilien' ? 'Kontaktanfrage B2in Immobilien' : 'Kontaktanfrage B2in';
|
|
||||||
$subjectLine = $subjectLabel
|
|
||||||
? $prefix.': '.$subjectLabel
|
|
||||||
: __('contact-form::contact-form.default_subject');
|
|
||||||
$service->handle($payload, $subjectLine, 'contact-form');
|
|
||||||
|
|
||||||
$this->success = true;
|
|
||||||
$this->firstName = '';
|
|
||||||
$this->lastName = '';
|
|
||||||
$this->company = '';
|
|
||||||
$this->email = '';
|
|
||||||
$this->phone = '';
|
|
||||||
$this->subject = '';
|
|
||||||
$this->message = '';
|
|
||||||
$this->privacy = false;
|
|
||||||
$this->website = '';
|
|
||||||
$this->formLoadedAt = time();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render(): \Illuminate\View\View
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.ui.contact-form');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Ui;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Footer extends Component
|
|
||||||
{
|
|
||||||
/** @var array<string, mixed> */
|
|
||||||
public array $content = [];
|
|
||||||
|
|
||||||
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
|
|
||||||
public string $world = 'interior';
|
|
||||||
|
|
||||||
/** @var array<string, array{label: string, url: string, active: bool}> */
|
|
||||||
public array $worlds = [];
|
|
||||||
|
|
||||||
public string $logoPath = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Negatives Logo für die Farbvariante „Modern“ (Nachtrag 18): dort liegt der Interior-Footer
|
|
||||||
* dunkel in Tanne. Die Palette schaltet im Browser, deshalb liegen beide Logos im Markup und
|
|
||||||
* das Stylesheet blendet das passende ein. Leer in der Immobilien-Welt, die ohnehin das
|
|
||||||
* negative Logo trägt.
|
|
||||||
*/
|
|
||||||
public string $logoPathDark = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fuer die Immobilien-Welt zeigen die gemeinsamen Footer-Links auf die Seiten dieser Welt.
|
|
||||||
*
|
|
||||||
* @var array<string, string>
|
|
||||||
*/
|
|
||||||
private const IMMOBILIEN_LINKS = [
|
|
||||||
'/faq' => '/immobilien/faq',
|
|
||||||
'/about' => '/immobilien/ueber-b2in',
|
|
||||||
'/magazin' => '/immobilien/magazin',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$section = cms_theme_section('footer');
|
|
||||||
$this->content = is_array($section) ? $section : [];
|
|
||||||
$this->world = b2in_world();
|
|
||||||
|
|
||||||
if ($this->world === 'immobilien' && is_array($this->content['links'] ?? null)) {
|
|
||||||
$this->content['links'] = array_map(function ($link) {
|
|
||||||
if (is_array($link) && isset(self::IMMOBILIEN_LINKS[$link['url'] ?? ''])) {
|
|
||||||
$link['url'] = self::IMMOBILIEN_LINKS[$link['url']];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $link;
|
|
||||||
}, $this->content['links']);
|
|
||||||
}
|
|
||||||
$this->worlds = b2in_worlds($this->world);
|
|
||||||
|
|
||||||
// Der Interior-Footer liegt auf Sand (helle Fläche) und trägt deshalb das positive
|
|
||||||
// Logo; der Immobilien-Footer bleibt dunkel und nutzt die negative Variante.
|
|
||||||
$variant = $this->world === 'immobilien' ? 'negative' : 'positive';
|
|
||||||
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath($variant, $this->world);
|
|
||||||
$this->logoPathDark = $this->world === 'immobilien'
|
|
||||||
? ''
|
|
||||||
: \App\Helpers\ThemeHelper::getLogoPath('negative', $this->world);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.ui.footer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Web\Components\Ui;
|
|
||||||
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
class Header extends Component
|
|
||||||
{
|
|
||||||
public $isMobileMenuOpen = false;
|
|
||||||
public $domainName;
|
|
||||||
public $domainUrl;
|
|
||||||
public $content = [];
|
|
||||||
public $currentLocale;
|
|
||||||
public $availableLocales = [
|
|
||||||
'de' => 'DE',
|
|
||||||
'en' => 'EN',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
|
|
||||||
public string $world = 'interior';
|
|
||||||
|
|
||||||
/** @var array<string, array{label: string, url: string, active: bool}> */
|
|
||||||
public array $worlds = [];
|
|
||||||
|
|
||||||
public string $logoPath = '';
|
|
||||||
|
|
||||||
/** Pfad der Seite, in mount() festgehalten – ein Livewire-Request traegt ihn nicht mehr. */
|
|
||||||
public string $currentPath = '/';
|
|
||||||
|
|
||||||
/** Ziel des Kontakt-Buttons: /contact (Interior) bzw. /immobilien/kontakt (Immobilien). */
|
|
||||||
public string $contactUrl = '';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
|
|
||||||
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
|
|
||||||
$this->currentLocale = app()->getLocale();
|
|
||||||
$this->world = b2in_world();
|
|
||||||
$this->worlds = b2in_worlds($this->world);
|
|
||||||
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath('positive', $this->world);
|
|
||||||
$this->currentPath = '/'.ltrim(request()->path(), '/');
|
|
||||||
$this->contactUrl = route($this->world === 'immobilien' ? 'immobilien.contact' : 'contact');
|
|
||||||
|
|
||||||
// Jede Welt hat ihre eigene Navigation (CMS: global.header bzw. global.header_immobilien).
|
|
||||||
$this->content = cms_theme_section($this->world === 'immobilien' ? 'header_immobilien' : 'header');
|
|
||||||
|
|
||||||
// Ensure required keys exist
|
|
||||||
if (!isset($this->content['portal_login'])) {
|
|
||||||
$this->content['portal_login'] = 'Portal Login';
|
|
||||||
}
|
|
||||||
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
|
|
||||||
$this->content['navigation'] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Referenzen lassen sich im CMS ausblenden; dann verschwindet auch der Menüpunkt.
|
|
||||||
if (! references_page_enabled()) {
|
|
||||||
$this->content['navigation'] = array_values(array_filter(
|
|
||||||
$this->content['navigation'],
|
|
||||||
fn ($item) => rtrim(parse_url((string) ($item['url'] ?? ''), PHP_URL_PATH) ?? '', '/') !== '/referenzen',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function switchLanguage(string $locale): mixed
|
|
||||||
{
|
|
||||||
if (array_key_exists($locale, $this->availableLocales)) {
|
|
||||||
app()->setLocale($locale);
|
|
||||||
session(['locale' => $locale]);
|
|
||||||
$this->currentLocale = $locale;
|
|
||||||
|
|
||||||
$referer = request()->header('Referer') ?? '/';
|
|
||||||
|
|
||||||
return redirect()->to($referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggleMobileMenu(): void
|
|
||||||
{
|
|
||||||
$this->isMobileMenuOpen = ! $this->isMobileMenuOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closeMobileMenu(): void
|
|
||||||
{
|
|
||||||
$this->isMobileMenuOpen = false;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Aktiver Navigationspunkt: exakter Treffer, sonst der laengste Eintrag, dessen Pfad ein
|
|
||||||
* Praefix der Seite ist. So markiert /immobilien/magazin nur „Magazin“, nicht auch
|
|
||||||
* „Immobilien“. Anker (#…) zaehlen nicht zum Pfad.
|
|
||||||
*/
|
|
||||||
public function isActiveRoute($url): bool
|
|
||||||
{
|
|
||||||
$path = $this->navPath((string) $url);
|
|
||||||
|
|
||||||
if ($this->currentPath === $path) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($path === '/' || ! str_starts_with($this->currentPath, rtrim($path, '/').'/')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->content['navigation'] ?? [] as $navItem) {
|
|
||||||
$other = $this->navPath((string) ($navItem['url'] ?? ''));
|
|
||||||
|
|
||||||
if ($other === $this->currentPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($other) > strlen($path) && str_starts_with($this->currentPath, rtrim($other, '/').'/')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function navPath(string $url): string
|
|
||||||
{
|
|
||||||
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
|
|
||||||
|
|
||||||
return '/'.ltrim($path, '/');
|
|
||||||
}
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.web.components.ui.header');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,47 +13,12 @@ class CmsArticle extends Model
|
||||||
|
|
||||||
protected $table = 'cms_articles';
|
protected $table = 'cms_articles';
|
||||||
|
|
||||||
/**
|
|
||||||
* Alte numerische Artikel-IDs (resources/lang/{locale}/b2in.php) → Slug.
|
|
||||||
*
|
|
||||||
* Wird an drei Stellen gebraucht: beim Anlegen durch den CmsArticleSeeder, für den
|
|
||||||
* Lang-Fallback und für die 301-Weiterleitung der alten /magazin/{id}-URLs.
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
public const LEGACY_ID_TO_SLUG = [
|
|
||||||
1 => 'escrow-system-dubai-investoren',
|
|
||||||
2 => 'spotlight-al-jaddaf-hotspot',
|
|
||||||
3 => 'turnkey-investments-moeblierung-rendite',
|
|
||||||
4 => 'supply-chain-management-vertragssicherheit',
|
|
||||||
5 => 'local-for-local-regionaler-moebelhandel',
|
|
||||||
];
|
|
||||||
|
|
||||||
public const WORLD_INTERIOR = 'interior';
|
|
||||||
|
|
||||||
public const WORLD_IMMOBILIEN = 'immobilien';
|
|
||||||
|
|
||||||
/** @var array<int, string> */
|
|
||||||
public const WORLDS = [self::WORLD_INTERIOR, self::WORLD_IMMOBILIEN];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Beiträge aus dem Bestand, die zur Immobilien-Welt gehören (Dubai-Themen).
|
|
||||||
*
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
public const LEGACY_IMMOBILIEN_SLUGS = [
|
|
||||||
'escrow-system-dubai-investoren',
|
|
||||||
'spotlight-al-jaddaf-hotspot',
|
|
||||||
'turnkey-investments-moeblierung-rendite',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'slug',
|
'slug',
|
||||||
'title',
|
'title',
|
||||||
'subtitle',
|
'subtitle',
|
||||||
'image',
|
'image',
|
||||||
'category',
|
'category',
|
||||||
'world',
|
|
||||||
'date_label',
|
'date_label',
|
||||||
'read_time',
|
'read_time',
|
||||||
'author',
|
'author',
|
||||||
|
|
@ -88,104 +53,6 @@ class CmsArticle extends Model
|
||||||
return $query->orderBy('order')->orderByDesc('created_at');
|
return $query->orderBy('order')->orderByDesc('created_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeForWorld(Builder $query, string $world): Builder
|
|
||||||
{
|
|
||||||
return $query->where('world', $world);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Veröffentlichte Artikel fürs Frontend, nach Slug indiziert.
|
|
||||||
*
|
|
||||||
* Das CMS gewinnt. Solange keine Artikel gepflegt sind, greift die Lang-Datei als Fallback –
|
|
||||||
* dieselbe Reihenfolge wie bei cms_theme_section(), damit das Magazin auf einer frischen
|
|
||||||
* Installation nicht leer ist.
|
|
||||||
*
|
|
||||||
* Mit `$world` nur die Beiträge dieser Farbwelt (Interior oder Immobilien) – die beiden
|
|
||||||
* Magazinlisten sind getrennt, die Detailseite rendert in der Welt ihres Beitrags.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function forFrontend(?string $world = null): array
|
|
||||||
{
|
|
||||||
$maintained = static::query()->published()->exists();
|
|
||||||
|
|
||||||
if ($maintained) {
|
|
||||||
return static::query()
|
|
||||||
->published()
|
|
||||||
->when($world !== null, fn (Builder $query) => $query->forWorld($world))
|
|
||||||
->ordered()
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(fn (self $article) => [$article->slug => $article->toFrontendArray()])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
$articles = static::fallbackFromLang();
|
|
||||||
|
|
||||||
if ($world === null) {
|
|
||||||
return $articles;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_filter($articles, fn (array $article) => ($article['world'] ?? self::WORLD_INTERIOR) === $world);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Slug zu einer alten numerischen Magazin-ID, falls bekannt.
|
|
||||||
*/
|
|
||||||
public static function slugForLegacyId(int|string $id): ?string
|
|
||||||
{
|
|
||||||
return self::LEGACY_ID_TO_SLUG[(int) $id] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Slug eines Artikels aus der Lang-Datei.
|
|
||||||
*
|
|
||||||
* Neue Artikel bringen ihren Slug selbst mit (`'slug' => …`). Die fünf ursprünglichen
|
|
||||||
* Artikel hatten keinen und werden weiter über {@see LEGACY_ID_TO_SLUG} aufgelöst – die
|
|
||||||
* Konstante bleibt damit das, was sie ist: die Karte für die alten `/magazin/{id}`-URLs.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $article
|
|
||||||
*/
|
|
||||||
public static function slugForLangArticle(int|string $id, array $article): ?string
|
|
||||||
{
|
|
||||||
$slug = $article['slug'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($slug) && $slug !== '') {
|
|
||||||
return $slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::slugForLegacyId($id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private static function fallbackFromLang(): array
|
|
||||||
{
|
|
||||||
$articles = trans('b2in.articles');
|
|
||||||
|
|
||||||
if (! is_array($articles)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($articles as $id => $article) {
|
|
||||||
if (! is_array($article) || ($article['published'] ?? true) === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slug = self::slugForLangArticle($id, $article);
|
|
||||||
|
|
||||||
if ($slug === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[$slug] = $article + ['slug' => $slug, 'world' => self::WORLD_INTERIOR];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|
@ -198,7 +65,6 @@ class CmsArticle extends Model
|
||||||
'subtitle' => $this->subtitle,
|
'subtitle' => $this->subtitle,
|
||||||
'image' => $this->image,
|
'image' => $this->image,
|
||||||
'category' => $this->category,
|
'category' => $this->category,
|
||||||
'world' => $this->world ?? self::WORLD_INTERIOR,
|
|
||||||
'date' => $this->date_label,
|
'date' => $this->date_label,
|
||||||
'readTime' => $this->read_time,
|
'readTime' => $this->read_time,
|
||||||
'author' => $this->author ?? [],
|
'author' => $this->author ?? [],
|
||||||
|
|
|
||||||
|
|
@ -1,389 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Spatie\Translatable\HasTranslations;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kernmarke mit eigener Detailseite (CABINET, Rotpunkt Küchen, Febrü …).
|
|
||||||
*
|
|
||||||
* Nicht zu verwechseln mit {@see Brand} – das ist die Produkt-/Partner-Marke.
|
|
||||||
*/
|
|
||||||
class CmsBrand extends Model
|
|
||||||
{
|
|
||||||
use HasFactory, HasTranslations;
|
|
||||||
|
|
||||||
/** Tatsaechlich ausgefuehrtes Projekt – ein Foto aus einem echten Auftrag. */
|
|
||||||
public const GALLERY_PROJECT = 'project';
|
|
||||||
|
|
||||||
/** Entwurf, Rendering oder KI-gestuetzte Darstellung – nicht realisiert. */
|
|
||||||
public const GALLERY_INSPIRATION = 'inspiration';
|
|
||||||
|
|
||||||
/** Einblick in Werkstatt, Fertigung oder Montage des Herstellers – weder Projekt noch Entwurf (Nachtrag 19). */
|
|
||||||
public const GALLERY_COMPANY = 'company';
|
|
||||||
|
|
||||||
/** @var list<string> */
|
|
||||||
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION, self::GALLERY_COMPANY];
|
|
||||||
|
|
||||||
protected $table = 'cms_brands';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'slug',
|
|
||||||
'name',
|
|
||||||
'tagline',
|
|
||||||
'teaser',
|
|
||||||
'summary',
|
|
||||||
'highlights',
|
|
||||||
'description',
|
|
||||||
'logo',
|
|
||||||
'image',
|
|
||||||
'services',
|
|
||||||
'sections',
|
|
||||||
'widget_label',
|
|
||||||
'cta_title',
|
|
||||||
'cta_text',
|
|
||||||
'gallery',
|
|
||||||
'website_url',
|
|
||||||
'showroom',
|
|
||||||
'origin_city',
|
|
||||||
'origin_distance',
|
|
||||||
'origin_role',
|
|
||||||
'is_published',
|
|
||||||
'order',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var array<string> */
|
|
||||||
public array $translatable = [
|
|
||||||
'name',
|
|
||||||
'tagline',
|
|
||||||
'teaser',
|
|
||||||
'summary',
|
|
||||||
'description',
|
|
||||||
'widget_label',
|
|
||||||
'cta_title',
|
|
||||||
'cta_text',
|
|
||||||
'origin_city',
|
|
||||||
'origin_distance',
|
|
||||||
'origin_role',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'highlights' => 'array',
|
|
||||||
'services' => 'array',
|
|
||||||
'sections' => 'array',
|
|
||||||
'gallery' => 'array',
|
|
||||||
'is_published' => 'boolean',
|
|
||||||
'order' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopePublished(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('is_published', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeOrdered(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->orderBy('order')->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Veröffentlichte Kernmarken fürs Frontend, nach Slug indiziert.
|
|
||||||
*
|
|
||||||
* Das CMS gewinnt. Solange keine Marke gepflegt ist, greift die Liste `core_brands.brands`
|
|
||||||
* der Lang-Datei als Fallback, damit die Startseite auf einer frischen Installation nicht ohne
|
|
||||||
* Kernmarken dasteht.
|
|
||||||
*
|
|
||||||
* @return array<string, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public static function forFrontend(): array
|
|
||||||
{
|
|
||||||
$brands = static::query()->published()->ordered()->get();
|
|
||||||
|
|
||||||
if ($brands->isNotEmpty()) {
|
|
||||||
return $brands
|
|
||||||
->mapWithKeys(fn (self $brand) => [$brand->slug => $brand->toFrontendArray()])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::fallbackFromLang();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private static function fallbackFromLang(): array
|
|
||||||
{
|
|
||||||
// Direkt aus der Lang-Datei: Im CMS-Eintrag `home.core_brands` steht die Liste nicht
|
|
||||||
// (mehr), dort pflegt man nur Überschrift und Untertitel – die Marken in der Markenverwaltung.
|
|
||||||
$brands = trans('b2in.themes.'.config('app.theme', 'b2in').'.core_brands.brands');
|
|
||||||
$brands = is_array($brands) ? $brands : [];
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($brands as $brand) {
|
|
||||||
if (! is_array($brand) || empty($brand['name'])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slug = $brand['slug'] ?? \Illuminate\Support\Str::slug((string) $brand['name']);
|
|
||||||
|
|
||||||
$entry = array_merge([
|
|
||||||
'slug' => $slug,
|
|
||||||
'tagline' => '',
|
|
||||||
'teaser' => '',
|
|
||||||
'summary' => '',
|
|
||||||
'highlights' => [],
|
|
||||||
'description' => '',
|
|
||||||
'logo' => '',
|
|
||||||
'image' => '',
|
|
||||||
'services' => [],
|
|
||||||
'sections' => [],
|
|
||||||
'widget_label' => '',
|
|
||||||
'cta_title' => '',
|
|
||||||
'cta_text' => '',
|
|
||||||
'gallery' => [],
|
|
||||||
'website_url' => '',
|
|
||||||
'showroom' => '',
|
|
||||||
'origin_city' => '',
|
|
||||||
'origin_distance' => '',
|
|
||||||
'origin_role' => '',
|
|
||||||
'has_detail_page' => false,
|
|
||||||
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
|
|
||||||
|
|
||||||
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
|
|
||||||
$entry['sections'] = self::normalizeSections($entry['sections']);
|
|
||||||
|
|
||||||
$out[$slug] = $entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seitenabschnitte in eine feste Form bringen.
|
|
||||||
*
|
|
||||||
* Jeder Abschnitt hat einen Titel und Absätze. Optional dazu:
|
|
||||||
* - `claim` hervorgehobene Schlusszeile
|
|
||||||
* - `quote` Zitat mit `quote_author`
|
|
||||||
* - `detail` Vertiefung, die im Frontend aufklappbar dargestellt wird
|
|
||||||
*
|
|
||||||
* @param mixed $sections
|
|
||||||
* @return list<array{title: string, paragraphs: list<string>, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list<string>}|null}>
|
|
||||||
*/
|
|
||||||
public static function normalizeSections($sections): array
|
|
||||||
{
|
|
||||||
if (! is_array($sections)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($sections as $section) {
|
|
||||||
if (! is_array($section)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$paragraphs = self::paragraphList($section['paragraphs'] ?? []);
|
|
||||||
$title = trim((string) ($section['title'] ?? ''));
|
|
||||||
|
|
||||||
if ($title === '' && $paragraphs === []) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$detail = null;
|
|
||||||
|
|
||||||
if (is_array($section['detail'] ?? null)) {
|
|
||||||
$detailParagraphs = self::paragraphList($section['detail']['paragraphs'] ?? []);
|
|
||||||
$detailTitle = trim((string) ($section['detail']['title'] ?? ''));
|
|
||||||
|
|
||||||
if ($detailTitle !== '' || $detailParagraphs !== []) {
|
|
||||||
$detail = ['title' => $detailTitle, 'paragraphs' => $detailParagraphs];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'title' => $title,
|
|
||||||
'paragraphs' => $paragraphs,
|
|
||||||
'claim' => trim((string) ($section['claim'] ?? '')),
|
|
||||||
'quote' => trim((string) ($section['quote'] ?? '')),
|
|
||||||
'quote_author' => trim((string) ($section['quote_author'] ?? '')),
|
|
||||||
'detail' => $detail,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Absätze eines Abschnitts für die Ausgabe gruppieren.
|
|
||||||
*
|
|
||||||
* Ein Absatz, der mit „- “ beginnt, ist ein Listenpunkt; aufeinanderfolgende Punkte bilden
|
|
||||||
* eine Liste. Steht im Punkt ein Doppelpunkt, wird der Teil davor als fette Einleitung
|
|
||||||
* ausgegeben („- Sichere Prüfung & Terminierung: Wir vereinbaren …“). So lässt sich eine
|
|
||||||
* Aufzählung im Backend im normalen Textfeld pflegen.
|
|
||||||
*
|
|
||||||
* @param list<string> $paragraphs
|
|
||||||
* @return list<array{type: 'paragraph', text: string}|array{type: 'list', items: list<array{lead: string, text: string}>}>
|
|
||||||
*/
|
|
||||||
public static function paragraphBlocks(array $paragraphs): array
|
|
||||||
{
|
|
||||||
$blocks = [];
|
|
||||||
|
|
||||||
foreach ($paragraphs as $paragraph) {
|
|
||||||
if (! preg_match('/^[-–•]\s+(.+)$/su', $paragraph, $match)) {
|
|
||||||
$blocks[] = ['type' => 'paragraph', 'text' => $paragraph];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$lead, $text] = str_contains($match[1], ': ')
|
|
||||||
? array_map('trim', explode(': ', $match[1], 2))
|
|
||||||
: ['', trim($match[1])];
|
|
||||||
|
|
||||||
$last = array_key_last($blocks);
|
|
||||||
|
|
||||||
if ($last === null || $blocks[$last]['type'] !== 'list') {
|
|
||||||
$blocks[] = ['type' => 'list', 'items' => []];
|
|
||||||
$last = array_key_last($blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
$blocks[$last]['items'][] = ['lead' => $lead, 'text' => $text];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param mixed $paragraphs
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function paragraphList($paragraphs): array
|
|
||||||
{
|
|
||||||
if (is_string($paragraphs)) {
|
|
||||||
$paragraphs = preg_split('/\R{2,}/', $paragraphs) ?: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($paragraphs)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter(array_map(
|
|
||||||
fn ($p) => trim((string) (is_array($p) ? ($p['text'] ?? '') : $p)),
|
|
||||||
$paragraphs,
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Galerie-Eintraege in eine feste Form bringen.
|
|
||||||
*
|
|
||||||
* Ein Eintrag ohne (oder mit unbekannter) Kategorie gilt als Inspiration, nicht als Projekt:
|
|
||||||
* Ein Entwurf, der versehentlich als Referenz durchgeht, waere die schaedlichere Verwechslung.
|
|
||||||
*
|
|
||||||
* @param mixed $gallery
|
|
||||||
* @return list<array{image: string, category: string, caption: string}>
|
|
||||||
*/
|
|
||||||
public static function normalizeGallery($gallery): array
|
|
||||||
{
|
|
||||||
if (! is_array($gallery)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($gallery as $item) {
|
|
||||||
$image = is_array($item) ? (string) ($item['image'] ?? '') : (string) $item;
|
|
||||||
$image = trim($image);
|
|
||||||
|
|
||||||
if ($image === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$category = is_array($item) ? (string) ($item['category'] ?? '') : '';
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'image' => $image,
|
|
||||||
'category' => in_array($category, self::GALLERY_CATEGORIES, true)
|
|
||||||
? $category
|
|
||||||
: self::GALLERY_INSPIRATION,
|
|
||||||
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Galerie nach realisierten Projekten, Einblicken in die Werkstatt und Inspirationen getrennt.
|
|
||||||
*
|
|
||||||
* @param mixed $gallery
|
|
||||||
* @return array{projects: list<array{image: string, category: string, caption: string}>, company: list<array{image: string, category: string, caption: string}>, inspirations: list<array{image: string, category: string, caption: string}>}
|
|
||||||
*/
|
|
||||||
public static function groupGallery($gallery): array
|
|
||||||
{
|
|
||||||
$items = self::normalizeGallery($gallery);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'projects' => array_values(array_filter(
|
|
||||||
$items,
|
|
||||||
fn (array $item) => $item['category'] === self::GALLERY_PROJECT,
|
|
||||||
)),
|
|
||||||
'company' => array_values(array_filter(
|
|
||||||
$items,
|
|
||||||
fn (array $item) => $item['category'] === self::GALLERY_COMPANY,
|
|
||||||
)),
|
|
||||||
'inspirations' => array_values(array_filter(
|
|
||||||
$items,
|
|
||||||
fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION,
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Beschriftung des Badges auf dem Galeriebild („Realisiertes Projekt“, „Aus der Werkstatt“, „Visualisierung“).
|
|
||||||
*/
|
|
||||||
public static function galleryBadge(string $category): string
|
|
||||||
{
|
|
||||||
return match ($category) {
|
|
||||||
self::GALLERY_PROJECT => __('ui.brand_gallery_badge_project'),
|
|
||||||
self::GALLERY_COMPANY => __('ui.brand_gallery_badge_company'),
|
|
||||||
default => __('ui.brand_gallery_badge_inspiration'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toFrontendArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'slug' => $this->slug,
|
|
||||||
'name' => $this->name,
|
|
||||||
'tagline' => $this->tagline,
|
|
||||||
'teaser' => $this->teaser,
|
|
||||||
'summary' => $this->summary,
|
|
||||||
'highlights' => $this->highlights ?? [],
|
|
||||||
'description' => $this->description,
|
|
||||||
'logo' => $this->logo,
|
|
||||||
'image' => $this->image,
|
|
||||||
'services' => $this->services ?? [],
|
|
||||||
'sections' => self::normalizeSections($this->sections),
|
|
||||||
'widget_label' => (string) $this->widget_label,
|
|
||||||
'cta_title' => (string) $this->cta_title,
|
|
||||||
'cta_text' => (string) $this->cta_text,
|
|
||||||
'gallery' => self::normalizeGallery($this->gallery),
|
|
||||||
'website_url' => $this->website_url,
|
|
||||||
'showroom' => $this->showroom,
|
|
||||||
'origin_city' => (string) $this->origin_city,
|
|
||||||
'origin_distance' => (string) $this->origin_distance,
|
|
||||||
'origin_role' => (string) $this->origin_role,
|
|
||||||
'has_detail_page' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Spatie\Translatable\HasTranslations;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Referenzprojekt der Interior-Welt: ein realisierter Auftrag mit Titelbild, weiteren Bildern
|
|
||||||
* und den beteiligten Kernmarken (Nachtrag 13). Erscheint auf /referenzen.
|
|
||||||
*
|
|
||||||
* Nicht zu verwechseln mit {@see CmsProject} – das sind die Immobilienprojekte.
|
|
||||||
*/
|
|
||||||
class CmsReference extends Model
|
|
||||||
{
|
|
||||||
use HasFactory, HasTranslations;
|
|
||||||
|
|
||||||
protected $table = 'cms_references';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'title',
|
|
||||||
'location',
|
|
||||||
'year',
|
|
||||||
'description',
|
|
||||||
'image',
|
|
||||||
'gallery',
|
|
||||||
'brand_slugs',
|
|
||||||
'is_published',
|
|
||||||
'order',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @var array<string> */
|
|
||||||
public array $translatable = ['title', 'description'];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'gallery' => 'array',
|
|
||||||
'brand_slugs' => 'array',
|
|
||||||
'is_published' => 'boolean',
|
|
||||||
'order' => 'integer',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopePublished(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->where('is_published', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scopeOrdered(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query->orderBy('order')->orderBy('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Galerie in eine feste Form bringen: nur Einträge mit Bild, Bildunterschrift getrimmt.
|
|
||||||
*
|
|
||||||
* @param mixed $gallery
|
|
||||||
* @return list<array{image: string, caption: string}>
|
|
||||||
*/
|
|
||||||
public static function normalizeGallery($gallery): array
|
|
||||||
{
|
|
||||||
if (! is_array($gallery)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($gallery as $item) {
|
|
||||||
$image = trim((string) (is_array($item) ? ($item['image'] ?? '') : $item));
|
|
||||||
|
|
||||||
if ($image === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'image' => $image,
|
|
||||||
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Beteiligte Marken als Liste mit Name und Link, aufgelöst gegen die veröffentlichten Marken.
|
|
||||||
*
|
|
||||||
* Eine unveröffentlichte oder gelöschte Marke fällt heraus.
|
|
||||||
*
|
|
||||||
* @param array<string, array<string, mixed>> $brands Ergebnis von CmsBrand::forFrontend()
|
|
||||||
* @return list<array{slug: string, name: string, link: ?string}>
|
|
||||||
*/
|
|
||||||
public function resolveBrands(array $brands): array
|
|
||||||
{
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($this->brand_slugs ?? [] as $slug) {
|
|
||||||
$slug = (string) $slug;
|
|
||||||
|
|
||||||
if (! isset($brands[$slug])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'slug' => $slug,
|
|
||||||
'name' => (string) ($brands[$slug]['name'] ?? $slug),
|
|
||||||
'link' => ! empty($brands[$slug]['has_detail_page']) ? '/marken/'.$slug : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Support\OperationalAlert;
|
|
||||||
use Illuminate\Foundation\Console\AboutCommand;
|
|
||||||
use Illuminate\Queue\Events\JobFailed;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
|
@ -33,17 +29,5 @@ class AppServiceProvider extends ServiceProvider
|
||||||
if ($scheme === 'https') {
|
if ($scheme === 'https') {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
|
|
||||||
Queue::failing(function (JobFailed $event): void {
|
|
||||||
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
|
|
||||||
'connection' => $event->connectionName,
|
|
||||||
'queue' => $event->job->getQueue(),
|
|
||||||
'exception' => $event->exception->getMessage(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ausgelieferte Version in `php artisan about` (ABOUT_SECTION in dev/betrieb/deploy.sh)
|
|
||||||
AboutCommand::add('B2in', fn (): array => ['Version' => config('app.version') ?? '—']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,18 +57,6 @@ class ThemeServiceProvider extends ServiceProvider
|
||||||
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
|
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unbekannter Host (Vorschau-, Staging- oder IP-Aufruf): Bis hierher stünde in `url` noch
|
|
||||||
// APP_URL bzw. der Vorgabewert aus config/domains.php – also eine *fremde* Domain. Alle
|
|
||||||
// Assets und Links würden dorthin zeigen, obwohl die Instanz unter einem anderen Namen
|
|
||||||
// läuft. Außerhalb von `production` gilt deshalb der tatsächliche Request-Origin.
|
|
||||||
//
|
|
||||||
// In `production` bleibt es bewusst bei der konfigurierten URL: dort darf ein gefälschter
|
|
||||||
// Host-Header keine Links auf eine fremde Domain erzeugen.
|
|
||||||
if ($domainConfig['domain_name'] !== $host && config('app.env') !== 'production') {
|
|
||||||
$domainConfig['domain_name'] = $host;
|
|
||||||
$domainConfig['url'] = Request::getSchemeAndHttpHost();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
|
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
|
||||||
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
|
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
|
||||||
$assetUrl = $domainConfig['url'];
|
$assetUrl = $domainConfig['url'];
|
||||||
|
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use FluxCms\Core\Models\CmsContent;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seitenbaum für den Inhalte-Editor (config/cms_pages.php, Nachtrag 12).
|
|
||||||
*
|
|
||||||
* Verbindet die Registry mit dem Datenbestand: je Sektion, ob sie in flux_cms_contents liegt
|
|
||||||
* (dann mit `content_id`), und was sonst noch in der Datenbank steht, aber in keiner Seite
|
|
||||||
* eingetragen ist („Nicht mehr verwendet“).
|
|
||||||
*/
|
|
||||||
class CmsPageTree
|
|
||||||
{
|
|
||||||
/** Schlüssel des Sammelpunkts für nicht eingetragene Sektionen. */
|
|
||||||
public const UNLISTED = 'unlisted';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alle Seiten mit ihren Sektionen in Website-Reihenfolge.
|
|
||||||
*
|
|
||||||
* @return array<string, array{title: string, path: ?string, sections: list<array<string, mixed>>}>
|
|
||||||
*/
|
|
||||||
public function pages(): array
|
|
||||||
{
|
|
||||||
$rows = CmsContent::query()
|
|
||||||
->get(['id', 'group', 'key'])
|
|
||||||
->keyBy(fn (CmsContent $row) => $row->group.'/'.$row->key);
|
|
||||||
|
|
||||||
$pages = [];
|
|
||||||
|
|
||||||
foreach (config('cms_pages.pages', []) as $pageKey => $page) {
|
|
||||||
$sections = [];
|
|
||||||
|
|
||||||
foreach ($page['sections'] ?? [] as $section) {
|
|
||||||
$id = $section['group'].'/'.$section['key'];
|
|
||||||
|
|
||||||
$sections[] = array_merge($section, [
|
|
||||||
'page' => $pageKey,
|
|
||||||
'path' => $section['path'] ?? $page['path'] ?? null,
|
|
||||||
'anchor' => $section['anchor'] ?? null,
|
|
||||||
'content_id' => $rows[$id]->id ?? null,
|
|
||||||
'theme_key' => $this->themeKey($section['group'], $section['key']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pages[$pageKey] = [
|
|
||||||
'title' => $page['title'],
|
|
||||||
'path' => $page['path'] ?? null,
|
|
||||||
'sections' => $sections,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$unlisted = $this->unlistedSections($rows->keys()->all(), $rows);
|
|
||||||
|
|
||||||
if ($unlisted !== []) {
|
|
||||||
$pages[self::UNLISTED] = [
|
|
||||||
'title' => config('cms_pages.unlisted.title', 'Nicht mehr verwendet'),
|
|
||||||
'path' => null,
|
|
||||||
'sections' => $unlisted,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $pages;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Eine Sektion aus der Registry samt Datenbankbezug, oder null.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
public function section(string $group, string $key): ?array
|
|
||||||
{
|
|
||||||
foreach ($this->pages() as $page) {
|
|
||||||
foreach ($page['sections'] as $section) {
|
|
||||||
if ($section['group'] === $group && $section['key'] === $key) {
|
|
||||||
return $section;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Klartext-Beschriftung eines Feldes: Sektions-Override, dann Wörterbuch, sonst lesbar gemacht.
|
|
||||||
*/
|
|
||||||
public function fieldLabel(string $field, ?string $group = null, ?string $key = null): string
|
|
||||||
{
|
|
||||||
if ($group !== null && $key !== null) {
|
|
||||||
foreach (config('cms_pages.pages', []) as $page) {
|
|
||||||
foreach ($page['sections'] ?? [] as $section) {
|
|
||||||
if ($section['group'] === $group && $section['key'] === $key && isset($section['fields'][$field])) {
|
|
||||||
return $section['fields'][$field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$labels = config('cms_pages.field_labels', []);
|
|
||||||
|
|
||||||
if (isset($labels[$field])) {
|
|
||||||
return $labels[$field];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Str::ucfirst(str_replace('_', ' ', $field));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adresse der Vorschau für eine Sektion: Frontend-Domain, Pfad, Cache-Brecher und Sprungmarke.
|
|
||||||
*/
|
|
||||||
public function previewUrl(?array $section, int $version = 0): ?string
|
|
||||||
{
|
|
||||||
$path = $section['path'] ?? null;
|
|
||||||
|
|
||||||
if ($path === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = rtrim(config('domains.protocol', 'https://').config('domains.domain_b2in'), '/')
|
|
||||||
.'/'.ltrim($path, '/')
|
|
||||||
.'?cms-preview='.$version;
|
|
||||||
|
|
||||||
if (! empty($section['anchor'])) {
|
|
||||||
$url .= '#'.$section['anchor'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme-Section-Schlüssel (Lang-Datei) zu Gruppe/Key, für „Aus Vorlage anlegen“.
|
|
||||||
*/
|
|
||||||
public function themeKey(string $group, string $key): ?string
|
|
||||||
{
|
|
||||||
foreach (config('cms_section_map.sections', []) as $themeKey => $target) {
|
|
||||||
if ($target === [$group, $key]) {
|
|
||||||
return $themeKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $ids
|
|
||||||
* @param \Illuminate\Support\Collection<string, CmsContent> $rows
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function unlistedSections(array $ids, $rows): array
|
|
||||||
{
|
|
||||||
$listed = [];
|
|
||||||
|
|
||||||
foreach (config('cms_pages.pages', []) as $page) {
|
|
||||||
foreach ($page['sections'] ?? [] as $section) {
|
|
||||||
$listed[$section['group'].'/'.$section['key']] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
|
|
||||||
foreach ($ids as $id) {
|
|
||||||
if (isset($listed[$id])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $rows[$id];
|
|
||||||
|
|
||||||
$out[] = [
|
|
||||||
'group' => $row->group,
|
|
||||||
'key' => $row->key,
|
|
||||||
'page' => self::UNLISTED,
|
|
||||||
'path' => null,
|
|
||||||
'anchor' => null,
|
|
||||||
'title' => $row->group.' / '.$row->key,
|
|
||||||
'description' => null,
|
|
||||||
'content_id' => $row->id,
|
|
||||||
'theme_key' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($out, fn (array $a, array $b) => [$a['group'], $a['key']] <=> [$b['group'], $b['key']]);
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services\Monitoring;
|
|
||||||
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Die Prüfungen, die ein externer Monitor jede Minute abfragt
|
|
||||||
*: Datenbank, Cache, Queue-Rückstau,
|
|
||||||
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
|
|
||||||
* große Tabellen, damit `/health` selbst keine Last erzeugt.
|
|
||||||
*
|
|
||||||
* Ein Admin-Systemstatus kann `scheduler()` und `disk()` mitbenutzen.
|
|
||||||
*/
|
|
||||||
class HealthProbe
|
|
||||||
{
|
|
||||||
public const StatusOk = 'ok';
|
|
||||||
|
|
||||||
public const StatusWarn = 'warn';
|
|
||||||
|
|
||||||
public const StatusCritical = 'critical';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Datei statt Datenbank/Cache: übersteht `cache:clear`, braucht keine
|
|
||||||
* Migration. Bei mehreren App-Servern stattdessen eine DB-Zeile nehmen.
|
|
||||||
*/
|
|
||||||
public static function heartbeatPath(): string
|
|
||||||
{
|
|
||||||
return (string) (config('monitoring.scheduler.heartbeat_path') ?: storage_path('framework/scheduler-heartbeat'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function recordHeartbeat(): void
|
|
||||||
{
|
|
||||||
file_put_contents(self::heartbeatPath(), now()->toIso8601String(), LOCK_EX);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastHeartbeat(): ?Carbon
|
|
||||||
{
|
|
||||||
$value = @file_get_contents(self::heartbeatPath());
|
|
||||||
|
|
||||||
return is_string($value) && trim($value) !== '' ? Carbon::parse(trim($value)) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{key: string, label: string, status: string, detail: string}>
|
|
||||||
*/
|
|
||||||
public function checks(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
$this->database(),
|
|
||||||
$this->cache(),
|
|
||||||
$this->queue(),
|
|
||||||
$this->scheduler(),
|
|
||||||
$this->disk(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array{status: string}> $checks
|
|
||||||
*/
|
|
||||||
public static function worstStatus(array $checks): string
|
|
||||||
{
|
|
||||||
$statuses = array_column($checks, 'status');
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
in_array(self::StatusCritical, $statuses, true) => self::StatusCritical,
|
|
||||||
in_array(self::StatusWarn, $statuses, true) => self::StatusWarn,
|
|
||||||
default => self::StatusOk,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{key: string, label: string, status: string, detail: string}
|
|
||||||
*/
|
|
||||||
public function scheduler(): array
|
|
||||||
{
|
|
||||||
$lastBeat = $this->lastHeartbeat();
|
|
||||||
$warnMinutes = (int) config('monitoring.scheduler.heartbeat_warn_minutes');
|
|
||||||
$criticalMinutes = (int) config('monitoring.scheduler.heartbeat_critical_minutes');
|
|
||||||
$minutesAgo = $lastBeat !== null ? (int) $lastBeat->diffInMinutes(now(), absolute: true) : null;
|
|
||||||
|
|
||||||
return $this->check(
|
|
||||||
'scheduler',
|
|
||||||
'Scheduler',
|
|
||||||
match (true) {
|
|
||||||
$minutesAgo === null, $minutesAgo >= $criticalMinutes => self::StatusCritical,
|
|
||||||
$minutesAgo >= $warnMinutes => self::StatusWarn,
|
|
||||||
default => self::StatusOk,
|
|
||||||
},
|
|
||||||
$lastBeat === null
|
|
||||||
? 'Noch kein Herzschlag — läuft `schedule:run` per Cron (bzw. `schedule:work`)?'
|
|
||||||
: 'Letzter Herzschlag '.$lastBeat->format('d.m.Y H:i:s').' (vor '.$minutesAgo.' Min.).',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{key: string, label: string, status: string, detail: string}
|
|
||||||
*/
|
|
||||||
public function disk(): array
|
|
||||||
{
|
|
||||||
$path = storage_path();
|
|
||||||
$free = @disk_free_space($path);
|
|
||||||
$total = @disk_total_space($path);
|
|
||||||
|
|
||||||
if ($free === false || $total === false || $total <= 0) {
|
|
||||||
return $this->check('disk', 'Plattenplatz', self::StatusWarn, 'Plattenplatz nicht ermittelbar.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$freePercent = round($free / $total * 100, 1);
|
|
||||||
$minPercent = (float) config('monitoring.health.disk_min_free_percent');
|
|
||||||
|
|
||||||
return $this->check(
|
|
||||||
'disk',
|
|
||||||
'Plattenplatz',
|
|
||||||
match (true) {
|
|
||||||
$freePercent < $minPercent => self::StatusCritical,
|
|
||||||
$freePercent < $minPercent * 2 => self::StatusWarn,
|
|
||||||
default => self::StatusOk,
|
|
||||||
},
|
|
||||||
sprintf('%s %% frei (%s GB von %s GB).', $freePercent, round($free / 1024 ** 3, 1), round($total / 1024 ** 3, 1)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{key: string, label: string, status: string, detail: string}
|
|
||||||
*/
|
|
||||||
private function database(): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
DB::select('select 1');
|
|
||||||
|
|
||||||
return $this->check('database', 'Datenbank', self::StatusOk, 'Verbindung steht.');
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
return $this->check('database', 'Datenbank', self::StatusCritical, 'Keine Verbindung: '.Str::limit($exception->getMessage(), 120));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{key: string, label: string, status: string, detail: string}
|
|
||||||
*/
|
|
||||||
private function cache(): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$value = Str::random(8);
|
|
||||||
Cache::put('monitoring:health-probe', $value, 60);
|
|
||||||
|
|
||||||
return Cache::get('monitoring:health-probe') === $value
|
|
||||||
? $this->check('cache', 'Cache', self::StatusOk, 'Schreiben und Lesen funktionieren.')
|
|
||||||
: $this->check('cache', 'Cache', self::StatusCritical, 'Geschriebener Wert kommt nicht zurück.');
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
return $this->check('cache', 'Cache', self::StatusCritical, 'Nicht erreichbar: '.Str::limit($exception->getMessage(), 120));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{key: string, label: string, status: string, detail: string}
|
|
||||||
*/
|
|
||||||
private function queue(): array
|
|
||||||
{
|
|
||||||
if (config('queue.default') !== 'database') {
|
|
||||||
return $this->check('queue', 'Queue', self::StatusOk, 'Treiber '.config('queue.default').' — Rückstau hier nicht prüfbar.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$now = now()->getTimestamp();
|
|
||||||
$oldestAvailable = DB::table('jobs')
|
|
||||||
->whereNull('reserved_at')
|
|
||||||
->where('available_at', '<=', $now)
|
|
||||||
->min('available_at');
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
return $this->check('queue', 'Queue', self::StatusCritical, 'Jobs-Tabelle nicht lesbar: '.Str::limit($exception->getMessage(), 120));
|
|
||||||
}
|
|
||||||
|
|
||||||
$waitingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : 0;
|
|
||||||
$criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds');
|
|
||||||
|
|
||||||
return $this->check(
|
|
||||||
'queue',
|
|
||||||
'Queue',
|
|
||||||
$waitingSeconds >= $criticalSeconds ? self::StatusCritical : self::StatusOk,
|
|
||||||
$oldestAvailable === null
|
|
||||||
? 'Kein wartender Job.'
|
|
||||||
: 'Ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{key: string, label: string, status: string, detail: string}
|
|
||||||
*/
|
|
||||||
private function check(string $key, string $label, string $status, string $detail): array
|
|
||||||
{
|
|
||||||
return ['key' => $key, 'label' => $label, 'status' => $status, 'detail' => $detail];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -27,10 +27,8 @@ class DisplayModuleSettings
|
||||||
],
|
],
|
||||||
DisplayVersionType::B2in->value => [
|
DisplayVersionType::B2in->value => [
|
||||||
'theme' => 'dark',
|
'theme' => 'dark',
|
||||||
// Standard-Theme ist dunkel, das Logo liegt dort auf einem dunklen Scrim –
|
'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
|
||||||
// deshalb die negative (weisse) Variante.
|
'header_claim' => 'Connecting Design & Property',
|
||||||
'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg',
|
|
||||||
'header_claim' => 'Premium-Einrichtung. Persönlich gemacht.',
|
|
||||||
'logo_position' => 'top-left',
|
'logo_position' => 'top-left',
|
||||||
'claim_position' => 'top-right',
|
'claim_position' => 'top-right',
|
||||||
'show_logo' => true,
|
'show_logo' => true,
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Monitoring;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Sentry\Event;
|
|
||||||
use Sentry\EventHint;
|
|
||||||
use Sentry\UserDataBag;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
|
|
||||||
*
|
|
||||||
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
|
|
||||||
* in Query-Strings (signierte Links) und in Pfaden
|
|
||||||
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
|
|
||||||
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
|
|
||||||
* QueryException mit eingesetzten Bindings) ebenso.
|
|
||||||
*/
|
|
||||||
class ErrorReportScrubber
|
|
||||||
{
|
|
||||||
public const Filtered = '[Filtered]';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
|
|
||||||
* Je Projekt anpassen — dieselbe Liste steht in error-reporting.js.
|
|
||||||
*
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort'];
|
|
||||||
|
|
||||||
private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i';
|
|
||||||
|
|
||||||
/** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */
|
|
||||||
private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#';
|
|
||||||
|
|
||||||
public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event
|
|
||||||
{
|
|
||||||
$userId = self::authenticatedUserId();
|
|
||||||
|
|
||||||
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
|
|
||||||
$event->setRequest(self::scrubRequest($event->getRequest()));
|
|
||||||
|
|
||||||
foreach ($event->getExceptions() as $exception) {
|
|
||||||
$exception->setValue(self::scrubText($exception->getValue()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($event->getMessage() !== null) {
|
|
||||||
$formatted = $event->getMessageFormatted();
|
|
||||||
|
|
||||||
$event->setMessage(
|
|
||||||
self::scrubText($event->getMessage()),
|
|
||||||
$event->getMessageParams(),
|
|
||||||
$formatted !== null ? self::scrubText($formatted) : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $event;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $request
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function scrubRequest(array $request): array
|
|
||||||
{
|
|
||||||
if (isset($request['url']) && is_string($request['url'])) {
|
|
||||||
$request['url'] = self::scrubUrl($request['url']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($request['query_string']) && is_string($request['query_string'])) {
|
|
||||||
$request['query_string'] = self::scrubQuery($request['query_string']);
|
|
||||||
}
|
|
||||||
|
|
||||||
unset($request['data'], $request['cookies']);
|
|
||||||
|
|
||||||
foreach ($request['headers'] ?? [] as $name => $values) {
|
|
||||||
if (strtolower((string) $name) === 'referer') {
|
|
||||||
$request['headers'][$name] = array_map(
|
|
||||||
static fn (mixed $value): string => self::scrubUrl((string) $value),
|
|
||||||
(array) $values,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $request;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function scrubUrl(string $url): string
|
|
||||||
{
|
|
||||||
$query = parse_url($url, PHP_URL_QUERY);
|
|
||||||
$withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url;
|
|
||||||
$scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery));
|
|
||||||
|
|
||||||
return $query !== null && $query !== false && $query !== ''
|
|
||||||
? $scrubbedPath.'?'.self::scrubQuery($query)
|
|
||||||
: $scrubbedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function scrubQuery(string $query): string
|
|
||||||
{
|
|
||||||
parse_str($query, $parameters);
|
|
||||||
|
|
||||||
$scrubbed = [];
|
|
||||||
foreach ($parameters as $name => $value) {
|
|
||||||
$isAllowed = in_array((string) $name, self::AllowedQueryParameters, true)
|
|
||||||
|| str_starts_with((string) $name, 'utm_');
|
|
||||||
|
|
||||||
$scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function scrubPath(string $path): string
|
|
||||||
{
|
|
||||||
return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function scrubText(string $text): string
|
|
||||||
{
|
|
||||||
return (string) preg_replace(self::EmailPattern, '[email]', $text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet
|
|
||||||
* (`token`, `hash`, `signature`), im Pfad der aktuellen Route.
|
|
||||||
*/
|
|
||||||
private static function scrubPathTokens(string $url): string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$route = request()->route();
|
|
||||||
} catch (Throwable) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($route === null) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($route->parameters() as $name => $value) {
|
|
||||||
if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) {
|
|
||||||
$url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function authenticatedUserId(): int|string|null
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return Auth::id();
|
|
||||||
} catch (Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Monitoring;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Sentry\Severity;
|
|
||||||
use Sentry\State\Scope;
|
|
||||||
|
|
||||||
use function Sentry\captureMessage;
|
|
||||||
use function Sentry\withScope;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fehler ohne Exception als GlitchTip-Warnung: tote
|
|
||||||
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
|
|
||||||
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
|
|
||||||
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
|
|
||||||
*/
|
|
||||||
class ErrorSignal
|
|
||||||
{
|
|
||||||
public const DeadInternalLink = 'dead_internal_link';
|
|
||||||
|
|
||||||
public const AccessDenied = 'access_denied';
|
|
||||||
|
|
||||||
public const PageExpired = 'page_expired';
|
|
||||||
|
|
||||||
public const Throttled = 'throttled';
|
|
||||||
|
|
||||||
private const Labels = [
|
|
||||||
self::DeadInternalLink => 'Toter interner Link',
|
|
||||||
self::AccessDenied => 'Zugriff verweigert (angemeldet)',
|
|
||||||
self::PageExpired => 'Seite abgelaufen (419)',
|
|
||||||
self::Throttled => 'Drosselung trifft angemeldeten Nutzer',
|
|
||||||
];
|
|
||||||
|
|
||||||
private const MaxReportsPerWindow = 5;
|
|
||||||
|
|
||||||
private const WindowSeconds = 600;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public function report(string $signal, string $location, array $context = []): bool
|
|
||||||
{
|
|
||||||
$location = Str::limit($location, 200, '');
|
|
||||||
|
|
||||||
$reported = RateLimiter::attempt(
|
|
||||||
'error-signal:'.sha1($signal.'|'.$location),
|
|
||||||
self::MaxReportsPerWindow,
|
|
||||||
static fn (): bool => true,
|
|
||||||
self::WindowSeconds,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($reported === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
withScope(function (Scope $scope) use ($signal, $location, $context): void {
|
|
||||||
$scope->setFingerprint(['error-signal', $signal, $location]);
|
|
||||||
$scope->setTag('signal', $signal);
|
|
||||||
$scope->setContext('signal', $context);
|
|
||||||
|
|
||||||
captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning());
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support\Monitoring;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
|
|
||||||
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
|
|
||||||
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
|
|
||||||
* beim Deploy entsteht (`git describe --tags --always --dirty > RELEASE`) —
|
|
||||||
* der Live-Server hat kein Git, solange per SFTP deployt wird.
|
|
||||||
*
|
|
||||||
* Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere,
|
|
||||||
* Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren
|
|
||||||
* Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“.
|
|
||||||
*/
|
|
||||||
class Release
|
|
||||||
{
|
|
||||||
public static function current(?string $configured, ?string $basePath = null): ?string
|
|
||||||
{
|
|
||||||
$configured = trim((string) $configured);
|
|
||||||
|
|
||||||
if ($configured !== '') {
|
|
||||||
return $configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = rtrim($basePath ?? base_path(), '/').'/RELEASE';
|
|
||||||
|
|
||||||
if (! is_file($file)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$release = trim((string) file_get_contents($file));
|
|
||||||
|
|
||||||
return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Support;
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zentrale Betriebs-Alerts: meldet fehlgeschlagene Scheduler-Tasks und
|
|
||||||
* Queue-Jobs (Verdrahtung: siehe 03-betriebsueberwachung.md). Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
|
|
||||||
* immer; Mail und ntfy-Push sind Best-Effort, unabhängig
|
|
||||||
* voneinander, und dürfen den auslösenden Prozess nie mit in den Abgrund
|
|
||||||
* reißen (Mailserver down, ntfy nicht erreichbar o. ä.).
|
|
||||||
*/
|
|
||||||
class OperationalAlert
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
public static function send(string $summary, array $context = []): void
|
|
||||||
{
|
|
||||||
Log::channel('monitoring')->critical('[monitoring] '.$summary, $context);
|
|
||||||
|
|
||||||
if (! config('monitoring.alerts.enabled')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self::mail($summary, $context);
|
|
||||||
self::push($summary, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private static function mail(string $summary, array $context): void
|
|
||||||
{
|
|
||||||
$recipient = config('monitoring.alerts.mail_to');
|
|
||||||
|
|
||||||
if (empty($recipient)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Mail::raw(
|
|
||||||
$summary."\n\n".json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
||||||
fn ($message) => $message->to($recipient)->subject('['.config('app.name').'] Betriebs-Alert: '.Str::limit($summary, 80)),
|
|
||||||
);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
|
|
||||||
'summary' => $summary,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header).
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private static function push(string $summary, array $context): void
|
|
||||||
{
|
|
||||||
$url = (string) config('monitoring.alerts.ntfy.url');
|
|
||||||
$topic = (string) config('monitoring.alerts.ntfy.topic');
|
|
||||||
|
|
||||||
if ($url === '' || $topic === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$details = collect($context)
|
|
||||||
->map(fn (mixed $value, string $key): string => $key.': '.(is_scalar($value) || $value === null ? (string) $value : json_encode($value)))
|
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
Http::timeout(5)
|
|
||||||
->connectTimeout(3)
|
|
||||||
->when(filled(config('monitoring.alerts.ntfy.token')), fn ($request) => $request->withToken((string) config('monitoring.alerts.ntfy.token')))
|
|
||||||
->post(rtrim($url, '/'), [
|
|
||||||
'topic' => $topic,
|
|
||||||
'title' => config('app.name').' ('.app()->environment().'): Betriebs-Alert',
|
|
||||||
'message' => Str::limit(trim($summary."\n\n".$details), 3500),
|
|
||||||
'priority' => (int) config('monitoring.alerts.ntfy.priority'),
|
|
||||||
'tags' => ['rotating_light'],
|
|
||||||
])
|
|
||||||
->throw();
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::channel('monitoring')->error('[monitoring] ntfy-Push konnte nicht versendet werden.', [
|
|
||||||
'summary' => $summary,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
162
app/helpers.php
162
app/helpers.php
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use FluxCms\Core\Models\CmsMedia;
|
use FluxCms\Core\Models\CmsMedia;
|
||||||
use FluxCms\Core\Services\CmsContentService;
|
use FluxCms\Core\Services\CmsContentService;
|
||||||
|
|
||||||
|
|
@ -49,6 +48,8 @@ if (! function_exists('cms_theme_section')) {
|
||||||
|
|
||||||
/** @var array<string, array{0: string, 1: string}> $sections */
|
/** @var array<string, array{0: string, 1: string}> $sections */
|
||||||
$sections = config('cms_section_map.sections', []);
|
$sections = config('cms_section_map.sections', []);
|
||||||
|
/** @var array<string, string> $langKeys */
|
||||||
|
$langKeys = config('cms_section_map.lang_keys', []);
|
||||||
|
|
||||||
if (isset($sections[$sectionKey])) {
|
if (isset($sections[$sectionKey])) {
|
||||||
[$group, $key] = $sections[$sectionKey];
|
[$group, $key] = $sections[$sectionKey];
|
||||||
|
|
@ -59,47 +60,9 @@ if (! function_exists('cms_theme_section')) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return trans("b2in.themes.{$theme}.{$sectionKey}");
|
$langKey = $langKeys[$sectionKey] ?? $sectionKey;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! function_exists('references_page_enabled')) {
|
return trans("b2in.themes.{$theme}.{$langKey}");
|
||||||
/**
|
|
||||||
* Ob die Seite /referenzen samt Menüpunkt sichtbar ist – Schalter im CMS-Modul „Referenzen“
|
|
||||||
* (Einstellung pages.referenzen_enabled). Ohne Eintrag ist die Seite sichtbar.
|
|
||||||
*/
|
|
||||||
function references_page_enabled(): bool
|
|
||||||
{
|
|
||||||
return (bool) Setting::getValue('pages', 'referenzen_enabled', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! function_exists('whatsapp_link')) {
|
|
||||||
/**
|
|
||||||
* Klick-zum-Chat-Link, sofern im CMS eine Nummer hinterlegt ist (global.contact.whatsapp).
|
|
||||||
*
|
|
||||||
* Liefert einen leeren String, solange keine Nummer bestaetigt ist – der Button erscheint dann
|
|
||||||
* gar nicht erst, statt auf eine erfundene Nummer zu zeigen.
|
|
||||||
*/
|
|
||||||
function whatsapp_link(): string
|
|
||||||
{
|
|
||||||
$service = app(CmsContentService::class);
|
|
||||||
|
|
||||||
// Die Nummer kann als eigener Eintrag oder als Feld der Sektion `global.contact` liegen.
|
|
||||||
$number = $service->getIfExists('global.contact.whatsapp');
|
|
||||||
|
|
||||||
if ($number === null) {
|
|
||||||
$contact = $service->getIfExists('global.contact');
|
|
||||||
$number = is_array($contact) ? ($contact['whatsapp'] ?? null) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($number)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$digits = preg_replace('/\D+/', '', $number);
|
|
||||||
|
|
||||||
return $digits === '' ? '' : 'https://wa.me/'.$digits;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,13 +94,6 @@ if (! function_exists('theme_image_url')) {
|
||||||
return asset('storage/'.$trimmed);
|
return asset('storage/'.$trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Liegt die Datei unter public/img/assets/, kommt sie von dort – auch wenn sie inzwischen in
|
|
||||||
// der Mediathek steht (AssetMediaImportSeeder). Nur so findet <x-web-picture> die WebP-Fassung
|
|
||||||
// neben JPG/PNG.
|
|
||||||
if (str_contains($trimmed, '/') && is_file(public_path('img/assets/'.$trimmed))) {
|
|
||||||
return asset('img/assets/'.$trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)])));
|
$candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)])));
|
||||||
foreach ($candidates as $candidate) {
|
foreach ($candidates as $candidate) {
|
||||||
if (CmsMedia::query()->where('filename', $candidate)->exists()) {
|
if (CmsMedia::query()->where('filename', $candidate)->exists()) {
|
||||||
|
|
@ -167,14 +123,29 @@ if (! function_exists('tcms')) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! function_exists('cms_media_url')) {
|
||||||
|
/**
|
||||||
|
* Resolve a CMS content key (type=image) to a full media URL.
|
||||||
|
* Falls back to asset('assets/images/...') if not found in media library.
|
||||||
|
*/
|
||||||
|
function cms_media_url(string $key, string $profile = ''): string
|
||||||
|
{
|
||||||
|
$filename = cms($key);
|
||||||
|
|
||||||
|
if (! $filename || ! is_string($filename) || $filename === $key) {
|
||||||
|
$fallback = str_replace('.', '/', $key);
|
||||||
|
|
||||||
|
return asset('assets/images/'.basename($fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
return media_url($filename, $profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! function_exists('media_url')) {
|
if (! function_exists('media_url')) {
|
||||||
/**
|
/**
|
||||||
* Resolve a CmsMedia filename to its full storage URL.
|
* Resolve a CmsMedia filename to its full storage URL.
|
||||||
* Cached per request to avoid repeated DB queries.
|
* Uses in-memory cache to avoid repeated DB queries.
|
||||||
*
|
|
||||||
* Ohne Eintrag in der Mediathek zeigt die URL auf public/img/assets/ – dort legen die Seeder
|
|
||||||
* Marken-, Magazin- und Slideshow-Bilder ab (wie theme_image_url() im Frontend). Der frühere
|
|
||||||
* Rückfall assets/images/ existierte nicht und lieferte im Admin 404 (07.10.2026).
|
|
||||||
*/
|
*/
|
||||||
function media_url(?string $filename, string $profile = ''): string
|
function media_url(?string $filename, string $profile = ''): string
|
||||||
{
|
{
|
||||||
|
|
@ -182,80 +153,27 @@ if (! function_exists('media_url')) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// once(): je Request (und je Test) zwischengespeichert, nicht über die ganze Prozesslaufzeit.
|
static $resolved = [];
|
||||||
return once(function () use ($filename, $profile): string {
|
$cacheKey = $filename.'|'.$profile;
|
||||||
$media = CmsMedia::where('filename', $filename)->first();
|
|
||||||
|
|
||||||
if (! $media) {
|
if (isset($resolved[$cacheKey])) {
|
||||||
return asset('img/assets/'.ltrim($filename, '/'));
|
return $resolved[$cacheKey];
|
||||||
}
|
|
||||||
|
|
||||||
return $profile && $media->hasConversion($profile)
|
|
||||||
? $media->getConversionUrl($profile)
|
|
||||||
: $media->getUrl();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! function_exists('b2in_world')) {
|
|
||||||
/**
|
|
||||||
* Farbwelt der aktuellen Seite: `interior` (Standard) oder `immobilien`.
|
|
||||||
*
|
|
||||||
* Die Immobilien-Welt umfasst die Immobilien-Routen samt Detailseiten und den
|
|
||||||
* Entwicklungs-Vorschauen. Das Layout setzt den Wert als Body-Klasse
|
|
||||||
* (`world-interior` / `world-immobilien`), theme-b2in.css schaltet daran die Farben um.
|
|
||||||
* Livewire-Komponenten merken sich die Welt in mount(), weil ein späterer
|
|
||||||
* Livewire-Request keine Seitenroute mehr trägt.
|
|
||||||
*/
|
|
||||||
function b2in_world(): string
|
|
||||||
{
|
|
||||||
if (app()->bound('b2in.world')) {
|
|
||||||
return (string) app('b2in.world');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$routeName = request()->route()?->getName();
|
$media = CmsMedia::where('filename', $filename)->first();
|
||||||
|
|
||||||
if (is_string($routeName) && (str_starts_with($routeName, 'immobilien') || str_starts_with($routeName, 'dev.immobilien'))) {
|
if (! $media) {
|
||||||
return 'immobilien';
|
$resolved[$cacheKey] = asset('assets/images/'.$filename);
|
||||||
|
|
||||||
|
return $resolved[$cacheKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'interior';
|
if ($profile && $media->hasConversion($profile)) {
|
||||||
}
|
$resolved[$cacheKey] = $media->getConversionUrl($profile);
|
||||||
}
|
} else {
|
||||||
|
$resolved[$cacheKey] = $media->getUrl();
|
||||||
if (! function_exists('b2in_worlds')) {
|
}
|
||||||
/**
|
|
||||||
* Die beiden Welten für den Welten-Wechsler in Kopf- und Fußzeile.
|
return $resolved[$cacheKey];
|
||||||
*
|
|
||||||
* @return array<string, array{label: string, url: string, active: bool}>
|
|
||||||
*/
|
|
||||||
function b2in_worlds(?string $current = null): array
|
|
||||||
{
|
|
||||||
$current = $current ?? b2in_world();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'interior' => [
|
|
||||||
'label' => __('ui.world_interior'),
|
|
||||||
'url' => url('/'),
|
|
||||||
'active' => $current === 'interior',
|
|
||||||
],
|
|
||||||
'immobilien' => [
|
|
||||||
'label' => __('ui.world_immobilien'),
|
|
||||||
'url' => route('immobilien'),
|
|
||||||
'active' => $current === 'immobilien',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! function_exists('b2in_world_set')) {
|
|
||||||
/**
|
|
||||||
* Welt für den laufenden Request festlegen, wenn sie nicht an der Route hängt –
|
|
||||||
* etwa auf der Magazin-Detailseite, die in der Welt ihres Beitrags rendert.
|
|
||||||
* Muss vor dem Rendern von Kopf- und Fußzeile geschehen (beide halten die Welt in mount() fest).
|
|
||||||
*/
|
|
||||||
function b2in_world_set(string $world): void
|
|
||||||
{
|
|
||||||
app()->instance('b2in.world', $world);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,14 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\HealthController;
|
|
||||||
use App\Http\Middleware\ReportErrorSignals;
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use Sentry\Laravel\Integration;
|
|
||||||
use Symfony\Component\Console\Exception\CommandNotFoundException;
|
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/domains.php',
|
web: __DIR__.'/../routes/domains.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
then: function (): void {
|
|
||||||
// Tiefer Health-Check für den externen Monitor (Uptime Kuma), auf jeder Domain
|
|
||||||
Route::get('/health', HealthController::class)
|
|
||||||
->middleware('throttle:30,1')
|
|
||||||
->name('health.deep');
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
|
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
|
||||||
|
|
@ -38,20 +27,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
|
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) –
|
|
||||||
// global, weil 404 vor dem Routing entsteht
|
|
||||||
$middleware->append(ReportErrorSignals::class);
|
|
||||||
|
|
||||||
// BasicAuth ganz am Ende, nach Session-Middleware
|
// BasicAuth ganz am Ende, nach Session-Middleware
|
||||||
if (env('BASIC_AUTH_ENABLED', true)) {
|
if (env('BASIC_AUTH_ENABLED', true)) {
|
||||||
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
|
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
// Gemeldete Exceptions an GlitchTip (ohne DSN ein No-op);
|
//
|
||||||
// Laravels dontReport (404, Validierung, Auth) gilt weiter
|
|
||||||
Integration::handles($exceptions);
|
|
||||||
|
|
||||||
// Vertippte artisan-Befehle sind kein Anwendungsfehler
|
|
||||||
$exceptions->dontReport(CommandNotFoundException::class);
|
|
||||||
})->create();
|
})->create();
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
"livewire/flux": "^2.1.1",
|
"livewire/flux": "^2.1.1",
|
||||||
"livewire/flux-pro": "^2.6",
|
"livewire/flux-pro": "^2.6",
|
||||||
"livewire/volt": "^1.7.0",
|
"livewire/volt": "^1.7.0",
|
||||||
"sentry/sentry-laravel": "^4.29",
|
|
||||||
"spatie/laravel-permission": "^6.17"
|
"spatie/laravel-permission": "^6.17"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
|
||||||
553
composer.lock
generated
553
composer.lock
generated
|
|
@ -4,11 +4,11 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f2a8196dfd6ef735f3e827805a8a1f94",
|
"content-hash": "9e07d437a063526b426b920d400db8b8",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "acme/contact-form",
|
"name": "acme/contact-form",
|
||||||
"version": "dev-feature/b2in-interior",
|
"version": "dev-master",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "path",
|
"type": "path",
|
||||||
"url": "packages/acme/contact-form",
|
"url": "packages/acme/contact-form",
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "acme/cookie-consent",
|
"name": "acme/cookie-consent",
|
||||||
"version": "dev-feature/b2in-interior",
|
"version": "dev-master",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "path",
|
"type": "path",
|
||||||
"url": "packages/acme/CookieConsent",
|
"url": "packages/acme/CookieConsent",
|
||||||
|
|
@ -860,7 +860,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "flux-cms/core",
|
"name": "flux-cms/core",
|
||||||
"version": "dev-feature/b2in-interior",
|
"version": "dev-master",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "path",
|
"type": "path",
|
||||||
"url": "packages/flux-cms/core",
|
"url": "packages/flux-cms/core",
|
||||||
|
|
@ -1603,66 +1603,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-02-19T13:11:17+00:00"
|
"time": "2026-02-19T13:11:17+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "jean85/pretty-package-versions",
|
|
||||||
"version": "2.1.1",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
|
||||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
|
||||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"composer-runtime-api": "^2.1.0",
|
|
||||||
"php": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.2",
|
|
||||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
|
||||||
"phpstan/phpstan": "^2.0",
|
|
||||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
|
||||||
"rector/rector": "^2.0",
|
|
||||||
"vimeo/psalm": "^4.3 || ^5.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "1.x-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Jean85\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Alessandro Lai",
|
|
||||||
"email": "alessandro.lai85@gmail.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A library to get pretty versions strings of installed dependencies",
|
|
||||||
"keywords": [
|
|
||||||
"composer",
|
|
||||||
"package",
|
|
||||||
"release",
|
|
||||||
"versions"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
|
||||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
|
||||||
},
|
|
||||||
"time": "2025-03-19T14:43:43+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "laravel/fortify",
|
"name": "laravel/fortify",
|
||||||
"version": "v1.35.0",
|
"version": "v1.35.0",
|
||||||
|
|
@ -3553,84 +3493,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-02-16T23:10:27+00:00"
|
"time": "2026-02-16T23:10:27+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "nyholm/psr7",
|
|
||||||
"version": "1.8.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/Nyholm/psr7.git",
|
|
||||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
|
||||||
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=7.2",
|
|
||||||
"psr/http-factory": "^1.0",
|
|
||||||
"psr/http-message": "^1.1 || ^2.0"
|
|
||||||
},
|
|
||||||
"provide": {
|
|
||||||
"php-http/message-factory-implementation": "1.0",
|
|
||||||
"psr/http-factory-implementation": "1.0",
|
|
||||||
"psr/http-message-implementation": "1.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"http-interop/http-factory-tests": "^0.9",
|
|
||||||
"php-http/message-factory": "^1.0",
|
|
||||||
"php-http/psr7-integration-tests": "^1.0",
|
|
||||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
|
|
||||||
"symfony/error-handler": "^4.4"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"branch-alias": {
|
|
||||||
"dev-master": "1.8-dev"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Nyholm\\Psr7\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Tobias Nyholm",
|
|
||||||
"email": "tobias.nyholm@gmail.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Martijn van der Ven",
|
|
||||||
"email": "martijn@vanderven.se"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A fast PHP7 implementation of PSR-7",
|
|
||||||
"homepage": "https://tnyholm.se",
|
|
||||||
"keywords": [
|
|
||||||
"psr-17",
|
|
||||||
"psr-7"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/Nyholm/psr7/issues",
|
|
||||||
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://github.com/Zegnat",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nyholm",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2024-09-09T07:06:30+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "paragonie/constant_time_encoding",
|
"name": "paragonie/constant_time_encoding",
|
||||||
"version": "v3.1.3",
|
"version": "v3.1.3",
|
||||||
|
|
@ -4516,195 +4378,6 @@
|
||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"time": "2025-12-14T04:43:48+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "sentry/sentry",
|
|
||||||
"version": "4.34.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/getsentry/sentry-php.git",
|
|
||||||
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/3d8f4e56becb10ede64e2ac6f8077f6434715461",
|
|
||||||
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-curl": "*",
|
|
||||||
"ext-json": "*",
|
|
||||||
"ext-mbstring": "*",
|
|
||||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0",
|
|
||||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
|
||||||
"php": "^7.2|^8.0",
|
|
||||||
"psr/log": "^1.0|^2.0|^3.0",
|
|
||||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"raven/raven": "*"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"carthage-software/mago": "1.30.0",
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.4",
|
|
||||||
"guzzlehttp/guzzle": "^7.0|^8.0",
|
|
||||||
"guzzlehttp/promises": "^2.0.3|^3.0.0",
|
|
||||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
|
||||||
"nyholm/psr7": "^1.8",
|
|
||||||
"open-telemetry/api": "^1.1",
|
|
||||||
"open-telemetry/context": "^1.1",
|
|
||||||
"open-telemetry/exporter-otlp": "^1.1",
|
|
||||||
"open-telemetry/gen-otlp-protobuf": "^1.2.1",
|
|
||||||
"open-telemetry/sdk": "^1.1",
|
|
||||||
"open-telemetry/sem-conv": "^1.27",
|
|
||||||
"phpstan/phpstan": "^2.2",
|
|
||||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
|
||||||
"spiral/roadrunner-http": "^3.6",
|
|
||||||
"spiral/roadrunner-worker": "^3.6"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
|
||||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"src/functions.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"Sentry\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Sentry",
|
|
||||||
"email": "accounts@sentry.io"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
|
||||||
"homepage": "http://sentry.io",
|
|
||||||
"keywords": [
|
|
||||||
"crash-reporting",
|
|
||||||
"crash-reports",
|
|
||||||
"error-handler",
|
|
||||||
"error-monitoring",
|
|
||||||
"log",
|
|
||||||
"logging",
|
|
||||||
"profiling",
|
|
||||||
"sentry",
|
|
||||||
"tracing"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
|
||||||
"source": "https://github.com/getsentry/sentry-php/tree/4.34.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/pricing/",
|
|
||||||
"type": "custom"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-10-08T11:25:58+00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sentry/sentry-laravel",
|
|
||||||
"version": "4.29.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/getsentry/sentry-laravel.git",
|
|
||||||
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b3deab3d8818490220e1da8e2d7cca971f74410",
|
|
||||||
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
|
||||||
"nyholm/psr7": "^1.0",
|
|
||||||
"php": "^7.2 | ^8.0",
|
|
||||||
"sentry/sentry": "^4.34.0",
|
|
||||||
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"friendsofphp/php-cs-fixer": "^3.11",
|
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
|
||||||
"laravel/ai": "^0.1.5 | ^1.0",
|
|
||||||
"laravel/folio": "^1.1",
|
|
||||||
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
|
|
||||||
"laravel/octane": "^2.15",
|
|
||||||
"laravel/pennant": "^1.0",
|
|
||||||
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
|
|
||||||
"mockery/mockery": "^1.3",
|
|
||||||
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
|
|
||||||
"phpstan/phpstan": "^1.10 | ^2.2",
|
|
||||||
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"aliases": {
|
|
||||||
"Sentry": "Sentry\\Laravel\\Facade"
|
|
||||||
},
|
|
||||||
"providers": [
|
|
||||||
"Sentry\\Laravel\\ServiceProvider",
|
|
||||||
"Sentry\\Laravel\\Tracing\\ServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-0": {
|
|
||||||
"Sentry\\Laravel\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Sentry",
|
|
||||||
"email": "accounts@sentry.io"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Laravel SDK for Sentry (https://sentry.io)",
|
|
||||||
"homepage": "https://sentry.io",
|
|
||||||
"keywords": [
|
|
||||||
"crash-reporting",
|
|
||||||
"crash-reports",
|
|
||||||
"error-handler",
|
|
||||||
"error-monitoring",
|
|
||||||
"laravel",
|
|
||||||
"log",
|
|
||||||
"logging",
|
|
||||||
"profiling",
|
|
||||||
"sentry",
|
|
||||||
"tracing"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/getsentry/sentry-laravel/issues",
|
|
||||||
"source": "https://github.com/getsentry/sentry-laravel/tree/4.29.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://sentry.io/pricing/",
|
|
||||||
"type": "custom"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-10-08T12:31:48+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "spatie/laravel-package-tools",
|
"name": "spatie/laravel-package-tools",
|
||||||
"version": "1.93.0",
|
"version": "1.93.0",
|
||||||
|
|
@ -5928,77 +5601,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-02-05T15:57:06+00:00"
|
"time": "2026-02-05T15:57:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/options-resolver",
|
|
||||||
"version": "v8.1.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/options-resolver.git",
|
|
||||||
"reference": "88f9c561f678a02d54b897014049fa839e33ff82"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
|
|
||||||
"reference": "88f9c561f678a02d54b897014049fa839e33ff82",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4.1",
|
|
||||||
"symfony/deprecation-contracts": "^2.5|^3"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Component\\OptionsResolver\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Provides an improved replacement for the array_replace PHP function",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"config",
|
|
||||||
"configuration",
|
|
||||||
"options"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-ctype",
|
"name": "symfony/polyfill-ctype",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
|
|
@ -6893,93 +6495,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-01-26T15:07:59+00:00"
|
"time": "2026-01-26T15:07:59+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/psr-http-message-bridge",
|
|
||||||
"version": "v8.1.0",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/psr-http-message-bridge.git",
|
|
||||||
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c",
|
|
||||||
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4.1",
|
|
||||||
"psr/http-message": "^1.0|^2.0",
|
|
||||||
"symfony/http-foundation": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"php-http/discovery": "<1.15"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"nyholm/psr7": "^1.1",
|
|
||||||
"php-http/discovery": "^1.15",
|
|
||||||
"psr/log": "^1.1.4|^2|^3",
|
|
||||||
"symfony/browser-kit": "^7.4|^8.0",
|
|
||||||
"symfony/config": "^7.4|^8.0",
|
|
||||||
"symfony/event-dispatcher": "^7.4|^8.0",
|
|
||||||
"symfony/framework-bundle": "^7.4|^8.0",
|
|
||||||
"symfony/http-kernel": "^7.4|^8.0",
|
|
||||||
"symfony/runtime": "^7.4|^8.0"
|
|
||||||
},
|
|
||||||
"type": "symfony-bridge",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Bridge\\PsrHttpMessage\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "PSR HTTP message bridge",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"keywords": [
|
|
||||||
"http",
|
|
||||||
"http-message",
|
|
||||||
"psr-17",
|
|
||||||
"psr-7"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "symfony/routing",
|
"name": "symfony/routing",
|
||||||
"version": "v7.4.6",
|
"version": "v7.4.6",
|
||||||
|
|
@ -8452,6 +7967,66 @@
|
||||||
},
|
},
|
||||||
"time": "2025-04-30T06:54:44+00:00"
|
"time": "2025-04-30T06:54:44+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "jean85/pretty-package-versions",
|
||||||
|
"version": "2.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||||
|
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||||
|
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2.1.0",
|
||||||
|
"php": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||||
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||||
|
"rector/rector": "^2.0",
|
||||||
|
"vimeo/psalm": "^4.3 || ^5.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Jean85\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Alessandro Lai",
|
||||||
|
"email": "alessandro.lai85@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A library to get pretty versions strings of installed dependencies",
|
||||||
|
"keywords": [
|
||||||
|
"composer",
|
||||||
|
"package",
|
||||||
|
"release",
|
||||||
|
"versions"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||||
|
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||||
|
},
|
||||||
|
"time": "2025-03-19T14:43:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/boost",
|
"name": "laravel/boost",
|
||||||
"version": "v1.8.11",
|
"version": "v1.8.11",
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,6 @@ return [
|
||||||
|
|
||||||
'name' => env('APP_NAME', 'Laravel'),
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Ausgelieferte Version
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Git-Tag des Deploys (Datei RELEASE, schreibt dev/betrieb/deploy.sh) oder
|
|
||||||
| APP_VERSION. Erscheint in /health, `php artisan about` und GlitchTip.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Environment
|
| Application Environment
|
||||||
|
|
@ -53,34 +41,6 @@ return [
|
||||||
|
|
||||||
'debug' => (bool) env('APP_DEBUG', false),
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Indexierung durch Suchmaschinen
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Nur die Produktivumgebung darf indexiert werden. Vorschau- und Testinstanzen
|
|
||||||
| liefern damit automatisch `noindex, nofollow` – sonst konkurriert eine Kopie
|
|
||||||
| der Website mit dem Original um dieselben Suchergebnisse.
|
|
||||||
|
|
|
||||||
| Bewusst als eigener Schalter und nicht nur an APP_ENV gekoppelt, damit eine
|
|
||||||
| Vorschau notfalls auch mit APP_ENV=production noindex bleiben kann.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
'indexable' => (bool) env('SITE_INDEXABLE', env('APP_ENV', 'production') === 'production'),
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Obere Hinweisleiste (Ankündigungsleiste)
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Seit der Abstimmung vom 01.10.2026 ausgeblendet – die Seite wirkt ohne die
|
|
||||||
| Leiste ruhiger. Inhalt und CMS-Pflege bleiben erhalten; mit
|
|
||||||
| B2IN_ANNOUNCEMENT_BAR=true ist sie auf allen Seiten wieder da.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'announcement_bar' => (bool) env('B2IN_ANNOUNCEMENT_BAR', false),
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application URL
|
| Application URL
|
||||||
|
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seitenbaum für den Inhalte-Editor im Portal (Nachtrag 12).
|
|
||||||
*
|
|
||||||
* Der Editor zeigt die CMS-Sektionen nicht mehr nach Datenbank-Gruppe, sondern so, wie sie auf
|
|
||||||
* der Website stehen: je Seite in der Reihenfolge von oben nach unten, mit Klartext-Titel und
|
|
||||||
* einem Satz, der sagt, was die Sektion ist. `anchor` ist die Sprungmarke in der Vorschau
|
|
||||||
* (das `id` des Abschnitts im Frontend).
|
|
||||||
*
|
|
||||||
* Eine Sektion, die auf mehreren Seiten steht, ist einmal eingetragen – dort, wo sie zuerst
|
|
||||||
* auftaucht – und nennt die anderen Seiten in der Beschreibung.
|
|
||||||
*
|
|
||||||
* `group`/`key` sind Gruppe und Schlüssel in flux_cms_contents (siehe cms_section_map.php).
|
|
||||||
* Sektionen, die in der Datenbank liegen, aber hier nicht eingetragen sind, sammelt der Editor
|
|
||||||
* unter „Nicht mehr verwendet“ – sie gehen also nicht verloren, stehen aber auch nicht im Weg.
|
|
||||||
*
|
|
||||||
* `fields` überschreibt Feldbeschriftungen je Sektion; alles andere kommt aus `field_labels`.
|
|
||||||
*/
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Feldbeschriftungen
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Gilt für Felder auf Sektionsebene und für Felder in Listeneinträgen.
|
|
||||||
| Unbekannte Schlüssel werden lesbar gemacht (button_text → „Button text“).
|
|
||||||
*/
|
|
||||||
'field_labels' => [
|
|
||||||
'title' => 'Überschrift',
|
|
||||||
'subtitle' => 'Untertitel',
|
|
||||||
'headline' => 'Überschrift',
|
|
||||||
'tagline' => 'Claim',
|
|
||||||
'text' => 'Text',
|
|
||||||
'intro' => 'Einleitung',
|
|
||||||
'description' => 'Beschreibung',
|
|
||||||
'content' => 'Inhalt',
|
|
||||||
'lead' => 'Vorspann',
|
|
||||||
'note' => 'Hinweis',
|
|
||||||
'label' => 'Beschriftung',
|
|
||||||
'badge' => 'Kennzeichen',
|
|
||||||
'eyebrow' => 'Überzeile',
|
|
||||||
'quote' => 'Zitat',
|
|
||||||
'author' => 'Zitatgeber',
|
|
||||||
'name' => 'Name',
|
|
||||||
'role' => 'Rolle / Funktion',
|
|
||||||
'city' => 'Ort',
|
|
||||||
'value' => 'Wert',
|
|
||||||
'number' => 'Zahl',
|
|
||||||
'suffix' => 'Zusatz',
|
|
||||||
'icon' => 'Icon',
|
|
||||||
'image' => 'Bild',
|
|
||||||
'image_alt' => 'Bildbeschreibung',
|
|
||||||
'alt' => 'Bildbeschreibung',
|
|
||||||
'logo' => 'Logo',
|
|
||||||
'link' => 'Link',
|
|
||||||
'url' => 'Adresse (URL)',
|
|
||||||
'href' => 'Link',
|
|
||||||
'link_text' => 'Linktext',
|
|
||||||
'button_text' => 'Button-Beschriftung',
|
|
||||||
'button_link' => 'Button-Link',
|
|
||||||
'primary_button' => 'Erster Button',
|
|
||||||
'secondary_button' => 'Zweiter Button',
|
|
||||||
'cta_title' => 'Überschrift Abschluss',
|
|
||||||
'cta_text' => 'Text Abschluss',
|
|
||||||
'cta_link' => 'Link Abschluss',
|
|
||||||
'items' => 'Einträge',
|
|
||||||
'steps' => 'Schritte',
|
|
||||||
'cards' => 'Karten',
|
|
||||||
'stats' => 'Kennzahlen',
|
|
||||||
'features' => 'Merkmale',
|
|
||||||
'benefits' => 'Vorteile',
|
|
||||||
'values' => 'Werte',
|
|
||||||
'links' => 'Links',
|
|
||||||
'columns' => 'Spalten',
|
|
||||||
'menu' => 'Menüpunkte',
|
|
||||||
'navigation' => 'Navigation',
|
|
||||||
'topics' => 'Themen',
|
|
||||||
'options' => 'Auswahlmöglichkeiten',
|
|
||||||
'placeholder' => 'Platzhaltertext',
|
|
||||||
'meta_title' => 'Seitentitel (Browser, Google)',
|
|
||||||
'meta_description' => 'Beschreibung für Google',
|
|
||||||
'email' => 'E-Mail',
|
|
||||||
'phone' => 'Telefon',
|
|
||||||
'whatsapp' => 'WhatsApp-Nummer',
|
|
||||||
'address' => 'Adresse',
|
|
||||||
'opening_hours' => 'Öffnungszeiten',
|
|
||||||
'copyright' => 'Copyright-Zeile',
|
|
||||||
'map_alt' => 'Beschreibung der Karte',
|
|
||||||
'region_label' => 'Beschriftung der Region',
|
|
||||||
'distance_suffix' => 'Zusatz hinter der Entfernung',
|
|
||||||
'hub' => 'Drehscheibe (Bielefeld)',
|
|
||||||
'steps_title' => 'Überschrift Ablauf-Block',
|
|
||||||
'steps_text' => 'Einleitung Ablauf-Block',
|
|
||||||
'x' => 'Position waagerecht (Entwickler)',
|
|
||||||
'y' => 'Position senkrecht (Entwickler)',
|
|
||||||
'id' => 'Sprungmarke (Entwickler)',
|
|
||||||
'slug' => 'Adresskürzel (Entwickler)',
|
|
||||||
'teaser' => 'Kurztext',
|
|
||||||
'summary' => 'Zusammenfassung',
|
|
||||||
'brands' => 'Marken',
|
|
||||||
'projects' => 'Projekte',
|
|
||||||
'partners' => 'Partner',
|
|
||||||
'questions' => 'Fragen',
|
|
||||||
'question' => 'Frage',
|
|
||||||
'answer' => 'Antwort',
|
|
||||||
'read_more' => 'Weiterlesen-Text',
|
|
||||||
'empty_text' => 'Text, wenn nichts vorhanden ist',
|
|
||||||
'success_text' => 'Text nach dem Absenden',
|
|
||||||
'submit_text' => 'Beschriftung Absenden-Button',
|
|
||||||
'privacy_text' => 'Datenschutz-Hinweis',
|
|
||||||
'subjects' => 'Betreffe',
|
|
||||||
'subject' => 'Betreff',
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Seiten in der Reihenfolge des Menüs
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
'pages' => [
|
|
||||||
|
|
||||||
'home' => [
|
|
||||||
'title' => 'Startseite',
|
|
||||||
'path' => '/',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'home', 'key' => 'hero', 'anchor' => 'hero', 'title' => 'Hero',
|
|
||||||
'description' => 'Der große Einstieg ganz oben: Zeile über der Überschrift (eyebrow), Überschrift, Text, Button, Häkchen-Punkte (stats) und die Slideshow. Unter „slides“ je Bild: image, label (Marke) und caption (Motiv) – beides erscheint in der Karte über dem Bild und wechselt mit –, alt (Bildbeschreibung für Screenreader) und active (aus = ausgeblendet); die Reihenfolge der Liste ist die Reihenfolge der Slideshow. „interval“ = Sekunden pro Bild (3–30). card_title/card_text stehen in der Karte, wenn eine Slide weder label noch caption hat; image/image_alt sind das Ersatzbild, wenn keine Slide aktiv ist.'],
|
|
||||||
['group' => 'home', 'key' => 'founder_bar', 'anchor' => 'gruender', 'title' => 'Gründer-Leiste',
|
|
||||||
'description' => 'Schmale Zeile mit Marcel Scheibe und Zitat. Steht auch auf /marken, „Über uns“ und in der Immobilien-Welt.'],
|
|
||||||
['group' => 'home', 'key' => 'audience_switch', 'anchor' => 'wege', 'title' => 'Zielgruppen-Wahl',
|
|
||||||
'description' => 'Zwei Wege: Privat einrichten oder Projekt ausstatten.'],
|
|
||||||
['group' => 'home', 'key' => 'core_brands', 'anchor' => 'marken', 'title' => 'Kernmarken',
|
|
||||||
'description' => 'Überschrift und Untertitel des Marken-Rasters. Die Kacheln selbst kommen aus der Markenverwaltung.'],
|
|
||||||
['group' => 'home', 'key' => 'origin_section', 'anchor' => 'herkunft', 'title' => 'Aus der Region gefertigt',
|
|
||||||
'description' => 'Karte mit Bielefeld als Drehscheibe und der Ablauf-Block am Beispiel CABINET. Die Standorte pflegst du bei den Marken. Steht auch auf /marken.'],
|
|
||||||
['group' => 'home', 'key' => 'vision_section', 'anchor' => 'vision', 'title' => 'Gebaut auf Expertise',
|
|
||||||
'description' => 'Weiße Box auf Ocker: Überschrift, Absätze (paragraphs) und Porträt mit Bildunterschrift (image_caption).'],
|
|
||||||
['group' => 'home', 'key' => 'sponsoring', 'anchor' => 'engagement', 'title' => 'Regional verbunden',
|
|
||||||
'description' => 'Vereine, die B2in unterstützt: je Verein name, image (Logo), text und optional url – mit url öffnet die Kachel die Vereinsseite in einem neuen Tab.'],
|
|
||||||
['group' => 'referenzen', 'key' => 'display_feed', 'anchor' => 'displays', 'title' => 'Aktuelles aus dem Showroom',
|
|
||||||
'description' => '„Aktuelles aus unserem Showroom“: Überschrift, Text, Hinweis (note) und Link in der weißen Box. Das Display rechts kommt aus den Store Displays (display_id leer = das erste veröffentlichte), Besucher können es über „Größer ansehen“ vergrößern. Steht auch auf /referenzen.'],
|
|
||||||
['group' => 'home', 'key' => 'immobilien_teaser', 'anchor' => 'immobilien', 'title' => 'Immobilien-Teaser',
|
|
||||||
'description' => 'Der Übergang in die Immobilien-Welt am Ende der Startseite.'],
|
|
||||||
['group' => 'global', 'key' => 'cta_section', 'anchor' => 'cta', 'title' => 'Abschluss: Termin anfragen',
|
|
||||||
'description' => 'Der letzte Block vor dem Fuß auf der Startseite und auf /referenzen. Marken und „Über uns“ haben eigene Abschlüsse.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'marken' => [
|
|
||||||
'title' => 'Marken',
|
|
||||||
'path' => '/marken',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'marken', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
|
||||||
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Einleitung (text), Bild und die Häkchen-Punkte (stats) oben auf /marken. Die Markenblöcke darunter kommen aus der Markenverwaltung.'],
|
|
||||||
['group' => 'marken', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
|
|
||||||
'description' => 'Großes Bild zwischen den Markenblöcken und „Aus der Region gefertigt“, damit sich die beiden hellen Sektionen absetzen. Mit Zitat (quote) und Autor (author); ohne Zitat steht nur das Bild.'],
|
|
||||||
['group' => 'marken', 'key' => 'network', 'anchor' => 'netzwerk', 'title' => 'Mehr als Marken: unser Netzwerk',
|
|
||||||
'description' => 'Partner und Zulieferer jenseits der Kernmarken.'],
|
|
||||||
['group' => 'marken', 'key' => 'growth', 'anchor' => 'wachstum', 'title' => 'Die Markenwelt wächst',
|
|
||||||
'description' => 'Hinweis, dass weitere Marken folgen.'],
|
|
||||||
['group' => 'marken', 'key' => 'cta', 'anchor' => 'marken-cta', 'title' => 'Abschluss auf /marken',
|
|
||||||
'description' => 'Der Abschlussblock „Lernen Sie unsere Marken kennen“ mit Terminanfrage und WhatsApp.'],
|
|
||||||
['group' => 'marken', 'key' => 'craft', 'anchor' => 'handwerk', 'title' => 'Handwerk-Abschluss auf jeder Markenseite',
|
|
||||||
'description' => 'Der letzte Abschnitt auf den Seiten von CABINET, Rotpunkt und Febrü: „Planung plus Handwerk“ mit Link zu Otterpohl. Einmal gepflegt, überall gleich.', 'path' => '/marken/cabinet'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'referenzen' => [
|
|
||||||
'title' => 'Referenzen',
|
|
||||||
'path' => '/referenzen',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'referenzen', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
|
||||||
'description' => 'Überschrift, Einleitung und Bild der Referenzen-Seite. Ob die Seite (samt Menüpunkt) erscheint, schaltest du unter „Referenzen“.'],
|
|
||||||
['group' => 'referenzen', 'key' => 'projects', 'anchor' => 'projekte', 'title' => 'Realisierte Projekte',
|
|
||||||
'description' => 'Überschrift, Text und Leerzustand über den Projekten. Die Projekte selbst pflegst du unter „Referenzen“.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'magazin' => [
|
|
||||||
'title' => 'Magazin',
|
|
||||||
'path' => '/magazin',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'magazin', 'key' => 'list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht',
|
|
||||||
'description' => 'Überschrift und Einleitung der Übersicht. Die Beiträge pflegst du unter „Magazin“.'],
|
|
||||||
['group' => 'magazin', 'key' => 'detail', 'anchor' => null, 'title' => 'Beitragsseite',
|
|
||||||
'description' => 'Feste Texte auf jeder Beitragsseite, etwa „Zurück zur Übersicht“ und „Weitere Beiträge“.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'about' => [
|
|
||||||
'title' => 'Über uns',
|
|
||||||
'path' => '/about',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'about', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
|
|
||||||
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte (card_title, card_text).'],
|
|
||||||
['group' => 'about', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Unsere Geschichte',
|
|
||||||
'description' => 'Überschrift, Text (summary) und die Zeitleiste (timeline) zur Entstehung von B2in.'],
|
|
||||||
['group' => 'about', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
|
|
||||||
'description' => 'Großes Bild zwischen Geschichte und Werten, mit Zitat (quote) und Autor (author).'],
|
|
||||||
['group' => 'about', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Unsere Werte',
|
|
||||||
'description' => 'Die Werte-Kacheln.'],
|
|
||||||
['group' => 'about', 'key' => 'partner_cta', 'anchor' => 'cta', 'title' => 'Abschluss auf „Über uns“',
|
|
||||||
'description' => 'Der Abschlussblock am Ende von „Über uns“.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'contact' => [
|
|
||||||
'title' => 'Kontakt',
|
|
||||||
'path' => '/contact',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'contact', 'key' => 'contact_form', 'anchor' => 'kontakt', 'title' => 'Kontaktformular',
|
|
||||||
'description' => 'Kopf des Formulars (hero: eyebrow, title, subtitle), Betreffe, Feldbeschriftungen und Datenschutz-Hinweis.'],
|
|
||||||
['group' => 'global', 'key' => 'contact', 'anchor' => 'kontakt', 'title' => 'Kontaktdaten',
|
|
||||||
'description' => 'Telefon, E-Mail, Adresse und die WhatsApp-Nummer. Ohne WhatsApp-Nummer erscheint kein WhatsApp-Button.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'faq' => [
|
|
||||||
'title' => 'FAQ',
|
|
||||||
'path' => '/faq',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'faq', 'key' => 'faq', 'anchor' => 'faq', 'title' => 'Fragen und Antworten',
|
|
||||||
'description' => 'Überschrift und die Liste der Fragen der Interior-Welt.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'global' => [
|
|
||||||
'title' => 'Auf jeder Seite',
|
|
||||||
'path' => '/',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'global', 'key' => 'announcement_bar', 'anchor' => 'header', 'title' => 'Hinweisleiste',
|
|
||||||
'description' => 'Schmale Zeile ganz oben über dem Menü (text, badge, Link). „enabled“ schaltet sie ein. Zurzeit ist die Leiste für die ganze Website über B2IN_ANNOUNCEMENT_BAR in der .env aus – Änderungen hier erscheinen erst, wenn sie dort eingeschaltet ist.'],
|
|
||||||
['group' => 'global', 'key' => 'header', 'anchor' => 'header', 'title' => 'Hauptmenü',
|
|
||||||
'description' => 'Menüpunkte und Buttons im Kopf der Interior-Welt. Der Punkt „Referenzen“ erscheint nur, solange die Referenzen-Seite eingeschaltet ist.'],
|
|
||||||
['group' => 'global', 'key' => 'appointment_widget', 'anchor' => null, 'title' => 'Terminanfrage-Widget',
|
|
||||||
'description' => 'Das schwebende Widget rechts unten: Texte, Themen und Zeitfenster.'],
|
|
||||||
['group' => 'global', 'key' => 'footer', 'anchor' => 'footer', 'title' => 'Fußbereich',
|
|
||||||
'description' => 'Claim, Zeile unter dem Claim (byline) und die Linkliste (links) im Fuß.'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'immobilien' => [
|
|
||||||
'title' => 'Immobilien-Welt',
|
|
||||||
'path' => '/immobilien',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'global', 'key' => 'announcement_bar_immobilien', 'anchor' => 'header', 'title' => 'Hinweisleiste (Immobilien)',
|
|
||||||
'description' => 'Hinweisleiste über dem Menü der Immobilien-Welt. Wie die Interior-Leiste zurzeit über B2IN_ANNOUNCEMENT_BAR ausgeschaltet.'],
|
|
||||||
['group' => 'global', 'key' => 'header_immobilien', 'anchor' => 'header', 'title' => 'Hauptmenü (Immobilien)',
|
|
||||||
'description' => 'Menüpunkte und Buttons im Kopf der Immobilien-Welt.'],
|
|
||||||
['group' => 'global', 'key' => 'cta_section_immobilien', 'anchor' => 'cta', 'title' => 'Abschluss (Immobilien)',
|
|
||||||
'description' => 'Der letzte Block vor dem Fuß auf allen Immobilien-Seiten.'],
|
|
||||||
['group' => 'immobilien', 'key' => 'magazin_list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht (Immobilien)',
|
|
||||||
'description' => 'Überschrift und Einleitung unter /immobilien/magazin.', 'path' => '/immobilien/magazin'],
|
|
||||||
['group' => 'immobilien', 'key' => 'about_hero', 'anchor' => 'seitenkopf', 'title' => 'Über B2in: Seitenkopf',
|
|
||||||
'description' => 'Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte unter /immobilien/ueber-b2in.', 'path' => '/immobilien/ueber-b2in'],
|
|
||||||
['group' => 'immobilien', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Über B2in: Geschichte',
|
|
||||||
'description' => 'Der Geschichte-Block der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
|
|
||||||
['group' => 'immobilien', 'key' => 'about_image_break', 'anchor' => 'bild', 'title' => 'Über B2in: Bildpause',
|
|
||||||
'description' => 'Bild zwischen Geschichte und Werten.', 'path' => '/immobilien/ueber-b2in'],
|
|
||||||
['group' => 'immobilien', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Über B2in: Werte',
|
|
||||||
'description' => 'Die Werte-Kacheln der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
|
|
||||||
['group' => 'faq', 'key' => 'faq_immobilien', 'anchor' => 'faq', 'title' => 'FAQ (Immobilien)',
|
|
||||||
'description' => 'Fragen und Antworten unter /immobilien/faq.', 'path' => '/immobilien/faq'],
|
|
||||||
['group' => 'contact', 'key' => 'contact_form_immobilien', 'anchor' => 'kontakt', 'title' => 'Kontaktformular (Immobilien)',
|
|
||||||
'description' => 'Abweichende Texte und Betreffe unter /immobilien/kontakt. Leere Felder übernehmen den Interior-Wert.', 'path' => '/immobilien/kontakt'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'legal' => [
|
|
||||||
'title' => 'Rechtliches',
|
|
||||||
'path' => '/impressum',
|
|
||||||
'sections' => [
|
|
||||||
['group' => 'legal', 'key' => 'impressum', 'anchor' => null, 'title' => 'Impressum',
|
|
||||||
'description' => 'Pflichtangaben. Der Inhalt ist HTML.', 'path' => '/impressum'],
|
|
||||||
['group' => 'legal', 'key' => 'privacy', 'anchor' => null, 'title' => 'Datenschutz',
|
|
||||||
'description' => 'Datenschutzerklärung. Der Inhalt ist HTML.', 'path' => '/privacy'],
|
|
||||||
['group' => 'legal', 'key' => 'terms', 'anchor' => null, 'title' => 'Nutzungsbedingungen',
|
|
||||||
'description' => 'Nutzungsbedingungen der Website. Der Inhalt ist HTML.', 'path' => '/terms'],
|
|
||||||
['group' => 'legal', 'key' => 'cookie_policy', 'anchor' => null, 'title' => 'Cookie-Hinweise',
|
|
||||||
'description' => 'Cookie-Richtlinie. Der Inhalt ist HTML.', 'path' => '/cookie-policy'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Sammelpunkt für alles, was in der Datenbank liegt, aber oben nicht steht
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
*/
|
|
||||||
'unlisted' => [
|
|
||||||
'title' => 'Nicht mehr verwendet',
|
|
||||||
'description' => 'Einträge in der Datenbank, die keine Seite anzeigt. Normalerweise leer – der UnusedCmsContentCleanupSeeder räumt solche Einträge ab.',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
@ -8,55 +8,84 @@ declare(strict_types=1);
|
||||||
* Wird von cms_theme_section() und CmsContentSeeder verwendet.
|
* Wird von cms_theme_section() und CmsContentSeeder verwendet.
|
||||||
*/
|
*/
|
||||||
return [
|
return [
|
||||||
|
'lang_keys' => [
|
||||||
|
'card_section' => 'partner_card_section',
|
||||||
|
],
|
||||||
|
|
||||||
'sections' => [
|
'sections' => [
|
||||||
'announcement_bar' => ['global', 'announcement_bar'],
|
'announcement_bar' => ['global', 'announcement_bar'],
|
||||||
'announcement_bar_immobilien' => ['global', 'announcement_bar_immobilien'],
|
|
||||||
'header' => ['global', 'header'],
|
'header' => ['global', 'header'],
|
||||||
'footer' => ['global', 'footer'],
|
|
||||||
'cta_section' => ['global', 'cta_section'],
|
'cta_section' => ['global', 'cta_section'],
|
||||||
'cta_section_immobilien' => ['global', 'cta_section_immobilien'],
|
'cta_section_portfolio' => ['global', 'cta_section_portfolio'],
|
||||||
'appointment_widget' => ['global', 'appointment_widget'],
|
|
||||||
|
|
||||||
'hero' => ['home', 'hero'],
|
'hero' => ['home', 'hero'],
|
||||||
'audience_switch' => ['home', 'audience_switch'],
|
|
||||||
'core_brands' => ['home', 'core_brands'],
|
|
||||||
'origin_section' => ['home', 'origin_section'],
|
|
||||||
|
|
||||||
'marken_hero' => ['marken', 'hero'],
|
|
||||||
'marken_image_break' => ['marken', 'image_break'],
|
|
||||||
'marken_network' => ['marken', 'network'],
|
|
||||||
'marken_growth' => ['marken', 'growth'],
|
|
||||||
'marken_cta' => ['marken', 'cta'],
|
|
||||||
'brand_craft' => ['marken', 'craft'],
|
|
||||||
|
|
||||||
'referenzen_hero' => ['referenzen', 'hero'],
|
|
||||||
'reference_projects' => ['referenzen', 'projects'],
|
|
||||||
'display_feed' => ['referenzen', 'display_feed'],
|
|
||||||
'sponsoring' => ['home', 'sponsoring'],
|
|
||||||
'immobilien_teaser' => ['home', 'immobilien_teaser'],
|
|
||||||
'founder_bar' => ['home', 'founder_bar'],
|
'founder_bar' => ['home', 'founder_bar'],
|
||||||
|
'synergie_section' => ['home', 'synergie_section'],
|
||||||
|
'ecosystem_core' => ['home', 'ecosystem_core'],
|
||||||
'vision_section' => ['home', 'vision_section'],
|
'vision_section' => ['home', 'vision_section'],
|
||||||
|
'brand_worlds' => ['home', 'brand_worlds'],
|
||||||
|
'integriertes_modell_b2in' => ['home', 'integriertes_modell_b2in'],
|
||||||
|
|
||||||
|
'immobilien_hero' => ['immobilien', 'hero'],
|
||||||
|
'immobilien_hero_v2' => ['immobilien', 'hero_v2'],
|
||||||
|
'immobilien_projects' => ['immobilien', 'projects_meta'],
|
||||||
|
'immobilien_moebel_vorteil' => ['immobilien', 'moebel_vorteil'],
|
||||||
|
'immobilien_trust' => ['immobilien', 'trust'],
|
||||||
|
'immobilien_warum_dubai' => ['immobilien', 'warum_dubai'],
|
||||||
|
'immobilien_image_break' => ['immobilien', 'image_break'],
|
||||||
|
'immobilien_kaufprozess' => ['immobilien', 'kaufprozess'],
|
||||||
|
'immobilien_bruecke' => ['immobilien', 'bruecke'],
|
||||||
|
'immobilien_mindset' => ['immobilien', 'mindset'],
|
||||||
|
'portfolio' => ['immobilien', 'portfolio'],
|
||||||
|
|
||||||
|
'netzwerk_hero' => ['netzwerk', 'hero'],
|
||||||
|
'netzwerk_image_break' => ['netzwerk', 'image_break'],
|
||||||
|
'netzwerk_teasers' => ['netzwerk', 'teasers'],
|
||||||
|
'netzwerk_cta' => ['netzwerk', 'cta'],
|
||||||
|
'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'],
|
||||||
|
'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'],
|
||||||
|
|
||||||
|
'interior_hero' => ['netzwerk', 'interior_hero'],
|
||||||
|
'interior_concept' => ['netzwerk', 'interior_concept'],
|
||||||
|
'interior_brands' => ['netzwerk', 'interior_brands'],
|
||||||
|
'interior_zielgruppen' => ['netzwerk', 'interior_zielgruppen'],
|
||||||
|
'interior_process' => ['netzwerk', 'interior_process'],
|
||||||
|
'interior_trust' => ['netzwerk', 'interior_trust'],
|
||||||
|
|
||||||
'faq' => ['faq', 'faq'],
|
'faq' => ['faq', 'faq'],
|
||||||
'faq_immobilien' => ['faq', 'faq_immobilien'],
|
|
||||||
|
|
||||||
// Immobilien-Welt: eigene Navigation, eigenes Magazin, eigene Ueber-uns-Seite
|
|
||||||
'header_immobilien' => ['global', 'header_immobilien'],
|
|
||||||
'magazin_list_immobilien' => ['immobilien', 'magazin_list'],
|
|
||||||
'immobilien_about_hero' => ['immobilien', 'about_hero'],
|
|
||||||
'immobilien_our_story' => ['immobilien', 'our_story'],
|
|
||||||
'immobilien_about_image_break' => ['immobilien', 'about_image_break'],
|
|
||||||
'immobilien_our_values' => ['immobilien', 'our_values'],
|
|
||||||
|
|
||||||
'contact_form' => ['contact', 'contact_form'],
|
'contact_form' => ['contact', 'contact_form'],
|
||||||
'contact_form_immobilien' => ['contact', 'contact_form_immobilien'],
|
|
||||||
|
|
||||||
'about_hero' => ['about', 'hero'],
|
'about_hero' => ['about', 'hero'],
|
||||||
'our_story' => ['about', 'our_story'],
|
'our_story' => ['about', 'our_story'],
|
||||||
'about_image_break' => ['about', 'image_break'],
|
'about_image_break' => ['about', 'image_break'],
|
||||||
'our_values' => ['about', 'our_values'],
|
'our_values' => ['about', 'our_values'],
|
||||||
|
'leadership_team' => ['about', 'leadership_team'],
|
||||||
|
|
||||||
'partner_cta' => ['about', 'partner_cta'],
|
'broker_section' => ['netzwerk', 'broker_section'],
|
||||||
|
'commitment_section' => ['netzwerk', 'commitment_section'],
|
||||||
|
'dark_stats_section' => ['netzwerk', 'dark_stats_section'],
|
||||||
|
'ecosystem_hero' => ['netzwerk', 'ecosystem_hero'],
|
||||||
|
'ecosystem_stats' => ['netzwerk', 'ecosystem_stats'],
|
||||||
|
'ecosystem_start' => ['netzwerk', 'ecosystem_start'],
|
||||||
|
'ecosystem_hub' => ['netzwerk', 'ecosystem_hub'],
|
||||||
|
'ecosystem_result' => ['netzwerk', 'ecosystem_result'],
|
||||||
|
'end_customer_section' => ['netzwerk', 'end_customer_section'],
|
||||||
|
'final_commitment' => ['netzwerk', 'final_commitment'],
|
||||||
|
'digital_core' => ['netzwerk', 'digital_core'],
|
||||||
|
'supply_chain_intro' => ['netzwerk', 'supply_chain_intro'],
|
||||||
|
|
||||||
|
'partner_hero' => ['netzwerk', 'partner_hero'],
|
||||||
|
'partner_benefits' => ['netzwerk', 'partner_benefits'],
|
||||||
|
'partner_cta' => ['netzwerk', 'partner_cta'],
|
||||||
|
'partner_card_section' => ['netzwerk', 'partner_card_section'],
|
||||||
|
'card_section' => ['netzwerk', 'partner_card_section'],
|
||||||
|
'partner_benefits_developer' => ['netzwerk', 'partner_benefits_developer'],
|
||||||
|
'partner_benefits_retailer' => ['netzwerk', 'partner_benefits_retailer'],
|
||||||
|
'partner_benefits_supplier' => ['netzwerk', 'partner_benefits_supplier'],
|
||||||
|
'partner_benefits_broker' => ['netzwerk', 'partner_benefits_broker'],
|
||||||
|
'partner_process' => ['netzwerk', 'partner_process'],
|
||||||
|
'supplier_section' => ['netzwerk', 'supplier_section'],
|
||||||
|
|
||||||
'magazin_detail' => ['magazin', 'detail'],
|
'magazin_detail' => ['magazin', 'detail'],
|
||||||
'magazin_list' => ['magazin', 'list'],
|
'magazin_list' => ['magazin', 'list'],
|
||||||
|
|
|
||||||
|
|
@ -71,29 +71,13 @@ return [
|
||||||
'view_prefix' => 'b2in',
|
'view_prefix' => 'b2in',
|
||||||
'assets_dir' => 'build/b2in',
|
'assets_dir' => 'build/b2in',
|
||||||
'description' => 'Haupt-Landingpage mit Standard-Theme',
|
'description' => 'Haupt-Landingpage mit Standard-Theme',
|
||||||
// Interior-Welt (Standard). Die Immobilien-Welt behält das bisherige Blau,
|
|
||||||
// siehe `worlds` und resources/css/web/theme-b2in.css.
|
|
||||||
'color_scheme' => [
|
'color_scheme' => [
|
||||||
'primary' => '#202c2b', // Tanne
|
'primary' => '#2b3f51', // Anthracite
|
||||||
'secondary' => '#a8741f', // Ocker
|
'secondary' => '#20a0da', // Dynamic Blue
|
||||||
],
|
],
|
||||||
'font_family' => [
|
'font_family' => [
|
||||||
'primary' => 'Inter',
|
'primary' => 'Inter',
|
||||||
'secondary' => 'EB Garamond',
|
'secondary' => 'IBM Plex Sans',
|
||||||
],
|
|
||||||
'worlds' => [
|
|
||||||
'interior' => [
|
|
||||||
'primary' => '#202c2b', // Tanne – Hauptton dunkel, Wortmarke
|
|
||||||
'background' => '#f7f4ef', // Creme
|
|
||||||
'surface' => '#d9cbbf', // Sand – Flächen, Footer
|
|
||||||
'accent' => '#a8741f', // Ocker – Akzent, Logo-Bogen
|
|
||||||
'secondary' => '#a38f82', // Clay – Sekundär, Produkte
|
|
||||||
'text' => '#2d2926', // Anthrazit
|
|
||||||
],
|
|
||||||
'immobilien' => [
|
|
||||||
'primary' => '#2b3f51', // Anthracite
|
|
||||||
'accent' => '#20a0da', // Dynamic Blue
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,6 @@ return [
|
||||||
|
|
||||||
'channels' => [
|
'channels' => [
|
||||||
|
|
||||||
'monitoring' => [
|
|
||||||
'driver' => 'daily',
|
|
||||||
'path' => storage_path('logs/monitoring.log'),
|
|
||||||
'level' => 'debug',
|
|
||||||
'days' => 14,
|
|
||||||
],
|
|
||||||
|
|
||||||
'stack' => [
|
'stack' => [
|
||||||
'driver' => 'stack',
|
'driver' => 'stack',
|
||||||
'channels' => explode(',', env('LOG_STACK', 'single')),
|
'channels' => explode(',', env('LOG_STACK', 'single')),
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Betriebs-Alerts
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Fehlgeschlagene Scheduler-Tasks und Queue-Jobs werden immer als
|
|
||||||
| `critical` ins Log geschrieben (rotierter `daily`-Channel) und – wenn
|
|
||||||
| aktiviert und eine Adresse gesetzt ist – zusätzlich per Best-Effort-Mail
|
|
||||||
| gemeldet. Die Mail ist bewusst nur die zweite Ebene: schlägt der Versand
|
|
||||||
| fehl, bleibt der Log-Eintrag als verlässliche Spur bestehen.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'alerts' => [
|
|
||||||
'enabled' => (bool) env('MONITORING_ALERTS_ENABLED', true),
|
|
||||||
|
|
||||||
// Ops-Postfach. Leer = keine Alert-Mail (Log und ntfy laufen trotzdem).
|
|
||||||
'mail_to' => env('MONITORING_ALERT_EMAIL'),
|
|
||||||
|
|
||||||
// Zweiter Alarmweg: Push über ntfy, JSON-POST
|
|
||||||
// an die Basis-URL. Ohne URL oder Topic aus.
|
|
||||||
'ntfy' => [
|
|
||||||
'url' => env('MONITORING_NTFY_URL'),
|
|
||||||
'topic' => env('MONITORING_NTFY_TOPIC'),
|
|
||||||
'token' => env('MONITORING_NTFY_TOKEN'),
|
|
||||||
'priority' => (int) env('MONITORING_NTFY_PRIORITY', 4),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Browserfehler
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Eigenes GlitchTip-Projekt für JavaScript-Fehler. Die DSN ist öffentlich
|
|
||||||
| (steht im HTML), sie erlaubt nur das Einliefern von Ereignissen. Ohne
|
|
||||||
| DSN wird kein Skript eingebunden.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'browser' => [
|
|
||||||
'dsn' => env('SENTRY_BROWSER_DSN'),
|
|
||||||
'sample_rate' => (float) env('SENTRY_BROWSER_SAMPLE_RATE', 1.0),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Health-Endpunkt und Scheduler-Herzschlag
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| `/health` antwortet nur mit gültigem Token (Bearer-Header oder
|
|
||||||
| `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht (404).
|
|
||||||
| Der Scheduler schreibt jede Minute einen Herzschlag; bleibt er aus, ist
|
|
||||||
| der Cron-Eintrag weg oder der Server hängt.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'health' => [
|
|
||||||
'token' => env('MONITORING_HEALTH_TOKEN'),
|
|
||||||
'queue_backlog_critical_seconds' => (int) env('MONITORING_QUEUE_BACKLOG_SECONDS', 300),
|
|
||||||
'disk_min_free_percent' => (float) env('MONITORING_DISK_MIN_FREE_PERCENT', 10),
|
|
||||||
],
|
|
||||||
|
|
||||||
'scheduler' => [
|
|
||||||
// Leer = storage/framework/scheduler-heartbeat. Die Tests setzen je Prozess eine eigene Datei.
|
|
||||||
'heartbeat_path' => env('MONITORING_HEARTBEAT_PATH'),
|
|
||||||
'heartbeat_warn_minutes' => 3,
|
|
||||||
'heartbeat_critical_minutes' => 10,
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Seed-Admin
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Zugangsdaten für den Admin-Benutzer, den der DatabaseSeeder anlegt.
|
|
||||||
| Gehören nicht ins Repository – über die .env setzen.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'admin_name' => env('SEED_ADMIN_NAME', 'Admin'),
|
|
||||||
'admin_email' => env('SEED_ADMIN_EMAIL', 'admin@b2in.test'),
|
|
||||||
'admin_password' => env('SEED_ADMIN_PASSWORD', 'password'),
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Support\Monitoring\ErrorReportScrubber;
|
|
||||||
use App\Support\Monitoring\Release;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sentry Laravel SDK configuration file.
|
|
||||||
*
|
|
||||||
* Ziel ist das selbst gehostete GlitchTip, das das
|
|
||||||
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
|
|
||||||
* Paket-Standard: kein Personenbezug außer der User-ID, keine
|
|
||||||
* Request-Bodys, keine Log-/Cache-Breadcrumbs (Lognachrichten tragen bei uns
|
|
||||||
* E-Mail-Adressen), keine Metriken (versteht GlitchTip nicht).
|
|
||||||
*
|
|
||||||
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
|
|
||||||
*/
|
|
||||||
return [
|
|
||||||
|
|
||||||
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
|
||||||
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
|
|
||||||
|
|
||||||
// @see https://spotlightjs.com/
|
|
||||||
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
|
|
||||||
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
|
|
||||||
|
|
||||||
// The release version of your application
|
|
||||||
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
|
|
||||||
// Gleiche Kennung wie config('app.version'): SENTRY_RELEASE bzw.
|
|
||||||
// APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE.
|
|
||||||
'release' => Release::current(env('SENTRY_RELEASE', env('APP_VERSION'))),
|
|
||||||
|
|
||||||
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
|
|
||||||
'environment' => env('SENTRY_ENVIRONMENT'),
|
|
||||||
|
|
||||||
// Override the organization ID used for trace continuation checks.
|
|
||||||
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
|
|
||||||
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
|
|
||||||
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
|
|
||||||
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
|
|
||||||
|
|
||||||
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
|
|
||||||
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
|
|
||||||
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics
|
|
||||||
'enable_metrics' => env('SENTRY_ENABLE_METRICS', false),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
|
|
||||||
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
|
|
||||||
|
|
||||||
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
|
|
||||||
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
|
|
||||||
'send_default_pii' => false,
|
|
||||||
|
|
||||||
// Formular- und JSON-Inhalte verlassen den Server nie.
|
|
||||||
'max_request_body_size' => 'never',
|
|
||||||
|
|
||||||
// User-ID statt Person, Tokens aus URL/Query/Referer, E-Mails aus Meldungen.
|
|
||||||
'before_send' => [ErrorReportScrubber::class, 'beforeSend'],
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
|
|
||||||
// 'ignore_exceptions' => [],
|
|
||||||
|
|
||||||
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
|
|
||||||
'ignore_transactions' => [
|
|
||||||
// Ignore Laravel's default health URL
|
|
||||||
'/up',
|
|
||||||
'/health',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Breadcrumb specific configuration
|
|
||||||
'breadcrumbs' => [
|
|
||||||
// Capture Laravel logs as breadcrumbs
|
|
||||||
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', false),
|
|
||||||
|
|
||||||
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
|
|
||||||
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', false),
|
|
||||||
|
|
||||||
// Capture Livewire components like routes as breadcrumbs
|
|
||||||
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture SQL queries as breadcrumbs
|
|
||||||
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
|
|
||||||
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
|
|
||||||
|
|
||||||
// Capture queue job information as breadcrumbs
|
|
||||||
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture command information as breadcrumbs
|
|
||||||
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture HTTP client request information as breadcrumbs
|
|
||||||
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture send notifications as breadcrumbs
|
|
||||||
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Performance monitoring specific configuration
|
|
||||||
'tracing' => [
|
|
||||||
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
|
|
||||||
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture queue jobs as spans when executed on the sync driver
|
|
||||||
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture SQL queries as spans
|
|
||||||
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture SQL query bindings (parameters) in SQL query spans
|
|
||||||
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
|
|
||||||
|
|
||||||
// Capture where the SQL query originated from on the SQL query spans
|
|
||||||
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
|
|
||||||
|
|
||||||
// Define a threshold in milliseconds for SQL queries to resolve their origin
|
|
||||||
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
|
|
||||||
|
|
||||||
// Capture views rendered as spans
|
|
||||||
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture Livewire components as spans
|
|
||||||
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture HTTP client requests as spans
|
|
||||||
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture Laravel cache events (hits, writes etc.) as spans
|
|
||||||
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture Redis operations as spans (this enables Redis events in Laravel)
|
|
||||||
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
|
|
||||||
|
|
||||||
// Capture where the Redis command originated from on the Redis command spans
|
|
||||||
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture send notifications as spans
|
|
||||||
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
|
|
||||||
|
|
||||||
// Enable tracing for requests without a matching route (404's)
|
|
||||||
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
|
|
||||||
|
|
||||||
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
|
|
||||||
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
|
|
||||||
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
|
|
||||||
|
|
||||||
// Capture AI agent interactions as spans (requires laravel/ai)
|
|
||||||
'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture AI invoke_agent spans
|
|
||||||
'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture AI chat spans
|
|
||||||
'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture AI execute_tool spans
|
|
||||||
'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true),
|
|
||||||
|
|
||||||
// Capture AI embeddings spans
|
|
||||||
'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true),
|
|
||||||
|
|
||||||
// Enable the tracing integrations supplied by Sentry (recommended)
|
|
||||||
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -23,7 +23,6 @@ class CmsArticleFactory extends Factory
|
||||||
'subtitle' => fake()->sentence(12),
|
'subtitle' => fake()->sentence(12),
|
||||||
'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg',
|
'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg',
|
||||||
'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']),
|
'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']),
|
||||||
'world' => CmsArticle::WORLD_INTERIOR,
|
|
||||||
'date_label' => fake()->date('F j, Y'),
|
'date_label' => fake()->date('F j, Y'),
|
||||||
'read_time' => fake()->numberBetween(3, 10).' min read',
|
'read_time' => fake()->numberBetween(3, 10).' min read',
|
||||||
'author' => [
|
'author' => [
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\CmsBrand;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends Factory<CmsBrand>
|
|
||||||
*/
|
|
||||||
class CmsBrandFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = CmsBrand::class;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
$name = fake()->unique()->company();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 999),
|
|
||||||
'name' => ['de' => $name, 'en' => $name],
|
|
||||||
'tagline' => ['de' => fake()->sentence(4), 'en' => fake()->sentence(4)],
|
|
||||||
'teaser' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
|
|
||||||
'description' => ['de' => '<p>'.fake()->paragraph(4).'</p>', 'en' => '<p>'.fake()->paragraph(4).'</p>'],
|
|
||||||
'logo' => '',
|
|
||||||
'image' => '',
|
|
||||||
'services' => [
|
|
||||||
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
|
|
||||||
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
|
|
||||||
],
|
|
||||||
'gallery' => [],
|
|
||||||
'website_url' => fake()->url(),
|
|
||||||
'showroom' => 'Bielefeld',
|
|
||||||
'is_published' => true,
|
|
||||||
'order' => fake()->numberBetween(1, 20),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Galerie mit realisierten Projekten und Inspirationen.
|
|
||||||
*/
|
|
||||||
public function withGallery(int $projects = 2, int $inspirations = 2): static
|
|
||||||
{
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
for ($i = 1; $i <= $projects; $i++) {
|
|
||||||
$items[] = [
|
|
||||||
'image' => 'projekt-'.$i.'.webp',
|
|
||||||
'category' => CmsBrand::GALLERY_PROJECT,
|
|
||||||
'caption' => 'Projekt '.$i,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
for ($i = 1; $i <= $inspirations; $i++) {
|
|
||||||
$items[] = [
|
|
||||||
'image' => 'inspiration-'.$i.'.webp',
|
|
||||||
'category' => CmsBrand::GALLERY_INSPIRATION,
|
|
||||||
'caption' => 'Inspiration '.$i,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->state(fn () => ['gallery' => $items]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function unpublished(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => ['is_published' => false]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\CmsReference;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends Factory<CmsReference>
|
|
||||||
*/
|
|
||||||
class CmsReferenceFactory extends Factory
|
|
||||||
{
|
|
||||||
protected $model = CmsReference::class;
|
|
||||||
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
$title = fake()->unique()->words(3, true);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => ['de' => ucfirst($title), 'en' => ucfirst($title)],
|
|
||||||
'location' => fake()->city(),
|
|
||||||
'year' => (string) fake()->numberBetween(2022, 2026),
|
|
||||||
'description' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
|
|
||||||
'image' => 'referenzen/'.fake()->unique()->slug(2).'.webp',
|
|
||||||
'gallery' => [],
|
|
||||||
'brand_slugs' => [],
|
|
||||||
'is_published' => true,
|
|
||||||
'order' => fake()->numberBetween(1, 20),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function published(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => ['is_published' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function unpublished(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => ['is_published' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $slugs
|
|
||||||
*/
|
|
||||||
public function forBrands(array $slugs): static
|
|
||||||
{
|
|
||||||
return $this->state(fn () => ['brand_slugs' => $slugs]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function withGallery(int $count = 2): static
|
|
||||||
{
|
|
||||||
return $this->state(function () use ($count) {
|
|
||||||
$items = [];
|
|
||||||
|
|
||||||
for ($i = 1; $i <= $count; $i++) {
|
|
||||||
$items[] = ['image' => 'referenzen/bild-'.$i.'.webp', 'caption' => 'Bild '.$i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['gallery' => $items];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Kernmarken (CABINET, Rotpunkt Küchen, Febrü) mit eigener Detailseite.
|
|
||||||
*
|
|
||||||
* Bewusst eine eigene Tabelle statt der bestehenden `brands`: die gehört zum
|
|
||||||
* Produkt-/Partner-Kontext (Partner::brand(), Product::brand()) und hat eine andere Bedeutung.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('cms_brands', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('slug')->unique();
|
|
||||||
$table->json('name');
|
|
||||||
$table->json('tagline')->nullable();
|
|
||||||
$table->json('teaser')->nullable();
|
|
||||||
$table->json('description')->nullable();
|
|
||||||
$table->string('logo')->nullable();
|
|
||||||
$table->string('image')->nullable();
|
|
||||||
$table->json('services')->nullable();
|
|
||||||
$table->json('gallery')->nullable();
|
|
||||||
$table->string('website_url')->nullable();
|
|
||||||
$table->string('showroom')->nullable();
|
|
||||||
$table->boolean('is_published')->default(true);
|
|
||||||
$table->integer('order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('cms_brands');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Die Übersichtsseite /marken zeigt je Marke einen mittellangen Text plus drei Merkmale.
|
|
||||||
*
|
|
||||||
* Abgrenzung zu den vorhandenen Feldern:
|
|
||||||
* teaser – Kurztext für die Kachel auf der Startseite
|
|
||||||
* summary – Text auf der Übersichtsseite /marken (neu)
|
|
||||||
* highlights – drei Stichpunkte auf /marken (neu)
|
|
||||||
* description – Einleitung auf der Detailseite
|
|
||||||
* services – Leistungen auf der Detailseite
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_brands', function (Blueprint $table) {
|
|
||||||
$table->json('summary')->nullable()->after('teaser');
|
|
||||||
$table->json('highlights')->nullable()->after('summary');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_brands', function (Blueprint $table) {
|
|
||||||
$table->dropColumn(['summary', 'highlights']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ausführliche Markenseiten nach den Zulieferungen vom 31.08.2026.
|
|
||||||
*
|
|
||||||
* Die gelieferten Texte (Rotpunkt, Febrü) bestehen aus mehreren Abschnitten, von denen einige
|
|
||||||
* eine Vertiefung mitbringen, die laut Umsetzungshinweis aufklappbar dargestellt werden soll.
|
|
||||||
* Dafür reichen `description` (Einleitung) und `services` (Kacheln) nicht aus.
|
|
||||||
*
|
|
||||||
* Ergänzt werden:
|
|
||||||
* - `sections` Abschnitte der Seite, je mit Titel, Absätzen und optionaler Vertiefung
|
|
||||||
* - `widget_label` Beschriftung des schwebenden Terminanfrage-Widgets auf dieser Markenseite
|
|
||||||
* - `cta_title` Überschrift des Abschlussblocks
|
|
||||||
* - `cta_text` Text des Abschlussblocks
|
|
||||||
*
|
|
||||||
* `sections` ist – wie `services`, `highlights` und `gallery` – bewusst nicht übersetzbar:
|
|
||||||
* Die Texte liegen nur auf Deutsch vor. Die drei Textfelder folgen den übrigen Textfeldern
|
|
||||||
* und sind übersetzbar.
|
|
||||||
*/
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_brands', function (Blueprint $table) {
|
|
||||||
$table->json('sections')->nullable()->after('services');
|
|
||||||
$table->json('widget_label')->nullable()->after('sections');
|
|
||||||
$table->json('cta_title')->nullable()->after('widget_label');
|
|
||||||
$table->json('cta_text')->nullable()->after('cta_title');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_brands', function (Blueprint $table) {
|
|
||||||
$table->dropColumn(['sections', 'widget_label', 'cta_title', 'cta_text']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\CmsArticle;
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Magazinbeiträge gehören zu einer Farbwelt (Interior oder Immobilien).
|
|
||||||
*
|
|
||||||
* Die drei Dubai-Beiträge aus dem Bestand werden direkt der Immobilien-Welt zugeordnet,
|
|
||||||
* damit sie nach der Trennung nicht in der Interior-Liste stehen.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_articles', function (Blueprint $table) {
|
|
||||||
$table->string('world', 20)->default(CmsArticle::WORLD_INTERIOR)->index()->after('category');
|
|
||||||
});
|
|
||||||
|
|
||||||
CmsArticle::query()
|
|
||||||
->whereIn('slug', CmsArticle::LEGACY_IMMOBILIEN_SLUGS)
|
|
||||||
->update(['world' => CmsArticle::WORLD_IMMOBILIEN]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_articles', function (Blueprint $table) {
|
|
||||||
$table->dropIndex(['world']);
|
|
||||||
$table->dropColumn('world');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fertigungsort je Kernmarke (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
|
|
||||||
*
|
|
||||||
* Die Herkunfts-Sektion „Aus der Region gefertigt“ holt ihre Standorte nicht mehr aus einem
|
|
||||||
* JSON-Eintrag im generischen Inhalte-Editor, sondern aus der Markenverwaltung: Marcel pflegt
|
|
||||||
* Ort, Entfernung und Rolle direkt bei der Marke. Die Kartenpositionen bleiben im Code.
|
|
||||||
*
|
|
||||||
* - `origin_city` Fertigungsort, z. B. „Bünde“ – leer bedeutet: Marke erscheint nicht in der Sektion
|
|
||||||
* - `origin_distance` Entfernung ab Bielefeld als Text, z. B. „rund 30 km“
|
|
||||||
* - `origin_role` Ein Satz zur Rolle des Standorts
|
|
||||||
*
|
|
||||||
* Alle drei sind übersetzbar wie die übrigen Textfelder der Marke.
|
|
||||||
*/
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_brands', function (Blueprint $table) {
|
|
||||||
$table->json('origin_city')->nullable()->after('showroom');
|
|
||||||
$table->json('origin_distance')->nullable()->after('origin_city');
|
|
||||||
$table->json('origin_role')->nullable()->after('origin_distance');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('cms_brands', function (Blueprint $table) {
|
|
||||||
$table->dropColumn(['origin_city', 'origin_distance', 'origin_role']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Referenzprojekte der Interior-Welt (Nachtrag 13).
|
|
||||||
*
|
|
||||||
* AP5 hatte die Referenzen aus den Markengalerien zusammengesammelt, um keine zweite Pflegestelle
|
|
||||||
* zu schaffen. Abstimmung 15.09.: Marcel soll Referenzen als eigene Einträge pflegen – ein
|
|
||||||
* realisiertes Projekt hat mehr als ein Bild und mehr als eine Marke. Solange kein Eintrag
|
|
||||||
* existiert, greift das Frontend weiter auf die Markengalerien zurück.
|
|
||||||
*
|
|
||||||
* - `title`, `description` übersetzbar
|
|
||||||
* - `location`, `year` Ort und Jahr als Text („Bielefeld“, „2025“)
|
|
||||||
* - `image` Titelbild (Dateiname aus der Medienbibliothek)
|
|
||||||
* - `gallery` weitere Bilder mit Bildunterschrift
|
|
||||||
* - `brand_slugs` beteiligte Kernmarken (Slugs aus cms_brands), verlinkt auf die Markenseiten
|
|
||||||
*/
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('cms_references', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->json('title');
|
|
||||||
$table->string('location')->nullable();
|
|
||||||
$table->string('year', 20)->nullable();
|
|
||||||
$table->json('description')->nullable();
|
|
||||||
$table->string('image')->nullable();
|
|
||||||
$table->json('gallery')->nullable();
|
|
||||||
$table->json('brand_slugs')->nullable();
|
|
||||||
$table->boolean('is_published')->default(true);
|
|
||||||
$table->integer('order')->default(0);
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('cms_references');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
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