10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
|
|
@ -1,24 +1,12 @@
|
|||
{
|
||||
"name": "B2In (Dev Container)",
|
||||
// 1. DIES IST DER WICHTIGSTE TEIL:
|
||||
// Wir verwenden Docker Compose für alle Services
|
||||
"name": "B2in (Dev Container)",
|
||||
"dockerComposeFile": [
|
||||
"../docker-compose.yml"
|
||||
],
|
||||
"service": "laravel.test",
|
||||
// 3. WIR DEFINIEREN DEN ARBEITSBEREICH:
|
||||
// Das ist der Pfad, in dem Ihr Code *innerhalb* des Containers liegt.
|
||||
"workspaceFolder": "/var/www/html",
|
||||
// 4. WIR LEGEN DEN BENUTZER FEST:
|
||||
// Laravel Sail führt Befehle standardmäßig als 'sail'-Benutzer aus, um Berechtigungsprobleme zu vermeiden.
|
||||
"remoteUser": "sail",
|
||||
// 5. ZUSÄTZLICHE ENTWICKLER-TOOLS (FEATURES):
|
||||
// Features werden über postCreateCommand installiert um Kompatibilitätsprobleme zu vermeiden
|
||||
"features": {},
|
||||
// 6. BEFEHLE NACH DEM ERSTELLEN:
|
||||
// Installiert nur die Tools die ohne Root-Rechte funktionieren
|
||||
//"postCreateCommand": "composer install --no-interaction --prefer-dist --optimize-autoloader",
|
||||
// 7. EDITOR-ANPASSUNGEN (Optional, aber sehr empfohlen):
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
|
@ -33,34 +21,22 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
// 8. ZU STARTENDE DIENSTE:
|
||||
// Legt fest, welche Dienste aus der docker-compose.yml gestartet werden sollen.
|
||||
// WICHTIG: Nur noch der Haupt-Container bleibt drin
|
||||
"runServices": [
|
||||
"laravel.test",
|
||||
"mysql",
|
||||
"redis",
|
||||
"mailpit"
|
||||
"laravel.test"
|
||||
],
|
||||
// 9. ZUSÄTZLICHE KONFIGURATION:
|
||||
// Umgebungsvariablen für den DevContainer
|
||||
"containerEnv": {
|
||||
"WWWUSER": "501",
|
||||
"WWWGROUP": "20",
|
||||
"LARAVEL_SAIL": "1"
|
||||
},
|
||||
// 10. MOUNT-KONFIGURATION:
|
||||
// Stellt sicher, dass der Code korrekt gemountet wird
|
||||
"mounts": [
|
||||
"source=${localWorkspaceFolder},target=/var/www/html,type=bind,consistency=cached"
|
||||
],
|
||||
// 11. FORWARD PORTS:
|
||||
// Ports die automatisch weitergeleitet werden sollen
|
||||
// WICHTIG: Nur noch die beiden Vite-Ports weiterleiten
|
||||
"forwardPorts": [
|
||||
5174,
|
||||
5175,
|
||||
33067,
|
||||
6381,
|
||||
8026
|
||||
5175
|
||||
],
|
||||
"portsAttributes": {
|
||||
"5174": {
|
||||
|
|
@ -70,18 +46,6 @@
|
|||
"5175": {
|
||||
"label": "Vite Dev Server (Web)",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"33067": {
|
||||
"label": "MySQL",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"6381": {
|
||||
"label": "Redis",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"8026": {
|
||||
"label": "Mailpit Dashboard",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ services:
|
|||
DB_USERNAME: sail
|
||||
DB_PASSWORD: password
|
||||
# Application Configuration
|
||||
APP_NAME: B2In
|
||||
APP_NAME: B2in
|
||||
APP_ENV: local
|
||||
APP_DEBUG: true
|
||||
APP_URL: http://localhost
|
||||
|
|
@ -42,7 +42,7 @@ services:
|
|||
MAIL_PASSWORD: null
|
||||
MAIL_ENCRYPTION: null
|
||||
MAIL_FROM_ADDRESS: hello@example.com
|
||||
MAIL_FROM_NAME: B2In
|
||||
MAIL_FROM_NAME: B2in
|
||||
# Redis Configuration
|
||||
REDIS_HOST: redis
|
||||
REDIS_PASSWORD: null
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Cookie Consent & Google Analytics (acme/cookie-consent)
|
||||
# GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
|
||||
# GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX
|
||||
# COOKIE_CONSENT_ENABLED=true
|
||||
# COOKIE_CONSENT_LIFETIME=365
|
||||
|
||||
# Testing: Bei true wird die Datenbank nicht zurückgesetzt (DatabaseTransactions statt RefreshDatabase).
|
||||
# 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
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -160,7 +160,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.13
|
||||
- php - 8.4.18
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
|
|
@ -178,10 +178,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||
- alpinejs (ALPINEJS) - v3
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## docs
|
||||
https://livewire.laravel.com/docs/4.x/
|
||||
https://fluxui.dev/docs/
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
|
|
@ -555,8 +551,8 @@ $delete = fn(Product $product) => $product->delete();
|
|||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
|
||||
- You must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ php artisan serve
|
|||
Diese Anwendung verwendet Vite mit verschiedenen Konfigurationen:
|
||||
|
||||
- **Hauptkompilierung**: `npm run dev` oder `npm run build`
|
||||
- **Admin-Assets**: `npm run build:admin`
|
||||
- **Admin-Assets**: `npm run build:portal`
|
||||
- **Web-Assets**: `npm run build:web`
|
||||
|
||||
## Domain-Simulation
|
||||
|
|
|
|||
124
app/Console/Commands/ConvertImagesToWebP.php
Normal file
124
app/Console/Commands/ConvertImagesToWebP.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ConvertImagesToWebP extends Command
|
||||
{
|
||||
protected $signature = 'images:convert-webp
|
||||
{--path=img/assets : Relative path inside public/}
|
||||
{--quality=85 : WebP quality (1-100)}
|
||||
{--force : Overwrite existing WebP files}
|
||||
{--dry-run : Show what would be converted without doing it}';
|
||||
|
||||
protected $description = 'Convert JPG/JPEG/PNG images to WebP format using GD';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$relativePath = $this->option('path');
|
||||
$quality = (int) $this->option('quality');
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$basePath = public_path($relativePath);
|
||||
|
||||
if (! File::isDirectory($basePath)) {
|
||||
$this->error("Directory not found: {$basePath}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$extensions = ['jpg', 'jpeg', 'png'];
|
||||
$files = collect(File::allFiles($basePath))
|
||||
->filter(fn ($file) => in_array(strtolower($file->getExtension()), $extensions));
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
$this->info('No images found to convert.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Found %d images in %s', $files->count(), $relativePath));
|
||||
|
||||
$converted = 0;
|
||||
$skipped = 0;
|
||||
$savedBytes = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $file->getPathname());
|
||||
|
||||
if (File::exists($webpPath) && ! $force) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would convert: {$file->getRelativePathname()}");
|
||||
$converted++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->convertToWebP($file->getPathname(), $webpPath, $quality);
|
||||
|
||||
if ($result) {
|
||||
$originalSize = $file->getSize();
|
||||
$webpSize = filesize($webpPath);
|
||||
$saving = $originalSize - $webpSize;
|
||||
$savedBytes += $saving;
|
||||
$percent = $originalSize > 0 ? round(($saving / $originalSize) * 100, 1) : 0;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✓</info> %s → %s KB → %s KB (<comment>-%s%%</comment>)',
|
||||
$file->getRelativePathname(),
|
||||
round($originalSize / 1024, 1),
|
||||
round($webpSize / 1024, 1),
|
||||
$percent
|
||||
));
|
||||
$converted++;
|
||||
} else {
|
||||
$this->warn(" ✗ Failed: {$file->getRelativePathname()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done: %d converted, %d skipped, %.1f KB saved',
|
||||
$converted,
|
||||
$skipped,
|
||||
$savedBytes / 1024
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function convertToWebP(string $sourcePath, string $destPath, int $quality): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION));
|
||||
|
||||
$image = match ($extension) {
|
||||
'jpg', 'jpeg' => @imagecreatefromjpeg($sourcePath),
|
||||
'png' => @imagecreatefrompng($sourcePath),
|
||||
default => false,
|
||||
};
|
||||
|
||||
if ($image === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($extension === 'png') {
|
||||
imagepalettetotruecolor($image);
|
||||
imagealphablending($image, true);
|
||||
imagesavealpha($image, true);
|
||||
}
|
||||
|
||||
$result = imagewebp($image, $destPath, $quality);
|
||||
imagedestroy($image);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
86
app/Console/Commands/MigrateLegacyDisplays.php
Normal file
86
app/Console/Commands/MigrateLegacyDisplays.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayFooterContent;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use App\Models\DisplayVideo;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class MigrateLegacyDisplays extends Command
|
||||
{
|
||||
protected $signature = 'display:migrate-legacy';
|
||||
|
||||
protected $description = 'Migrate existing DisplayVideo/DisplayFooterContent data into the new DisplayVersion system';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (DisplayVersion::where('name', 'Video-Display (Legacy)')->exists()) {
|
||||
$this->warn('Legacy migration already executed. Skipping.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$videos = DisplayVideo::orderBy('sort_order')->get();
|
||||
$footers = DisplayFooterContent::orderBy('sort_order')->get();
|
||||
|
||||
if ($videos->isEmpty() && $footers->isEmpty()) {
|
||||
$this->info('No legacy data found. Nothing to migrate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$version = DisplayVersion::create([
|
||||
'name' => 'Video-Display (Legacy)',
|
||||
'type' => 'video-display',
|
||||
'settings' => [],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$sortOrder = 0;
|
||||
foreach ($videos as $video) {
|
||||
DisplayVersionItem::create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'video',
|
||||
'content' => [
|
||||
'filename' => $video->filename,
|
||||
'title' => $video->title,
|
||||
'position' => $video->position,
|
||||
],
|
||||
'sort_order' => $sortOrder++,
|
||||
'is_active' => $video->is_active,
|
||||
]);
|
||||
}
|
||||
|
||||
$sortOrder = 0;
|
||||
foreach ($footers as $footer) {
|
||||
DisplayVersionItem::create([
|
||||
'display_version_id' => $version->id,
|
||||
'item_type' => 'footer',
|
||||
'content' => [
|
||||
'headline' => $footer->headline,
|
||||
'subline' => $footer->subline,
|
||||
'url' => $footer->url,
|
||||
],
|
||||
'sort_order' => $sortOrder++,
|
||||
'is_active' => $footer->is_active,
|
||||
]);
|
||||
}
|
||||
|
||||
$display = Display::create([
|
||||
'name' => 'Hauptdisplay',
|
||||
'location' => 'Schaufenster',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$display->versions()->attach($version->id, ['sort_order' => 0]);
|
||||
|
||||
$this->info("Migrated {$videos->count()} videos and {$footers->count()} footer items.");
|
||||
$this->info("Created version: {$version->name} (ID: {$version->id})");
|
||||
$this->info("Created display: {$display->name} (ID: {$display->id})");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
22
app/Console/Commands/ResetCabinetTabletOverrides.php
Normal file
22
app/Console/Commands/ResetCabinetTabletOverrides.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CabinetTabletSetting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ResetCabinetTabletOverrides extends Command
|
||||
{
|
||||
protected $signature = 'cabinet:reset-overrides';
|
||||
|
||||
protected $description = 'Setzt die Sonderöffnungszeiten des Cabinet Info-Tablets zurück';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
CabinetTabletSetting::current()->clearOverrides();
|
||||
|
||||
$this->info('Cabinet-Tablet Sonderöffnungszeiten wurden zurückgesetzt.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
31
app/Enums/DisplayVersionType.php
Normal file
31
app/Enums/DisplayVersionType.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DisplayVersionType: string
|
||||
{
|
||||
case VideoDisplay = 'video-display';
|
||||
case B2in = 'b2in';
|
||||
case Offers = 'offers';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::VideoDisplay => 'Video-Display',
|
||||
self::B2in => 'B2in Display',
|
||||
self::Offers => 'Angebote',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function allowedItemTypes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::VideoDisplay => ['video', 'footer'],
|
||||
self::B2in => ['media'],
|
||||
self::Offers => ['slide'],
|
||||
};
|
||||
}
|
||||
}
|
||||
31
app/Helpers/PriceHelper.php
Normal file
31
app/Helpers/PriceHelper.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class PriceHelper
|
||||
{
|
||||
/** Offizieller AED/USD-Kurs (feste Bindung) */
|
||||
const AED_USD_RATE = 3.6725;
|
||||
|
||||
/** Näherungswert EUR/USD (wird gelegentlich angepasst) */
|
||||
const USD_EUR_RATE = 1.08;
|
||||
|
||||
/**
|
||||
* Formatiert einen AED-Betrag mit EUR- und USD-Umrechnung.
|
||||
*
|
||||
* Beispiel: formatAed(1125000, 'ab') → "ab 1.125.000 AED (ca. 284.000 EUR / 306.000 USD)"
|
||||
*/
|
||||
public static function formatAed(int $aed, string $prefix = ''): string
|
||||
{
|
||||
$usd = (int) (round($aed / self::AED_USD_RATE / 1000) * 1000);
|
||||
$eur = (int) (round($usd / self::USD_EUR_RATE / 1000) * 1000);
|
||||
|
||||
$fmtAed = number_format($aed, 0, ',', '.');
|
||||
$fmtEur = number_format($eur, 0, ',', '.');
|
||||
$fmtUsd = number_format($usd, 0, ',', '.');
|
||||
|
||||
$prefixStr = $prefix !== '' ? "{$prefix} " : '';
|
||||
|
||||
return "{$prefixStr}{$fmtAed} AED (ca. {$fmtEur} EUR / {$fmtUsd} USD)";
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Api/CabinetTabletController.php
Normal file
54
app/Http/Controllers/Api/CabinetTabletController.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CabinetTabletSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CabinetTabletController extends Controller
|
||||
{
|
||||
/**
|
||||
* Full status response for the info-tablet.
|
||||
*/
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$settings = CabinetTabletSetting::current();
|
||||
$computed = $settings->computeStatus();
|
||||
|
||||
return response()->json([
|
||||
'store_status' => $computed['status'],
|
||||
'today_close' => $computed['today_close'],
|
||||
'next_open' => $computed['next_open'],
|
||||
'notice_headline' => $settings->notice_headline,
|
||||
'notice_subtext' => $settings->notice_subtext,
|
||||
'override_open_today' => $settings->override_open_today,
|
||||
'override_close_today' => $settings->override_close_today,
|
||||
'next_appointment' => [
|
||||
'date' => $settings->next_appointment_date?->format('Y-m-d'),
|
||||
'time' => $settings->next_appointment_time,
|
||||
],
|
||||
'hours' => $settings->getHoursArray(),
|
||||
'contact' => [
|
||||
'phone' => $settings->contact_phone,
|
||||
'email' => $settings->contact_email,
|
||||
],
|
||||
'updated_at' => $settings->updated_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight check – returns the timestamp and current computed status
|
||||
* so the tablet can detect both settings changes and time-based open/close transitions.
|
||||
*/
|
||||
public function check(): JsonResponse
|
||||
{
|
||||
$settings = CabinetTabletSetting::current();
|
||||
$computed = $settings->computeStatus();
|
||||
|
||||
return response()->json([
|
||||
'updated_at' => $settings->updated_at?->toIso8601String(),
|
||||
'store_status' => $computed['status'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
151
app/Http/Controllers/Api/DisplayVersionApiController.php
Normal file
151
app/Http/Controllers/Api/DisplayVersionApiController.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Display;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DisplayVersionApiController extends Controller
|
||||
{
|
||||
public function config(Display $display): JsonResponse
|
||||
{
|
||||
if (! $display->is_active) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
$display->load('versions');
|
||||
|
||||
if ($display->versions->isEmpty()) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
$playlist = [];
|
||||
|
||||
foreach ($display->versions as $version) {
|
||||
$items = $version->activeItems()->get();
|
||||
|
||||
$entry = match ($version->type->value) {
|
||||
'video-display' => $this->videoDisplayData($version, $items),
|
||||
'b2in' => $this->b2inData($version, $items),
|
||||
'offers' => $this->offersData($version, $items),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($entry) {
|
||||
$playlist[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'playlist' => $playlist,
|
||||
'updated_at' => $display->versions->max('updated_at')?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function check(Display $display): JsonResponse
|
||||
{
|
||||
if (! $display->is_active) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
$display->load('versions');
|
||||
|
||||
if ($display->versions->isEmpty()) {
|
||||
return response()->json(['error' => 'Display not configured'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'updated_at' => $display->versions->max('updated_at')?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function videoDisplayData($version, $items): array
|
||||
{
|
||||
$videos = $items->where('item_type', 'video')->values()->map(fn ($item) => [
|
||||
'src' => 'assets/'.($item->content['filename'] ?? ''),
|
||||
'position' => $item->content['position'] ?? 25,
|
||||
]);
|
||||
|
||||
$footerContent = $items->where('item_type', 'footer')->values()->map(function ($item) {
|
||||
$data = [
|
||||
'headline' => $item->content['headline'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
];
|
||||
|
||||
if (! empty($item->content['url'])) {
|
||||
$data['url'] = $item->content['url'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return [
|
||||
'type' => 'video-display',
|
||||
'version_name' => $version->name,
|
||||
'videoPlaylist' => $videos,
|
||||
'footerContent' => $footerContent,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function b2inData($version, $items): array
|
||||
{
|
||||
$mediaItems = $items->where('item_type', 'media')->values()->map(fn ($item) => [
|
||||
'id' => $item->id,
|
||||
'category' => $item->content['category'] ?? 'immobilien',
|
||||
'media_type' => $item->content['media_type'] ?? 'image',
|
||||
'media_url' => $item->content['media_url'] ?? '',
|
||||
'headline' => $item->content['headline'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
'duration_seconds' => $item->content['duration_seconds'] ?? 10,
|
||||
'sort_order' => $item->sort_order,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return [
|
||||
'type' => 'b2in',
|
||||
'version_name' => $version->name,
|
||||
'settings' => $version->settings ?? [],
|
||||
'items' => $mediaItems,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function offersData($version, $items): array
|
||||
{
|
||||
$slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [
|
||||
'type' => $item->content['type'] ?? 'product-hero',
|
||||
'duration' => $item->content['duration'] ?? 8000,
|
||||
'image_url' => $item->content['image_url'] ?? '',
|
||||
'badge_text' => $item->content['badge_text'] ?? '',
|
||||
'eyebrow' => $item->content['eyebrow'] ?? '',
|
||||
'title' => $item->content['title'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
'price' => $item->content['price'] ?? '',
|
||||
'original_price' => $item->content['original_price'] ?? '',
|
||||
'tag_text' => $item->content['tag_text'] ?? '',
|
||||
'bullets' => $item->content['bullets'] ?? [],
|
||||
'disclaimer' => $item->content['disclaimer'] ?? '',
|
||||
'qr_url' => $item->content['qr_url'] ?? '',
|
||||
'qr_title' => $item->content['qr_title'] ?? '',
|
||||
'contact' => $item->content['contact'] ?? '',
|
||||
'show_brand_text' => $item->content['show_brand_text'] ?? false,
|
||||
'brand_tagline' => $item->content['brand_tagline'] ?? '',
|
||||
]);
|
||||
|
||||
return [
|
||||
'type' => 'offers',
|
||||
'version_name' => $version->name,
|
||||
'settings' => $version->settings ?? [],
|
||||
'slides' => $slides,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -37,8 +37,14 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Display-API und Short-Links (öffentlicher Zugriff für Display-Seite)
|
||||
if ($request->is('api/display/*') || $request->is('_cabinet/*')) {
|
||||
// Skip Basic Auth für Display-API, Cabinet-Tablet-API und Short-Links (öffentlicher Zugriff für Display-Seiten)
|
||||
// Skip Basic Auth für Display-API, Cabinet-Tablet-API und Short-Links (öffentlicher Zugriff für Display-Seiten)
|
||||
if (
|
||||
$request->is('api/display/*') || $request->is('api/cabinet-tablet/*') || $request->is('_cabinet/*') ||
|
||||
str_contains($request->url(), 'portal.b2in.test') || str_contains($request->url(), 'portal.b2in.eu') ||
|
||||
str_contains($request->url(), 'b2in.test') || str_contains($request->url(), 'b2in.eu')
|
||||
|
||||
) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
|
|
|||
21
app/Http/Middleware/SetLocale.php
Normal file
21
app/Http/Middleware/SetLocale.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = session('locale');
|
||||
|
||||
if ($locale && in_array($locale, ['de', 'en'])) {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\CMS;
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use App\Models\DisplayVideo;
|
||||
use App\Models\DisplayFooterContent;
|
||||
|
|
@ -284,7 +284,7 @@ class CabinetDisplay extends Component
|
|||
$videos = DisplayVideo::orderBy('sort_order')->get();
|
||||
$footerContents = DisplayFooterContent::orderBy('sort_order')->get();
|
||||
|
||||
return view('livewire.admin.c-m-s.cabinet-display', [
|
||||
return view('livewire.admin.cms.cabinet-display', [
|
||||
'videos' => $videos,
|
||||
'footerContents' => $footerContents,
|
||||
]);
|
||||
189
app/Livewire/Admin/Cms/CabinetInfoTablet.php
Normal file
189
app/Livewire/Admin/Cms/CabinetInfoTablet.php
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use App\Models\CabinetTabletSetting;
|
||||
use Livewire\Component;
|
||||
|
||||
class CabinetInfoTablet extends Component
|
||||
{
|
||||
// Store status mode
|
||||
public string $storeStatus = 'auto';
|
||||
|
||||
public string $noticeHeadline = '';
|
||||
|
||||
public string $noticeSubtext = '';
|
||||
|
||||
// Override times for today
|
||||
public ?string $overrideOpenToday = '';
|
||||
|
||||
public ?string $overrideCloseToday = '';
|
||||
|
||||
// Appointment
|
||||
public ?string $nextAppointmentDate = null;
|
||||
|
||||
public ?string $nextAppointmentTime = '';
|
||||
|
||||
// Structured opening hours per weekday (open + close, empty = closed)
|
||||
public ?string $hoursMondayOpen = '10:00';
|
||||
|
||||
public ?string $hoursMondayClose = '18:00';
|
||||
|
||||
public ?string $hoursTuesdayOpen = '10:00';
|
||||
|
||||
public ?string $hoursTuesdayClose = '18:00';
|
||||
|
||||
public ?string $hoursWednesdayOpen = '10:00';
|
||||
|
||||
public ?string $hoursWednesdayClose = '18:00';
|
||||
|
||||
public ?string $hoursThursdayOpen = '10:00';
|
||||
|
||||
public ?string $hoursThursdayClose = '18:00';
|
||||
|
||||
public ?string $hoursFridayOpen = '10:00';
|
||||
|
||||
public ?string $hoursFridayClose = '18:00';
|
||||
|
||||
public ?string $hoursSaturdayOpen = '10:00';
|
||||
|
||||
public ?string $hoursSaturdayClose = '14:00';
|
||||
|
||||
public ?string $hoursSundayOpen = '';
|
||||
|
||||
public ?string $hoursSundayClose = '';
|
||||
|
||||
// Contact
|
||||
public string $contactPhone = '';
|
||||
|
||||
public string $contactEmail = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$s = CabinetTabletSetting::current();
|
||||
|
||||
$this->storeStatus = $s->store_status ?? 'auto';
|
||||
$this->noticeHeadline = $s->notice_headline ?? '';
|
||||
$this->noticeSubtext = $s->notice_subtext ?? '';
|
||||
$this->overrideOpenToday = $s->override_open_today ?? '';
|
||||
$this->overrideCloseToday = $s->override_close_today ?? '';
|
||||
$this->nextAppointmentDate = $s->next_appointment_date?->format('Y-m-d');
|
||||
$this->nextAppointmentTime = $s->next_appointment_time ?? '';
|
||||
$this->hoursMondayOpen = $s->hours_monday_open ?? '';
|
||||
$this->hoursMondayClose = $s->hours_monday_close ?? '';
|
||||
$this->hoursTuesdayOpen = $s->hours_tuesday_open ?? '';
|
||||
$this->hoursTuesdayClose = $s->hours_tuesday_close ?? '';
|
||||
$this->hoursWednesdayOpen = $s->hours_wednesday_open ?? '';
|
||||
$this->hoursWednesdayClose = $s->hours_wednesday_close ?? '';
|
||||
$this->hoursThursdayOpen = $s->hours_thursday_open ?? '';
|
||||
$this->hoursThursdayClose = $s->hours_thursday_close ?? '';
|
||||
$this->hoursFridayOpen = $s->hours_friday_open ?? '';
|
||||
$this->hoursFridayClose = $s->hours_friday_close ?? '';
|
||||
$this->hoursSaturdayOpen = $s->hours_saturday_open ?? '';
|
||||
$this->hoursSaturdayClose = $s->hours_saturday_close ?? '';
|
||||
$this->hoursSundayOpen = $s->hours_sunday_open ?? '';
|
||||
$this->hoursSundayClose = $s->hours_sunday_close ?? '';
|
||||
$this->contactPhone = $s->contact_phone ?? '';
|
||||
$this->contactEmail = $s->contact_email ?? '';
|
||||
}
|
||||
|
||||
private function timeRule(): array
|
||||
{
|
||||
return ['nullable', 'string', 'regex:/^(\d{2}:\d{2})?$/'];
|
||||
}
|
||||
|
||||
private function toNullIfEmpty(?string $value): ?string
|
||||
{
|
||||
return $value !== null && trim($value) !== '' ? trim($value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
|
||||
*/
|
||||
public function save(array $hours = []): void
|
||||
{
|
||||
foreach ($hours as $prop => $value) {
|
||||
if (property_exists($this, $prop)) {
|
||||
$this->{$prop} = $value ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
$timeRule = $this->timeRule();
|
||||
|
||||
$this->validate([
|
||||
'storeStatus' => 'required|in:auto,notice,warning,closed',
|
||||
'noticeHeadline' => 'nullable|string|max:40',
|
||||
'noticeSubtext' => 'nullable|string|max:80',
|
||||
'overrideOpenToday' => $timeRule,
|
||||
'overrideCloseToday' => $timeRule,
|
||||
'nextAppointmentDate' => 'nullable|date',
|
||||
'nextAppointmentTime' => $timeRule,
|
||||
'hoursMondayOpen' => $timeRule,
|
||||
'hoursMondayClose' => $timeRule,
|
||||
'hoursTuesdayOpen' => $timeRule,
|
||||
'hoursTuesdayClose' => $timeRule,
|
||||
'hoursWednesdayOpen' => $timeRule,
|
||||
'hoursWednesdayClose' => $timeRule,
|
||||
'hoursThursdayOpen' => $timeRule,
|
||||
'hoursThursdayClose' => $timeRule,
|
||||
'hoursFridayOpen' => $timeRule,
|
||||
'hoursFridayClose' => $timeRule,
|
||||
'hoursSaturdayOpen' => $timeRule,
|
||||
'hoursSaturdayClose' => $timeRule,
|
||||
'hoursSundayOpen' => $timeRule,
|
||||
'hoursSundayClose' => $timeRule,
|
||||
'contactPhone' => 'nullable|string|max:50',
|
||||
'contactEmail' => 'nullable|email|max:100',
|
||||
], [
|
||||
'storeStatus.required' => 'Der Store-Status ist erforderlich.',
|
||||
'storeStatus.in' => 'Ungültiger Status. Erlaubt: auto, notice, warning, closed.',
|
||||
'noticeHeadline.max' => 'Die Headline darf maximal 40 Zeichen haben.',
|
||||
'noticeSubtext.max' => 'Der Subtext darf maximal 80 Zeichen haben.',
|
||||
'overrideOpenToday.regex' => 'Bitte im Format HH:MM eingeben.',
|
||||
'overrideCloseToday.regex' => 'Bitte im Format HH:MM eingeben.',
|
||||
'nextAppointmentTime.regex' => 'Bitte im Format HH:MM eingeben.',
|
||||
'contactEmail.email' => 'Bitte eine gültige E-Mail-Adresse eingeben.',
|
||||
]);
|
||||
CabinetTabletSetting::current()->update([
|
||||
'store_status' => $this->storeStatus,
|
||||
'notice_headline' => $this->toNullIfEmpty($this->noticeHeadline),
|
||||
'notice_subtext' => $this->toNullIfEmpty($this->noticeSubtext),
|
||||
'override_open_today' => $this->toNullIfEmpty($this->overrideOpenToday),
|
||||
'override_close_today' => $this->toNullIfEmpty($this->overrideCloseToday),
|
||||
'next_appointment_date' => $this->toNullIfEmpty($this->nextAppointmentDate),
|
||||
'next_appointment_time' => $this->toNullIfEmpty($this->nextAppointmentTime),
|
||||
'hours_monday_open' => $this->toNullIfEmpty($this->hoursMondayOpen),
|
||||
'hours_monday_close' => $this->toNullIfEmpty($this->hoursMondayClose),
|
||||
'hours_tuesday_open' => $this->toNullIfEmpty($this->hoursTuesdayOpen),
|
||||
'hours_tuesday_close' => $this->toNullIfEmpty($this->hoursTuesdayClose),
|
||||
'hours_wednesday_open' => $this->toNullIfEmpty($this->hoursWednesdayOpen),
|
||||
'hours_wednesday_close' => $this->toNullIfEmpty($this->hoursWednesdayClose),
|
||||
'hours_thursday_open' => $this->toNullIfEmpty($this->hoursThursdayOpen),
|
||||
'hours_thursday_close' => $this->toNullIfEmpty($this->hoursThursdayClose),
|
||||
'hours_friday_open' => $this->toNullIfEmpty($this->hoursFridayOpen),
|
||||
'hours_friday_close' => $this->toNullIfEmpty($this->hoursFridayClose),
|
||||
'hours_saturday_open' => $this->toNullIfEmpty($this->hoursSaturdayOpen),
|
||||
'hours_saturday_close' => $this->toNullIfEmpty($this->hoursSaturdayClose),
|
||||
'hours_sunday_open' => $this->toNullIfEmpty($this->hoursSundayOpen),
|
||||
'hours_sunday_close' => $this->toNullIfEmpty($this->hoursSundayClose),
|
||||
'contact_phone' => $this->toNullIfEmpty($this->contactPhone),
|
||||
'contact_email' => $this->toNullIfEmpty($this->contactEmail),
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Info-Tablet Einstellungen gespeichert!');
|
||||
}
|
||||
|
||||
public function clearOverrides(): void
|
||||
{
|
||||
CabinetTabletSetting::current()->clearOverrides();
|
||||
$this->overrideOpenToday = '';
|
||||
$this->overrideCloseToday = '';
|
||||
|
||||
session()->flash('success', 'Sonderöffnungszeiten wurden zurückgesetzt!');
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.admin.cms.cabinet-info-tablet');
|
||||
}
|
||||
}
|
||||
153
app/Livewire/Admin/Cms/DisplayList.php
Normal file
153
app/Livewire/Admin/Cms/DisplayList.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use App\Models\Display;
|
||||
use App\Models\DisplayVersion;
|
||||
use Livewire\Component;
|
||||
|
||||
class DisplayList extends Component
|
||||
{
|
||||
public $showModal = false;
|
||||
|
||||
public $displayId = null;
|
||||
|
||||
public $displayName = '';
|
||||
|
||||
public $displayLocation = '';
|
||||
|
||||
/** @var array<int> */
|
||||
public $selectedVersionIds = [];
|
||||
|
||||
public $displayIsActive = true;
|
||||
|
||||
public $addVersionSelect = null;
|
||||
|
||||
public function openModal(?int $id = null): void
|
||||
{
|
||||
if ($id) {
|
||||
$display = Display::with('versions')->findOrFail($id);
|
||||
$this->displayId = $display->id;
|
||||
$this->displayName = $display->name;
|
||||
$this->displayLocation = $display->location ?? '';
|
||||
$this->selectedVersionIds = $display->versions->pluck('id')->toArray();
|
||||
$this->displayIsActive = $display->is_active;
|
||||
} else {
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function addVersion(?int $versionId = null): void
|
||||
{
|
||||
$id = $versionId ?? $this->addVersionSelect;
|
||||
|
||||
if ($id && ! in_array((int) $id, $this->selectedVersionIds)) {
|
||||
$this->selectedVersionIds[] = (int) $id;
|
||||
}
|
||||
|
||||
$this->addVersionSelect = null;
|
||||
}
|
||||
|
||||
public function removeVersion(int $index): void
|
||||
{
|
||||
array_splice($this->selectedVersionIds, $index, 1);
|
||||
}
|
||||
|
||||
public function moveVersion(int $index, string $direction): void
|
||||
{
|
||||
$newIndex = $direction === 'up' ? $index - 1 : $index + 1;
|
||||
|
||||
if ($newIndex < 0 || $newIndex >= count($this->selectedVersionIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$temp = $this->selectedVersionIds[$index];
|
||||
$this->selectedVersionIds[$index] = $this->selectedVersionIds[$newIndex];
|
||||
$this->selectedVersionIds[$newIndex] = $temp;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate([
|
||||
'displayName' => 'required|string|max:255',
|
||||
'displayLocation' => 'nullable|string|max:255',
|
||||
'selectedVersionIds' => 'array',
|
||||
'selectedVersionIds.*' => 'exists:display_versions,id',
|
||||
], [
|
||||
'displayName.required' => 'Bitte geben Sie einen Namen ein.',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $this->displayName,
|
||||
'location' => $this->displayLocation ?: null,
|
||||
'is_active' => $this->displayIsActive,
|
||||
];
|
||||
|
||||
if ($this->displayId) {
|
||||
$display = Display::findOrFail($this->displayId);
|
||||
$display->update($data);
|
||||
session()->flash('success', 'Display erfolgreich aktualisiert!');
|
||||
} else {
|
||||
$display = Display::create($data);
|
||||
session()->flash('success', 'Display erfolgreich erstellt!');
|
||||
}
|
||||
|
||||
// Sync versions with sort_order
|
||||
$syncData = [];
|
||||
foreach ($this->selectedVersionIds as $sortOrder => $versionId) {
|
||||
$syncData[$versionId] = ['sort_order' => $sortOrder];
|
||||
}
|
||||
$display->versions()->sync($syncData);
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function deleteDisplay(int $id): void
|
||||
{
|
||||
$display = Display::findOrFail($id);
|
||||
$name = $display->name;
|
||||
$display->delete();
|
||||
|
||||
session()->flash('success', 'Display "'.$name.'" wurde gelöscht!');
|
||||
}
|
||||
|
||||
public function toggleActive(int $id): void
|
||||
{
|
||||
$display = Display::findOrFail($id);
|
||||
$display->update(['is_active' => ! $display->is_active]);
|
||||
}
|
||||
|
||||
public function closeModal(): void
|
||||
{
|
||||
$this->showModal = false;
|
||||
$this->resetForm();
|
||||
}
|
||||
|
||||
public function resetForm(): void
|
||||
{
|
||||
$this->displayId = null;
|
||||
$this->displayName = '';
|
||||
$this->displayLocation = '';
|
||||
$this->selectedVersionIds = [];
|
||||
$this->displayIsActive = true;
|
||||
$this->addVersionSelect = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$displays = Display::with('versions')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$versions = DisplayVersion::active()
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('livewire.admin.cms.display-list', [
|
||||
'displays' => $displays,
|
||||
'versions' => $versions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
125
app/Livewire/Admin/Cms/DisplayMediaPicker.php
Normal file
125
app/Livewire/Admin/Cms/DisplayMediaPicker.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use App\Models\DisplayMedia;
|
||||
use App\Services\DisplayMediaService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class DisplayMediaPicker extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
use WithPagination;
|
||||
|
||||
public ?int $value = null;
|
||||
|
||||
public string $field = 'media_url';
|
||||
|
||||
public string $type = 'all';
|
||||
|
||||
public string $label = 'Medium auswählen';
|
||||
|
||||
public bool $showModal = false;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $quickUploads = [];
|
||||
|
||||
public function mount(?int $value = null): void
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function openPicker(): void
|
||||
{
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function selectMedia(int $id): void
|
||||
{
|
||||
$media = DisplayMedia::find($id);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->value = $media->id;
|
||||
$this->showModal = false;
|
||||
|
||||
$this->dispatch('display-media-selected', field: $this->field, mediaId: $media->id, url: $media->getUrl());
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
$this->value = null;
|
||||
$this->dispatch('display-media-selected', field: $this->field, mediaId: null, url: null);
|
||||
}
|
||||
|
||||
public function updatedQuickUploads(): void
|
||||
{
|
||||
$this->validate([
|
||||
'quickUploads' => 'nullable|array|max:5',
|
||||
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,mp4,webm,mov|max:51200',
|
||||
]);
|
||||
|
||||
$service = app(DisplayMediaService::class);
|
||||
$lastMedia = null;
|
||||
|
||||
foreach ($this->quickUploads as $file) {
|
||||
$lastMedia = $service->storeUpload($file);
|
||||
}
|
||||
|
||||
$this->quickUploads = [];
|
||||
|
||||
if ($lastMedia) {
|
||||
$this->value = $lastMedia->id;
|
||||
$this->showModal = false;
|
||||
|
||||
$this->dispatch('display-media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
public function removeQuickUpload(int $index): void
|
||||
{
|
||||
if (isset($this->quickUploads[$index])) {
|
||||
unset($this->quickUploads[$index]);
|
||||
$this->quickUploads = array_values($this->quickUploads);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.admin.cms.display-media-picker', [
|
||||
'selectedMedia' => $this->resolveSelectedMedia(),
|
||||
'mediaItems' => $this->resolveMediaItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveSelectedMedia(): ?DisplayMedia
|
||||
{
|
||||
if (! $this->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DisplayMedia::find($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, DisplayMedia>
|
||||
*/
|
||||
private function resolveMediaItems(): LengthAwarePaginator
|
||||
{
|
||||
return DisplayMedia::query()
|
||||
->active()
|
||||
->when($this->type === 'image', fn ($q) => $q->images())
|
||||
->when($this->type === 'video', fn ($q) => $q->videos())
|
||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")
|
||||
->orWhere('title', 'like', "%{$this->search}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(18);
|
||||
}
|
||||
}
|
||||
453
app/Livewire/Admin/Cms/DisplayVersionEditor.php
Normal file
453
app/Livewire/Admin/Cms/DisplayVersionEditor.php
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use App\Models\DisplayVersion;
|
||||
use App\Models\DisplayVersionItem;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class DisplayVersionEditor extends Component
|
||||
{
|
||||
public DisplayVersion $version;
|
||||
|
||||
public string $versionName = '';
|
||||
|
||||
// Item Modal
|
||||
public bool $showItemModal = false;
|
||||
|
||||
public ?int $itemId = null;
|
||||
|
||||
public string $itemType = '';
|
||||
|
||||
// Video-Display: Video fields
|
||||
public string $videoFilename = '';
|
||||
|
||||
public string $videoTitle = '';
|
||||
|
||||
public int $videoPosition = 25;
|
||||
|
||||
public bool $videoIsActive = true;
|
||||
|
||||
// Video-Display: Footer fields
|
||||
public string $footerHeadline = '';
|
||||
|
||||
public string $footerSubline = '';
|
||||
|
||||
public string $footerUrl = '';
|
||||
|
||||
public bool $footerIsActive = true;
|
||||
|
||||
// B2in: Media fields
|
||||
public string $mediaType = 'image';
|
||||
|
||||
public string $mediaCategory = 'immobilien';
|
||||
|
||||
public string $mediaUrl = '';
|
||||
|
||||
public string $mediaHeadline = '';
|
||||
|
||||
public string $mediaSubline = '';
|
||||
|
||||
public int $mediaDuration = 10;
|
||||
|
||||
public bool $mediaIsActive = true;
|
||||
|
||||
// Offers: Slide fields
|
||||
public string $slideType = 'product-hero';
|
||||
|
||||
public int $slideDuration = 8000;
|
||||
|
||||
public string $slideImageUrl = '';
|
||||
|
||||
public string $slideBadge = '';
|
||||
|
||||
public string $slideEyebrow = '';
|
||||
|
||||
public string $slideTitle = '';
|
||||
|
||||
public string $slideSubline = '';
|
||||
|
||||
public string $slidePrice = '';
|
||||
|
||||
public string $slideOriginalPrice = '';
|
||||
|
||||
public string $slideTagText = '';
|
||||
|
||||
/** @var array<string> */
|
||||
public array $slideBullets = [];
|
||||
|
||||
public string $slideDisclaimer = '';
|
||||
|
||||
public string $slideQrUrl = '';
|
||||
|
||||
public string $slideQrTitle = '';
|
||||
|
||||
public string $slideContact = '';
|
||||
|
||||
public bool $slideShowBrandText = false;
|
||||
|
||||
public string $slideBrandTagline = '';
|
||||
|
||||
public bool $slideIsActive = true;
|
||||
|
||||
// Settings Modal
|
||||
public bool $showSettingsModal = false;
|
||||
|
||||
public array $settings = [];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $availableVideos = [];
|
||||
|
||||
public function mount(DisplayVersion $displayVersion): void
|
||||
{
|
||||
$this->version = $displayVersion;
|
||||
$this->versionName = $displayVersion->name;
|
||||
$this->settings = $displayVersion->settings ?? [];
|
||||
|
||||
if ($this->version->type === DisplayVersionType::VideoDisplay) {
|
||||
$this->loadAvailableVideos();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadAvailableVideos(): void
|
||||
{
|
||||
$assetsPath = public_path('_cabinet/assets');
|
||||
|
||||
if (File::exists($assetsPath)) {
|
||||
$this->availableVideos = collect(File::files($assetsPath))
|
||||
->map(fn ($file) => $file->getFilename())
|
||||
->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']))
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleTheme(): void
|
||||
{
|
||||
$settings = $this->version->settings ?? [];
|
||||
$settings['theme'] = ($settings['theme'] ?? 'dark') === 'dark' ? 'light' : 'dark';
|
||||
$this->version->update(['settings' => $settings]);
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
public function saveName(): void
|
||||
{
|
||||
$this->validate([
|
||||
'versionName' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$this->version->update(['name' => $this->versionName]);
|
||||
session()->flash('success', 'Name aktualisiert!');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// SETTINGS
|
||||
// ========================================
|
||||
|
||||
public function openSettingsModal(): void
|
||||
{
|
||||
$this->settings = $this->version->settings ?? [];
|
||||
$this->showSettingsModal = true;
|
||||
}
|
||||
|
||||
public function saveSettings(): void
|
||||
{
|
||||
$this->version->update(['settings' => $this->settings]);
|
||||
$this->showSettingsModal = false;
|
||||
session()->flash('success', 'Einstellungen gespeichert!');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ITEM CRUD
|
||||
// ========================================
|
||||
|
||||
public function openItemModal(?int $id = null, string $type = ''): void
|
||||
{
|
||||
$this->resetItemForm();
|
||||
|
||||
if ($id) {
|
||||
$item = DisplayVersionItem::findOrFail($id);
|
||||
$this->itemId = $item->id;
|
||||
$this->itemType = $item->item_type;
|
||||
$this->loadItemContent($item);
|
||||
} else {
|
||||
$this->itemType = $type ?: $this->defaultItemType();
|
||||
}
|
||||
|
||||
$this->showItemModal = true;
|
||||
}
|
||||
|
||||
public function saveItem(): void
|
||||
{
|
||||
$content = $this->buildItemContent();
|
||||
$isActive = $this->getActiveFlag();
|
||||
|
||||
if ($this->itemId) {
|
||||
$item = DisplayVersionItem::findOrFail($this->itemId);
|
||||
$item->update([
|
||||
'content' => $content,
|
||||
'is_active' => $isActive,
|
||||
]);
|
||||
session()->flash('success', 'Inhalt aktualisiert!');
|
||||
} else {
|
||||
$maxSort = DisplayVersionItem::where('display_version_id', $this->version->id)
|
||||
->where('item_type', $this->itemType)
|
||||
->max('sort_order') ?? -1;
|
||||
|
||||
DisplayVersionItem::create([
|
||||
'display_version_id' => $this->version->id,
|
||||
'item_type' => $this->itemType,
|
||||
'content' => $content,
|
||||
'sort_order' => $maxSort + 1,
|
||||
'is_active' => $isActive,
|
||||
]);
|
||||
session()->flash('success', 'Inhalt hinzugefügt!');
|
||||
}
|
||||
|
||||
$this->closeItemModal();
|
||||
}
|
||||
|
||||
public function deleteItem(int $id): void
|
||||
{
|
||||
DisplayVersionItem::findOrFail($id)->delete();
|
||||
session()->flash('success', 'Inhalt gelöscht!');
|
||||
}
|
||||
|
||||
public function toggleItemStatus(int $id): void
|
||||
{
|
||||
$item = DisplayVersionItem::findOrFail($id);
|
||||
$item->update(['is_active' => ! $item->is_active]);
|
||||
}
|
||||
|
||||
public function moveItem(int $id, string $direction): void
|
||||
{
|
||||
$item = DisplayVersionItem::findOrFail($id);
|
||||
$currentOrder = $item->sort_order;
|
||||
|
||||
$swapItem = DisplayVersionItem::where('display_version_id', $this->version->id)
|
||||
->where('item_type', $item->item_type)
|
||||
->where('sort_order', $direction === 'up' ? $currentOrder - 1 : $currentOrder + 1)
|
||||
->first();
|
||||
|
||||
if ($swapItem) {
|
||||
$item->update(['sort_order' => $swapItem->sort_order]);
|
||||
$swapItem->update(['sort_order' => $currentOrder]);
|
||||
}
|
||||
}
|
||||
|
||||
#[On('display-media-selected')]
|
||||
public function onDisplayMediaSelected(string $field, ?int $mediaId, ?string $url): void
|
||||
{
|
||||
if (! $url) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($field) {
|
||||
'videoFilename' => $this->videoFilename = $url,
|
||||
'mediaUrl' => $this->mediaUrl = $url,
|
||||
'slideImageUrl' => $this->slideImageUrl = $url,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function addBullet(): void
|
||||
{
|
||||
$this->slideBullets[] = '';
|
||||
}
|
||||
|
||||
public function removeBullet(int $index): void
|
||||
{
|
||||
unset($this->slideBullets[$index]);
|
||||
$this->slideBullets = array_values($this->slideBullets);
|
||||
}
|
||||
|
||||
public function closeItemModal(): void
|
||||
{
|
||||
$this->showItemModal = false;
|
||||
$this->resetItemForm();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// HELPERS
|
||||
// ========================================
|
||||
|
||||
private function loadItemContent(DisplayVersionItem $item): void
|
||||
{
|
||||
$content = $item->content;
|
||||
|
||||
match ($item->item_type) {
|
||||
'video' => $this->loadVideoContent($content),
|
||||
'footer' => $this->loadFooterContent($content),
|
||||
'media' => $this->loadMediaContent($content),
|
||||
'slide' => $this->loadSlideContent($content),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function loadVideoContent(array $content): void
|
||||
{
|
||||
$this->videoFilename = $content['filename'] ?? '';
|
||||
$this->videoTitle = $content['title'] ?? '';
|
||||
$this->videoPosition = $content['position'] ?? 25;
|
||||
$this->videoIsActive = true;
|
||||
}
|
||||
|
||||
private function loadFooterContent(array $content): void
|
||||
{
|
||||
$this->footerHeadline = $content['headline'] ?? '';
|
||||
$this->footerSubline = $content['subline'] ?? '';
|
||||
$this->footerUrl = $content['url'] ?? '';
|
||||
$this->footerIsActive = true;
|
||||
}
|
||||
|
||||
private function loadMediaContent(array $content): void
|
||||
{
|
||||
$this->mediaType = $content['media_type'] ?? 'image';
|
||||
$this->mediaCategory = $content['category'] ?? 'immobilien';
|
||||
$this->mediaUrl = $content['media_url'] ?? '';
|
||||
$this->mediaHeadline = $content['headline'] ?? '';
|
||||
$this->mediaSubline = $content['subline'] ?? '';
|
||||
$this->mediaDuration = $content['duration_seconds'] ?? 10;
|
||||
$this->mediaIsActive = true;
|
||||
}
|
||||
|
||||
private function loadSlideContent(array $content): void
|
||||
{
|
||||
$this->slideType = $content['type'] ?? 'product-hero';
|
||||
$this->slideDuration = $content['duration'] ?? 8000;
|
||||
$this->slideImageUrl = $content['image_url'] ?? '';
|
||||
$this->slideBadge = $content['badge_text'] ?? '';
|
||||
$this->slideEyebrow = $content['eyebrow'] ?? '';
|
||||
$this->slideTitle = $content['title'] ?? '';
|
||||
$this->slideSubline = $content['subline'] ?? '';
|
||||
$this->slidePrice = $content['price'] ?? '';
|
||||
$this->slideOriginalPrice = $content['original_price'] ?? '';
|
||||
$this->slideTagText = $content['tag_text'] ?? '';
|
||||
$this->slideBullets = $content['bullets'] ?? [];
|
||||
$this->slideDisclaimer = $content['disclaimer'] ?? '';
|
||||
$this->slideQrUrl = $content['qr_url'] ?? '';
|
||||
$this->slideQrTitle = $content['qr_title'] ?? '';
|
||||
$this->slideContact = $content['contact'] ?? '';
|
||||
$this->slideShowBrandText = $content['show_brand_text'] ?? false;
|
||||
$this->slideBrandTagline = $content['brand_tagline'] ?? '';
|
||||
$this->slideIsActive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildItemContent(): array
|
||||
{
|
||||
return match ($this->itemType) {
|
||||
'video' => [
|
||||
'filename' => $this->videoFilename,
|
||||
'title' => $this->videoTitle,
|
||||
'position' => $this->videoPosition,
|
||||
],
|
||||
'footer' => [
|
||||
'headline' => $this->footerHeadline,
|
||||
'subline' => $this->footerSubline,
|
||||
'url' => $this->footerUrl ?: null,
|
||||
],
|
||||
'media' => [
|
||||
'media_type' => $this->mediaType,
|
||||
'category' => $this->mediaCategory,
|
||||
'media_url' => $this->mediaUrl,
|
||||
'headline' => $this->mediaHeadline,
|
||||
'subline' => $this->mediaSubline,
|
||||
'duration_seconds' => $this->mediaDuration,
|
||||
],
|
||||
'slide' => [
|
||||
'type' => $this->slideType,
|
||||
'duration' => $this->slideDuration,
|
||||
'image_url' => $this->slideImageUrl,
|
||||
'badge_text' => $this->slideBadge,
|
||||
'eyebrow' => $this->slideEyebrow,
|
||||
'title' => $this->slideTitle,
|
||||
'subline' => $this->slideSubline,
|
||||
'price' => $this->slidePrice,
|
||||
'original_price' => $this->slideOriginalPrice,
|
||||
'tag_text' => $this->slideTagText,
|
||||
'bullets' => $this->slideBullets,
|
||||
'disclaimer' => $this->slideDisclaimer,
|
||||
'qr_url' => $this->slideQrUrl,
|
||||
'qr_title' => $this->slideQrTitle,
|
||||
'contact' => $this->slideContact,
|
||||
'show_brand_text' => $this->slideShowBrandText,
|
||||
'brand_tagline' => $this->slideBrandTagline,
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function getActiveFlag(): bool
|
||||
{
|
||||
return match ($this->itemType) {
|
||||
'video' => $this->videoIsActive,
|
||||
'footer' => $this->footerIsActive,
|
||||
'media' => $this->mediaIsActive,
|
||||
'slide' => $this->slideIsActive,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
private function defaultItemType(): string
|
||||
{
|
||||
return match ($this->version->type) {
|
||||
DisplayVersionType::VideoDisplay => 'video',
|
||||
DisplayVersionType::B2in => 'media',
|
||||
DisplayVersionType::Offers => 'slide',
|
||||
};
|
||||
}
|
||||
|
||||
private function resetItemForm(): void
|
||||
{
|
||||
$this->itemId = null;
|
||||
$this->itemType = '';
|
||||
$this->videoFilename = '';
|
||||
$this->videoTitle = '';
|
||||
$this->videoPosition = 25;
|
||||
$this->videoIsActive = true;
|
||||
$this->footerHeadline = '';
|
||||
$this->footerSubline = '';
|
||||
$this->footerUrl = '';
|
||||
$this->footerIsActive = true;
|
||||
$this->mediaType = 'image';
|
||||
$this->mediaCategory = 'immobilien';
|
||||
$this->mediaUrl = '';
|
||||
$this->mediaHeadline = '';
|
||||
$this->mediaSubline = '';
|
||||
$this->mediaDuration = 10;
|
||||
$this->mediaIsActive = true;
|
||||
$this->slideType = 'product-hero';
|
||||
$this->slideDuration = 8000;
|
||||
$this->slideImageUrl = '';
|
||||
$this->slideBadge = '';
|
||||
$this->slideEyebrow = '';
|
||||
$this->slideTitle = '';
|
||||
$this->slideSubline = '';
|
||||
$this->slidePrice = '';
|
||||
$this->slideOriginalPrice = '';
|
||||
$this->slideTagText = '';
|
||||
$this->slideBullets = [];
|
||||
$this->slideDisclaimer = '';
|
||||
$this->slideQrUrl = '';
|
||||
$this->slideQrTitle = '';
|
||||
$this->slideContact = '';
|
||||
$this->slideShowBrandText = false;
|
||||
$this->slideBrandTagline = '';
|
||||
$this->slideIsActive = true;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$items = $this->version->items()->get()->groupBy('item_type');
|
||||
|
||||
return view('livewire.admin.cms.display-version-editor', [
|
||||
'items' => $items,
|
||||
]);
|
||||
}
|
||||
}
|
||||
102
app/Livewire/Admin/Cms/DisplayVersionList.php
Normal file
102
app/Livewire/Admin/Cms/DisplayVersionList.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use App\Models\DisplayVersion;
|
||||
use Livewire\Component;
|
||||
|
||||
class DisplayVersionList extends Component
|
||||
{
|
||||
public $showCreateModal = false;
|
||||
|
||||
public $newName = '';
|
||||
|
||||
public $newType = '';
|
||||
|
||||
public function openCreateModal(): void
|
||||
{
|
||||
$this->newName = '';
|
||||
$this->newType = '';
|
||||
$this->showCreateModal = true;
|
||||
}
|
||||
|
||||
public function createVersion(): void
|
||||
{
|
||||
$this->validate([
|
||||
'newName' => 'required|string|max:255',
|
||||
'newType' => 'required|string|in:video-display,b2in,offers',
|
||||
], [
|
||||
'newName.required' => 'Bitte geben Sie einen Namen ein.',
|
||||
'newType.required' => 'Bitte wählen Sie einen Typ aus.',
|
||||
]);
|
||||
|
||||
$version = DisplayVersion::create([
|
||||
'name' => $this->newName,
|
||||
'type' => $this->newType,
|
||||
'settings' => $this->defaultSettingsForType($this->newType),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->showCreateModal = false;
|
||||
$this->newName = '';
|
||||
$this->newType = '';
|
||||
|
||||
session()->flash('success', 'Version "'.$version->name.'" wurde erstellt!');
|
||||
|
||||
$this->redirect(
|
||||
route('admin.cms.display-version-edit', $version),
|
||||
navigate: true
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteVersion(int $id): void
|
||||
{
|
||||
$version = DisplayVersion::findOrFail($id);
|
||||
$name = $version->name;
|
||||
$version->delete();
|
||||
|
||||
session()->flash('success', 'Version "'.$name.'" wurde gelöscht!');
|
||||
}
|
||||
|
||||
public function toggleActive(int $id): void
|
||||
{
|
||||
$version = DisplayVersion::findOrFail($id);
|
||||
$version->update(['is_active' => ! $version->is_active]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function defaultSettingsForType(string $type): array
|
||||
{
|
||||
return match ($type) {
|
||||
'b2in' => [
|
||||
'theme' => 'dark',
|
||||
'footer_name' => '',
|
||||
'footer_url' => '',
|
||||
'transition' => ['type' => 'crossfade', 'duration_ms' => 800],
|
||||
'default_image_duration' => 10,
|
||||
'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
|
||||
'display_active' => true,
|
||||
],
|
||||
'offers' => [
|
||||
'loop' => true,
|
||||
'transition' => ['type' => 'fade', 'duration' => 600],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$versions = DisplayVersion::withCount(['items', 'displays'])
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('livewire.admin.cms.display-version-list', [
|
||||
'versions' => $versions,
|
||||
'types' => DisplayVersionType::cases(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
app/Livewire/Admin/Cms/MediaLibraryUploader.php
Normal file
45
app/Livewire/Admin/Cms/MediaLibraryUploader.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class MediaLibraryUploader extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $uploads = [];
|
||||
|
||||
public function updatedUploads(): void
|
||||
{
|
||||
$this->validate([
|
||||
'uploads' => 'nullable|array|max:20',
|
||||
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
||||
]);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
|
||||
foreach ($this->uploads as $file) {
|
||||
$media = $service->storeUpload($file);
|
||||
$this->dispatch('media-library-uploaded', mediaId: $media->id);
|
||||
}
|
||||
|
||||
$this->uploads = [];
|
||||
}
|
||||
|
||||
public function removeUpload(int $index): void
|
||||
{
|
||||
if (isset($this->uploads[$index])) {
|
||||
unset($this->uploads[$index]);
|
||||
$this->uploads = array_values($this->uploads);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.cms.media-library-uploader');
|
||||
}
|
||||
}
|
||||
139
app/Livewire/Admin/Cms/MediaPicker.php
Normal file
139
app/Livewire/Admin/Cms/MediaPicker.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\Cms;
|
||||
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class MediaPicker extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
use WithPagination;
|
||||
|
||||
public ?int $value = null;
|
||||
|
||||
public string $field = 'media_id';
|
||||
|
||||
public string $type = 'image';
|
||||
|
||||
public string $profile = 'thumbnail';
|
||||
|
||||
public string $label = 'Bild auswählen';
|
||||
|
||||
public bool $showModal = false;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $quickUploads = [];
|
||||
|
||||
public function mount(?int $value = null): void
|
||||
{
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
public function openPicker(): void
|
||||
{
|
||||
$this->showModal = true;
|
||||
}
|
||||
|
||||
public function selectMedia(int $id): void
|
||||
{
|
||||
$media = CmsMedia::find($id);
|
||||
if (! $media) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($media->isImage() && $this->profile) {
|
||||
$service = app(MediaConversionService::class);
|
||||
if (! $media->hasConversion($this->profile)) {
|
||||
$service->convert($media, $this->profile);
|
||||
$media->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$this->value = $media->id;
|
||||
$this->showModal = false;
|
||||
|
||||
$this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile));
|
||||
}
|
||||
|
||||
public function clearSelection(): void
|
||||
{
|
||||
$this->value = null;
|
||||
$this->dispatch('media-selected', field: $this->field, mediaId: null, url: null);
|
||||
}
|
||||
|
||||
public function updatedQuickUploads(): void
|
||||
{
|
||||
$this->validate([
|
||||
'quickUploads' => 'nullable|array|max:5',
|
||||
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
|
||||
]);
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$lastMedia = null;
|
||||
|
||||
foreach ($this->quickUploads as $file) {
|
||||
$lastMedia = $service->storeUpload($file);
|
||||
|
||||
if ($lastMedia->isImage() && $this->profile) {
|
||||
$service->convert($lastMedia, $this->profile);
|
||||
$lastMedia->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$this->quickUploads = [];
|
||||
|
||||
if ($lastMedia) {
|
||||
$this->value = $lastMedia->id;
|
||||
$this->showModal = false;
|
||||
|
||||
$this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile));
|
||||
}
|
||||
}
|
||||
|
||||
public function removeQuickUpload(int $index): void
|
||||
{
|
||||
if (isset($this->quickUploads[$index])) {
|
||||
unset($this->quickUploads[$index]);
|
||||
$this->quickUploads = array_values($this->quickUploads);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.admin.cms.media-picker', [
|
||||
'selectedMedia' => $this->resolveSelectedMedia(),
|
||||
'mediaItems' => $this->resolveMediaItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveSelectedMedia(): ?CmsMedia
|
||||
{
|
||||
if (! $this->value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CmsMedia::find($this->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LengthAwarePaginator<int, CmsMedia>
|
||||
*/
|
||||
private function resolveMediaItems(): LengthAwarePaginator
|
||||
{
|
||||
return CmsMedia::query()
|
||||
->when($this->type === 'image', fn ($q) => $q->images())
|
||||
->when($this->type === 'pdf', fn ($q) => $q->pdfs())
|
||||
->when($this->type === 'document', fn ($q) => $q->documents())
|
||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(18);
|
||||
}
|
||||
}
|
||||
109
app/Livewire/Cabinet/QuickStatus.php
Normal file
109
app/Livewire/Cabinet/QuickStatus.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Cabinet;
|
||||
|
||||
use App\Models\CabinetTabletSetting;
|
||||
use Livewire\Component;
|
||||
|
||||
class QuickStatus extends Component
|
||||
{
|
||||
public bool $authorized = false;
|
||||
|
||||
public string $storeStatus = 'auto';
|
||||
|
||||
public string $noticeHeadline = '';
|
||||
|
||||
public string $noticeSubtext = '';
|
||||
|
||||
public bool $saved = false;
|
||||
|
||||
/** @var array<string, array{label: string, color: string, icon: string, description: string}> */
|
||||
public array $statusOptions = [
|
||||
'auto' => [
|
||||
'label' => 'Automatisch',
|
||||
'color' => 'green',
|
||||
'icon' => '✓',
|
||||
'description' => 'Status aus Öffnungszeiten',
|
||||
],
|
||||
'closed' => [
|
||||
'label' => 'Geschlossen',
|
||||
'color' => 'yellow',
|
||||
'icon' => '–',
|
||||
'description' => 'Manuell geschlossen',
|
||||
],
|
||||
'notice' => [
|
||||
'label' => 'Hinweis',
|
||||
'color' => 'orange',
|
||||
'icon' => '!',
|
||||
'description' => 'Info-Nachricht anzeigen',
|
||||
],
|
||||
'warning' => [
|
||||
'label' => 'Warnung',
|
||||
'color' => 'red',
|
||||
'icon' => '!',
|
||||
'description' => 'Dringende Warnung',
|
||||
],
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$validKey = config('domains.cabinet_status_key');
|
||||
$key = request()->get('key');
|
||||
if (! $validKey || $key !== $validKey) {
|
||||
$this->authorized = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->authorized = true;
|
||||
$settings = CabinetTabletSetting::current();
|
||||
$this->storeStatus = $settings->store_status ?? 'auto';
|
||||
$this->noticeHeadline = $settings->notice_headline ?? '';
|
||||
$this->noticeSubtext = $settings->notice_subtext ?? '';
|
||||
}
|
||||
|
||||
public function selectStatus(string $status): void
|
||||
{
|
||||
if (! $this->authorized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! array_key_exists($status, $this->statusOptions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->storeStatus = $status;
|
||||
$this->saved = false;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
if (! $this->authorized) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'storeStatus' => 'required|in:auto,notice,warning,closed',
|
||||
'noticeHeadline' => 'nullable|string|max:40',
|
||||
'noticeSubtext' => 'nullable|string|max:80',
|
||||
], [
|
||||
'storeStatus.in' => 'Ungültiger Status.',
|
||||
'noticeHeadline.max' => 'Headline max. 40 Zeichen.',
|
||||
'noticeSubtext.max' => 'Subtext max. 80 Zeichen.',
|
||||
]);
|
||||
|
||||
CabinetTabletSetting::current()->update([
|
||||
'store_status' => $this->storeStatus,
|
||||
'notice_headline' => $this->noticeHeadline ?: null,
|
||||
'notice_subtext' => $this->noticeSubtext ?: null,
|
||||
]);
|
||||
|
||||
$this->saved = true;
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.cabinet.quick-status')
|
||||
->layout('layouts.cabinet-quick');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,7 @@ class AboutHero extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.about_hero", []);
|
||||
$this->content = cms_theme_section('about_hero');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ use Livewire\Component;
|
|||
class BenefitsSection extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $layout = 'left'; // Standard-Layout
|
||||
|
||||
public $bg = 'bg-background';
|
||||
|
||||
public $section = 'card_section';
|
||||
|
||||
public function mount($layout = 'left', $bg = 'bg-background', $section = 'card_section')
|
||||
|
|
@ -16,11 +19,9 @@ class BenefitsSection extends Component
|
|||
$this->layout = $layout;
|
||||
$this->bg = $bg;
|
||||
$this->section = $section;
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.benefits-section');
|
||||
|
|
|
|||
|
|
@ -7,14 +7,16 @@ use Livewire\Component;
|
|||
class BrandWorlds extends Component
|
||||
{
|
||||
public $title;
|
||||
|
||||
public $subtitle;
|
||||
|
||||
public $worlds = [];
|
||||
|
||||
public $bg = 'bg-background';
|
||||
|
||||
public function mount($bg = 'bg-background')
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$content = config("content.themes.{$theme}.brand_worlds");
|
||||
$content = cms_theme_section('brand_worlds');
|
||||
|
||||
$this->title = $content['title'] ?? '';
|
||||
$this->subtitle = $content['subtitle'] ?? '';
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class BrokerSection extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.broker_section", []);
|
||||
$this->content = cms_theme_section('broker_section');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ use Livewire\Component;
|
|||
class CTASection extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $bg = '';
|
||||
|
||||
public $section = '';
|
||||
|
||||
public function mount($bg = 'bg-secondary', $section = 'cta_section')
|
||||
{
|
||||
$this->section = $section;
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ use Livewire\Component;
|
|||
class CardSection extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $layout = 'left'; // Standard-Layout
|
||||
|
||||
public $bg = 'bg-background';
|
||||
|
||||
public $section = 'card_section';
|
||||
|
||||
public function mount($layout = 'left', $bg = 'bg-background', $section = 'card_section')
|
||||
|
|
@ -16,8 +19,7 @@ class CardSection extends Component
|
|||
$this->layout = $layout;
|
||||
$this->bg = $bg;
|
||||
$this->section = $section;
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class CommitmentSection extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.commitment_section", []);
|
||||
$this->content = cms_theme_section('commitment_section');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ use Livewire\Component;
|
|||
class ContentSection extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $layout = 'left'; // Standard-Layout
|
||||
|
||||
public $bg = 'bg-background';
|
||||
|
||||
public $section = 'content_section_left';
|
||||
|
||||
public function mount($layout = 'left', $bg = 'bg-background', $section = 'content_section')
|
||||
|
|
@ -16,8 +19,7 @@ class ContentSection extends Component
|
|||
$this->layout = $layout;
|
||||
$this->bg = $bg;
|
||||
$this->section = $section;
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class DarkStatsSection extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.dark_stats_section", []);
|
||||
$this->content = cms_theme_section('dark_stats_section');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class DigitalCore extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.digital_core", []);
|
||||
$this->content = cms_theme_section('digital_core');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ use Livewire\Component;
|
|||
class EcosystemCore extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $bg = 'bg-background';
|
||||
|
||||
public $section = 'ecosystem_core';
|
||||
|
||||
public function mount($bg = 'bg-background', $section = 'ecosystem_core')
|
||||
|
|
@ -15,8 +17,7 @@ class EcosystemCore extends Component
|
|||
$this->bg = $bg;
|
||||
$this->section = $section;
|
||||
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class EcosystemHero extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.ecosystem_hero", []);
|
||||
$this->content = cms_theme_section('ecosystem_hero');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class EcosystemStats extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.ecosystem_stats", []);
|
||||
$this->content = cms_theme_section('ecosystem_stats');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,15 +7,16 @@ use Livewire\Component;
|
|||
class EndCustomerSection extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $bg = '';
|
||||
|
||||
public $section = '';
|
||||
|
||||
public function mount($bg = 'bg-accent', $section = 'end_customer_section')
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->section = $section;
|
||||
$this->bg = $bg;
|
||||
$this->content = config("content.themes.{$theme}.end_customer_section", []);
|
||||
$this->content = cms_theme_section('end_customer_section');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ use Livewire\Component;
|
|||
class FAQ extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $bg = '';
|
||||
|
||||
public $section = '';
|
||||
|
||||
public function mount($bg = 'bg-background', $section = 'faq')
|
||||
|
|
@ -15,8 +17,7 @@ class FAQ extends Component
|
|||
$this->bg = $bg;
|
||||
$this->section = $section;
|
||||
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class FinalCommitment extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.final_commitment", []);
|
||||
$this->content = cms_theme_section('final_commitment');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
20
app/Livewire/Web/Components/Sections/FounderBar.php
Normal file
20
app/Livewire/Web/Components/Sections/FounderBar.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class FounderBar extends Component
|
||||
{
|
||||
public array $content = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->content = cms_theme_section('founder_bar');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.founder-bar');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,7 @@ class Hero extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.hero", []);
|
||||
$this->content = cms_theme_section('hero');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class HeroImage extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.hero_image", []);
|
||||
$this->content = cms_theme_section('hero_image');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ use Livewire\Component;
|
|||
class HeroSlider extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $currentSlide = 0;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.hero_slider", []);
|
||||
$this->content = cms_theme_section('hero_slider');
|
||||
}
|
||||
|
||||
public function nextSlide()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class HeroTiles extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.hero_tiles", []);
|
||||
$this->content = cms_theme_section('hero_tiles');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
23
app/Livewire/Web/Components/Sections/ImageBreak.php
Normal file
23
app/Livewire/Web/Components/Sections/ImageBreak.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ImageBreak extends Component
|
||||
{
|
||||
public array $content = [];
|
||||
|
||||
public string $section = 'about_image_break';
|
||||
|
||||
public function mount(string $section = 'about_image_break'): void
|
||||
{
|
||||
$this->section = $section;
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.image-break');
|
||||
}
|
||||
}
|
||||
100
app/Livewire/Web/Components/Sections/ImmobilienContactForm.php
Normal file
100
app/Livewire/Web/Components/Sections/ImmobilienContactForm.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Web\Components\Sections;
|
||||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use Livewire\Component;
|
||||
|
||||
class ImmobilienContactForm extends Component
|
||||
{
|
||||
public string $projectSlug = '';
|
||||
|
||||
public string $projectTitle = '';
|
||||
|
||||
/** @var array<string, string> */
|
||||
public array $interestOptions = [];
|
||||
|
||||
public string $interest = '';
|
||||
|
||||
public string $firstName = '';
|
||||
|
||||
public string $lastName = '';
|
||||
|
||||
public string $email = '';
|
||||
|
||||
public string $phone = '';
|
||||
|
||||
public string $message = '';
|
||||
|
||||
public bool $privacy = false;
|
||||
|
||||
public bool $success = false;
|
||||
|
||||
/** @var string Hidden honeypot field */
|
||||
public string $website = '';
|
||||
|
||||
public ?int $formLoadedAt = null;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $interestOptions
|
||||
*/
|
||||
public function mount(string $projectSlug = '', string $projectTitle = '', array $interestOptions = []): void
|
||||
{
|
||||
$this->projectSlug = $projectSlug;
|
||||
$this->projectTitle = $projectTitle;
|
||||
$this->interestOptions = $interestOptions;
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function submit(ContactFormService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'firstName' => ['required', 'string', 'max:255'],
|
||||
'lastName' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email:rfc', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:80'],
|
||||
'interest' => ['nullable', 'string', 'max:255'],
|
||||
'message' => ['nullable', 'string', 'max:2000'],
|
||||
'privacy' => ['accepted'],
|
||||
'website' => ['nullable', 'string', 'max:0'],
|
||||
]);
|
||||
|
||||
$spamDetector = SpamDetector::fromConfig();
|
||||
$payload = [
|
||||
'project' => $this->projectSlug,
|
||||
'project_title' => $this->projectTitle,
|
||||
'interest' => $this->interest,
|
||||
'first_name' => $this->firstName,
|
||||
'last_name' => $this->lastName,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'message' => $this->message,
|
||||
'website' => $this->website,
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'is_spam' => false,
|
||||
];
|
||||
|
||||
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
|
||||
|
||||
$subject = 'Immobilien-Anfrage: '.($this->projectTitle ?: $this->projectSlug);
|
||||
$service->handle($payload, $subject, 'immobilien-contact-form');
|
||||
|
||||
$this->success = true;
|
||||
$this->interest = '';
|
||||
$this->firstName = '';
|
||||
$this->lastName = '';
|
||||
$this->email = '';
|
||||
$this->phone = '';
|
||||
$this->message = '';
|
||||
$this->privacy = false;
|
||||
$this->website = '';
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.web.components.sections.immobilien-contact-form');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,8 +10,7 @@ class LeadershipTeam extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.leadership_team", []);
|
||||
$this->content = cms_theme_section('leadership_team');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ use Livewire\Component;
|
|||
class MagazinDetail extends Component
|
||||
{
|
||||
public $articleId;
|
||||
|
||||
public $article;
|
||||
|
||||
public $relatedArticles;
|
||||
|
||||
public $content;
|
||||
|
||||
public function mount($id = 1)
|
||||
|
|
@ -37,20 +40,19 @@ class MagazinDetail extends Component
|
|||
|
||||
private function loadThemeContent()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.magazin_detail", []);
|
||||
$this->content = cms_theme_section('magazin_detail');
|
||||
}
|
||||
|
||||
private function getArticlesData()
|
||||
{
|
||||
return config('content.articles', []);
|
||||
return trans('b2in.articles');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.magazin-detail', [
|
||||
'article' => $this->article,
|
||||
'relatedArticles' => $this->relatedArticles
|
||||
'relatedArticles' => $this->relatedArticles,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use Livewire\Component;
|
|||
class MagazinList extends Component
|
||||
{
|
||||
public $posts = [];
|
||||
|
||||
public $content = [];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -17,7 +18,7 @@ class MagazinList extends Component
|
|||
|
||||
private function loadPosts()
|
||||
{
|
||||
$articles = config('content.articles', []);
|
||||
$articles = trans('b2in.articles');
|
||||
$this->posts = collect($articles)->map(function ($article) {
|
||||
return [
|
||||
'id' => $article['id'],
|
||||
|
|
@ -32,8 +33,7 @@ class MagazinList extends Component
|
|||
|
||||
private function loadThemeContent()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.magazin_list", []);
|
||||
$this->content = cms_theme_section('magazin_list');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class OurStory extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.our_story", []);
|
||||
$this->content = cms_theme_section('our_story');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class OurValues extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.our_values", []);
|
||||
$this->content = cms_theme_section('our_values');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class PartnerBenefits extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.partner_benefits", []);
|
||||
$this->content = cms_theme_section('partner_benefits');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ use Livewire\Component;
|
|||
class PartnerCTA extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $bg = '';
|
||||
|
||||
public $section = '';
|
||||
|
||||
public function mount($bg = 'bg-secondary', $section = 'partner_cta')
|
||||
{
|
||||
$this->section = $section;
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
$this->bg = $bg;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,20 +28,21 @@ class PartnerHero extends Component
|
|||
'icon' => 'award',
|
||||
],
|
||||
];
|
||||
|
||||
public $section = 'partner_hero';
|
||||
|
||||
public $content = [];
|
||||
|
||||
public function mount($section = 'partner_hero')
|
||||
{
|
||||
$this->section = $section;
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.{$this->section}", []);
|
||||
$this->content = cms_theme_section($this->section);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.web.components.sections.partner-hero', [
|
||||
'partnerTypes' => $this->partnerTypes
|
||||
'partnerTypes' => $this->partnerTypes,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class PartnerProcess extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.partner_process", []);
|
||||
$this->content = cms_theme_section('partner_process');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,10 +7,15 @@ use Livewire\Component;
|
|||
class Portfolio extends Component
|
||||
{
|
||||
public string $activeFilter = 'alle';
|
||||
|
||||
public ?array $selectedProject = null;
|
||||
|
||||
public bool $showModal = false;
|
||||
|
||||
public $content = [];
|
||||
|
||||
public $theme = '';
|
||||
|
||||
public $filters = [];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -19,13 +24,17 @@ class Portfolio extends Component
|
|||
'alle' => 'Alle',
|
||||
'villen' => 'Villen',
|
||||
'penthouse' => 'Penthouse',
|
||||
'loft' => 'Loft'
|
||||
'loft' => 'Loft',
|
||||
];
|
||||
$this->filters = $filters;
|
||||
$this->activeFilter = 'alle';
|
||||
$this->theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$this->theme}.portfolio", []);
|
||||
$this->filters = config("content.themes.{$this->theme}.portfolio.filters", $filters);
|
||||
$portfolio = cms_theme_section('portfolio', $this->theme);
|
||||
if (! is_array($portfolio)) {
|
||||
$portfolio = [];
|
||||
}
|
||||
$this->content = $portfolio;
|
||||
$this->filters = $portfolio['filters'] ?? $filters;
|
||||
}
|
||||
|
||||
public function filterBy(string $category): void
|
||||
|
|
@ -47,8 +56,7 @@ class Portfolio extends Component
|
|||
|
||||
public function getFilteredProjects(): array
|
||||
{
|
||||
$projects = config("content.themes.{$this->theme}.portfolio.projects", []);
|
||||
|
||||
$projects = $this->content['projects'] ?? [];
|
||||
|
||||
if ($this->activeFilter === 'alle') {
|
||||
return $projects;
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class SpotlightsSection extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.spotlights_section", []);
|
||||
$this->content = cms_theme_section('spotlights_section');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ class SupplierSection extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.supplier_section", []);
|
||||
$this->content = cms_theme_section('supplier_section');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ use Livewire\Component;
|
|||
class VisionSection extends Component
|
||||
{
|
||||
public $content = [];
|
||||
|
||||
public $bg = 'bg-background';
|
||||
|
||||
public function mount($bg = 'bg-background')
|
||||
{
|
||||
$this->bg = $bg;
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.vision_section", []);
|
||||
$this->content = cms_theme_section('vision_section');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,63 +2,113 @@
|
|||
|
||||
namespace App\Livewire\Web\Components\Ui;
|
||||
|
||||
use Acme\ContactForm\ContactFormService;
|
||||
use Acme\ContactForm\SpamDetector;
|
||||
use Livewire\Component;
|
||||
|
||||
class ContactForm extends Component
|
||||
{
|
||||
public $firstName = '';
|
||||
public $lastName = '';
|
||||
public $company = '';
|
||||
public $email = '';
|
||||
public $phone = '';
|
||||
public $subject = '';
|
||||
public $message = '';
|
||||
public string $firstName = '';
|
||||
|
||||
public $content = [];
|
||||
public string $lastName = '';
|
||||
|
||||
public function mount()
|
||||
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
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.contact_form", []);
|
||||
$this->content = cms_theme_section('contact_form');
|
||||
$this->formLoadedAt = time();
|
||||
}
|
||||
|
||||
public function getSubjectsProperty()
|
||||
/** @return array<string, string> */
|
||||
public function getSubjectsProperty(): array
|
||||
{
|
||||
return $this->content['form']['subjects'] ?? [];
|
||||
}
|
||||
|
||||
public function getContactInfoProperty()
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function getContactInfoProperty(): array
|
||||
{
|
||||
return $this->content['contact_info'] ?? [];
|
||||
}
|
||||
|
||||
public function getSocialMediaProperty()
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function getSocialMediaProperty(): array
|
||||
{
|
||||
return $this->content['social_media']['platforms'] ?? [];
|
||||
}
|
||||
|
||||
public function submit()
|
||||
public function submit(ContactFormService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'firstName' => 'required|string|max:255',
|
||||
'lastName' => 'required|string|max:255',
|
||||
'email' => 'required|email|max:255',
|
||||
'subject' => 'required|string',
|
||||
'message' => 'required|string|max:2000',
|
||||
'company' => 'nullable|string|max:255',
|
||||
'phone' => 'nullable|string|max:255',
|
||||
'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'],
|
||||
]);
|
||||
|
||||
// Here you would typically save to database or send email
|
||||
// For now, we'll just show a success message
|
||||
$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,
|
||||
];
|
||||
|
||||
$successMessage = $this->content['form']['success_message'] ?? 'Vielen Dank für Ihre Nachricht! Wir werden uns schnellstmöglich bei Ihnen melden.';
|
||||
session()->flash('message', $successMessage);
|
||||
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
|
||||
|
||||
$this->reset(['firstName', 'lastName', 'company', 'email', 'phone', 'subject', 'message']);
|
||||
$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()
|
||||
public function render(): \Illuminate\View\View
|
||||
{
|
||||
return view('livewire.web.components.ui.contact-form');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,19 @@ class Header extends Component
|
|||
public $domainName;
|
||||
public $domainUrl;
|
||||
public $content = [];
|
||||
public $currentLocale;
|
||||
public $availableLocales = [
|
||||
'de' => 'DE',
|
||||
'en' => 'EN',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
|
||||
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.header", [
|
||||
'portal_login' => 'Portal Login',
|
||||
'navigation' => []
|
||||
]);
|
||||
$this->currentLocale = app()->getLocale();
|
||||
|
||||
$this->content = cms_theme_section('header');
|
||||
|
||||
// Ensure required keys exist
|
||||
if (!isset($this->content['portal_login'])) {
|
||||
|
|
@ -30,12 +33,27 @@ class Header extends Component
|
|||
}
|
||||
}
|
||||
|
||||
public function toggleMobileMenu()
|
||||
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()
|
||||
public function closeMobileMenu(): void
|
||||
{
|
||||
$this->isMobileMenuOpen = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class PartnerInvitationMail extends Mailable
|
|||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Einladung: ' . $this->invitation->company_name . ' - B2In Platform',
|
||||
subject: 'Einladung: '.$this->invitation->company_name.' - B2in Platform',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
155
app/Models/CabinetTabletSetting.php
Normal file
155
app/Models/CabinetTabletSetting.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CabinetTabletSetting extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\CabinetTabletSettingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'store_status',
|
||||
'notice_headline',
|
||||
'notice_subtext',
|
||||
'override_open_today',
|
||||
'override_close_today',
|
||||
'next_appointment_date',
|
||||
'next_appointment_time',
|
||||
'hours_monday_open', 'hours_monday_close',
|
||||
'hours_tuesday_open', 'hours_tuesday_close',
|
||||
'hours_wednesday_open', 'hours_wednesday_close',
|
||||
'hours_thursday_open', 'hours_thursday_close',
|
||||
'hours_friday_open', 'hours_friday_close',
|
||||
'hours_saturday_open', 'hours_saturday_close',
|
||||
'hours_sunday_open', 'hours_sunday_close',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'next_appointment_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
private const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
|
||||
private const GERMAN_DAY_LABELS = [
|
||||
'monday' => 'Montag',
|
||||
'tuesday' => 'Dienstag',
|
||||
'wednesday' => 'Mittwoch',
|
||||
'thursday' => 'Donnerstag',
|
||||
'friday' => 'Freitag',
|
||||
'saturday' => 'Samstag',
|
||||
'sunday' => 'Sonntag',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get or create the singleton settings row.
|
||||
*/
|
||||
public static function current(): self
|
||||
{
|
||||
return static::firstOrCreate(['id' => 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get opening hours as display strings for the frontend (e.g. "10:00 – 18:00" or "Geschlossen").
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getHoursArray(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (self::DAYS as $day) {
|
||||
$open = $this->{"hours_{$day}_open"};
|
||||
$close = $this->{"hours_{$day}_close"};
|
||||
$result[$day] = ($open && $close) ? "{$open} – {$close}" : 'Geschlossen';
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the effective store status based on opening hours and current Berlin time.
|
||||
*
|
||||
* Returns the display status ('open', 'closed', 'notice'), the next opening time when
|
||||
* closed, and today's closing time when open.
|
||||
*
|
||||
* @return array{status: string, today_close: string|null, next_open: array{label: string, time: string}|null}
|
||||
*/
|
||||
public function computeStatus(): array
|
||||
{
|
||||
if ($this->store_status === 'notice' || $this->store_status === 'warning') {
|
||||
return ['status' => $this->store_status, 'today_close' => null, 'next_open' => null];
|
||||
}
|
||||
|
||||
if ($this->store_status === 'closed') {
|
||||
$now = Carbon::now('Europe/Berlin');
|
||||
|
||||
return ['status' => 'closed', 'today_close' => null, 'next_open' => $this->findNextOpenTime($now, true)];
|
||||
}
|
||||
|
||||
// Auto mode: compute from opening hours
|
||||
$now = Carbon::now('Europe/Berlin');
|
||||
$dayKey = strtolower($now->englishDayOfWeek);
|
||||
|
||||
$openTime = $this->override_open_today ?: $this->{"hours_{$dayKey}_open"};
|
||||
$closeTime = $this->override_close_today ?: $this->{"hours_{$dayKey}_close"};
|
||||
$currentHHMM = $now->format('H:i');
|
||||
|
||||
if ($openTime && $closeTime && $currentHHMM >= $openTime && $currentHHMM < $closeTime) {
|
||||
return ['status' => 'open', 'today_close' => $closeTime, 'next_open' => null];
|
||||
}
|
||||
|
||||
return ['status' => 'closed', 'today_close' => null, 'next_open' => $this->findNextOpenTime($now, false)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next upcoming opening time from the given Berlin datetime.
|
||||
*
|
||||
* @return array{label: string, time: string}|null
|
||||
*/
|
||||
private function findNextOpenTime(Carbon $now, bool $skipToday): ?array
|
||||
{
|
||||
// Check if today still has an upcoming opening (we might be before opening time)
|
||||
if (! $skipToday) {
|
||||
$dayKey = strtolower($now->englishDayOfWeek);
|
||||
$todayOpen = $this->override_open_today ?: $this->{"hours_{$dayKey}_open"};
|
||||
|
||||
if ($todayOpen && $now->format('H:i') < $todayOpen) {
|
||||
return ['label' => 'Heute', 'time' => $todayOpen];
|
||||
}
|
||||
}
|
||||
|
||||
// Look ahead up to 7 days
|
||||
for ($i = 1; $i <= 7; $i++) {
|
||||
$checkDate = $now->copy()->addDays($i);
|
||||
$dayKey = strtolower($checkDate->englishDayOfWeek);
|
||||
$openTime = $this->{"hours_{$dayKey}_open"};
|
||||
|
||||
if ($openTime) {
|
||||
$label = $i === 1 ? 'Morgen' : self::GERMAN_DAY_LABELS[$dayKey];
|
||||
|
||||
return ['label' => $label, 'time' => $openTime];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear override times (called by midnight scheduler).
|
||||
*/
|
||||
public function clearOverrides(): void
|
||||
{
|
||||
$this->update([
|
||||
'override_open_today' => null,
|
||||
'override_close_today' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
74
app/Models/CmsArticle.php
Normal file
74
app/Models/CmsArticle.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
class CmsArticle extends Model
|
||||
{
|
||||
use HasFactory, HasTranslations;
|
||||
|
||||
protected $table = 'cms_articles';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'title',
|
||||
'subtitle',
|
||||
'image',
|
||||
'category',
|
||||
'date_label',
|
||||
'read_time',
|
||||
'author',
|
||||
'content',
|
||||
'is_published',
|
||||
'order',
|
||||
];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $translatable = [
|
||||
'title',
|
||||
'subtitle',
|
||||
'content',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'author' => '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')->orderByDesc('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toFrontendArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'slug' => $this->slug,
|
||||
'title' => $this->title,
|
||||
'subtitle' => $this->subtitle,
|
||||
'image' => $this->image,
|
||||
'category' => $this->category,
|
||||
'date' => $this->date_label,
|
||||
'readTime' => $this->read_time,
|
||||
'author' => $this->author ?? [],
|
||||
'content' => $this->content ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
111
app/Models/CmsProject.php
Normal file
111
app/Models/CmsProject.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Helpers\PriceHelper;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Translatable\HasTranslations;
|
||||
|
||||
class CmsProject extends Model
|
||||
{
|
||||
use HasFactory, HasTranslations;
|
||||
|
||||
protected $table = 'cms_projects';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'title',
|
||||
'location',
|
||||
'status',
|
||||
'launch_date',
|
||||
'price_from_aed',
|
||||
'currency',
|
||||
'image',
|
||||
'highlights',
|
||||
'quick_facts',
|
||||
'investment_case',
|
||||
'gallery',
|
||||
'location_info',
|
||||
'contact',
|
||||
'investor_trust',
|
||||
'furniture_benefit',
|
||||
'is_published',
|
||||
'order',
|
||||
];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $translatable = [
|
||||
'title',
|
||||
'location',
|
||||
'highlights',
|
||||
'investment_case',
|
||||
'location_info',
|
||||
'contact',
|
||||
'investor_trust',
|
||||
'furniture_benefit',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'launch_date' => 'date',
|
||||
'price_from_aed' => 'integer',
|
||||
'highlights' => 'array',
|
||||
'quick_facts' => 'array',
|
||||
'investment_case' => 'array',
|
||||
'gallery' => 'array',
|
||||
'location_info' => 'array',
|
||||
'contact' => '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')->orderByDesc('launch_date');
|
||||
}
|
||||
|
||||
public function getFormattedPrice(string $prefix = 'ab'): string
|
||||
{
|
||||
if (! $this->price_from_aed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return PriceHelper::formatAed($this->price_from_aed, $prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array compatible with the existing Blade views
|
||||
* (immobilien.blade.php and immobilien-show.blade.php).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toFrontendArray(): array
|
||||
{
|
||||
return [
|
||||
'slug' => $this->slug,
|
||||
'title' => $this->title,
|
||||
'location' => $this->location,
|
||||
'status' => $this->status,
|
||||
'launch_date' => $this->launch_date?->format('d.m.Y'),
|
||||
'price_from' => $this->getFormattedPrice(),
|
||||
'image' => $this->image,
|
||||
'highlights' => $this->highlights ?? [],
|
||||
'quick_facts' => $this->quick_facts ?? [],
|
||||
'investment_case' => $this->investment_case ?? [],
|
||||
'gallery' => $this->gallery ?? [],
|
||||
'location_info' => $this->location_info ?? [],
|
||||
'contact' => $this->contact ?? [],
|
||||
'investor_trust' => $this->investor_trust ?? [],
|
||||
'furniture_benefit' => $this->furniture_benefit ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Models/Display.php
Normal file
33
app/Models/Display.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Display extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DisplayFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'location',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function versions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(DisplayVersion::class, 'display_display_version')
|
||||
->withPivot('sort_order')
|
||||
->orderByPivot('sort_order');
|
||||
}
|
||||
}
|
||||
153
app/Models/DisplayMedia.php
Normal file
153
app/Models/DisplayMedia.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DisplayMedia extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DisplayMediaFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'display_media';
|
||||
|
||||
protected $fillable = [
|
||||
'filename',
|
||||
'disk',
|
||||
'path',
|
||||
'external_url',
|
||||
'source_type',
|
||||
'type',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'thumbnail_path',
|
||||
'alt_text',
|
||||
'title',
|
||||
'collection',
|
||||
'metadata',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
'file_size' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ACCESSORS
|
||||
// ========================================
|
||||
|
||||
public function getUrl(): string
|
||||
{
|
||||
if ($this->isExternal()) {
|
||||
return $this->external_url;
|
||||
}
|
||||
|
||||
return Storage::disk($this->disk)->url($this->path);
|
||||
}
|
||||
|
||||
public function getThumbnailUrl(): ?string
|
||||
{
|
||||
if ($this->thumbnail_path) {
|
||||
return Storage::disk($this->disk)->url($this->thumbnail_path);
|
||||
}
|
||||
|
||||
if ($this->isUpload() && $this->isImage()) {
|
||||
return $this->getUrl();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// TYPE CHECKS
|
||||
// ========================================
|
||||
|
||||
public function isUpload(): bool
|
||||
{
|
||||
return $this->source_type === 'upload';
|
||||
}
|
||||
|
||||
public function isExternal(): bool
|
||||
{
|
||||
return $this->source_type === 'external';
|
||||
}
|
||||
|
||||
public function isImage(): bool
|
||||
{
|
||||
return $this->type === 'image';
|
||||
}
|
||||
|
||||
public function isVideo(): bool
|
||||
{
|
||||
return $this->type === 'video';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// DISPLAY HELPERS
|
||||
// ========================================
|
||||
|
||||
public function getHumanFileSize(): string
|
||||
{
|
||||
if ($this->file_size === 0) {
|
||||
return $this->isExternal() ? 'Extern' : '0 B';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$size = $this->file_size;
|
||||
$unitIndex = 0;
|
||||
|
||||
while ($size >= 1024 && $unitIndex < count($units) - 1) {
|
||||
$size /= 1024;
|
||||
$unitIndex++;
|
||||
}
|
||||
|
||||
return round($size, 1).' '.$units[$unitIndex];
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return $this->title ?: $this->filename;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// SCOPES
|
||||
// ========================================
|
||||
|
||||
public function scopeImages(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', 'image');
|
||||
}
|
||||
|
||||
public function scopeVideos(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', 'video');
|
||||
}
|
||||
|
||||
public function scopeUploads(Builder $query): Builder
|
||||
{
|
||||
return $query->where('source_type', 'upload');
|
||||
}
|
||||
|
||||
public function scopeExternals(Builder $query): Builder
|
||||
{
|
||||
return $query->where('source_type', 'external');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeInCollection(Builder $query, string $collection): Builder
|
||||
{
|
||||
return $query->where('collection', $collection);
|
||||
}
|
||||
}
|
||||
67
app/Models/DisplayVersion.php
Normal file
67
app/Models/DisplayVersion.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\DisplayVersionType;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class DisplayVersion extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DisplayVersionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'settings',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => DisplayVersionType::class,
|
||||
'settings' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function displays(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Display::class, 'display_display_version')
|
||||
->withPivot('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<DisplayVersionItem, $this>
|
||||
*/
|
||||
public function activeItems(?string $itemType = null): HasMany
|
||||
{
|
||||
$query = $this->items()->where('is_active', true);
|
||||
|
||||
if ($itemType) {
|
||||
$query->where('item_type', $itemType);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function scopeOfType(Builder $query, DisplayVersionType $type): Builder
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
}
|
||||
41
app/Models/DisplayVersionItem.php
Normal file
41
app/Models/DisplayVersionItem.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DisplayVersionItem extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\DisplayVersionItemFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'display_version_id',
|
||||
'item_type',
|
||||
'content',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'content' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function version(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DisplayVersion::class, 'display_version_id');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true)->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
197
app/Services/CmsFluxEditorHtmlTransformer.php
Normal file
197
app/Services/CmsFluxEditorHtmlTransformer.php
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use DOMXPath;
|
||||
|
||||
/**
|
||||
* Der Flux-Editor (TipTap) nutzt für Hervorhebungen das Element `mark` (Highlight-Extension).
|
||||
* Im Frontend wird derselbe optische Zweck oft mit `span.text-secondary` umgesetzt.
|
||||
* Diese Klasse wandelt beim Laden in den Editor und beim Speichern zwischen beiden Formen um.
|
||||
*/
|
||||
final class CmsFluxEditorHtmlTransformer
|
||||
{
|
||||
/**
|
||||
* JSON-Felder, die im CMS-Modal mit flux:editor (Rich-Text) bearbeitet werden.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const RICH_TEXT_JSON_FIELDS = [
|
||||
'description',
|
||||
'text',
|
||||
'content',
|
||||
'help',
|
||||
'answer',
|
||||
'quote',
|
||||
];
|
||||
|
||||
public static function toEditor(string $html): string
|
||||
{
|
||||
if ($html === '' || ! str_contains($html, 'text-secondary')) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return self::spansWithClassToMarks($html);
|
||||
}
|
||||
|
||||
public static function fromEditor(string $html): string
|
||||
{
|
||||
if ($html === '' || ! str_contains($html, '<mark')) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
return self::marksToSecondarySpans($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>|array{_value: string}> $items
|
||||
* @return array<int, array<string, mixed>|array{_value: string}>
|
||||
*/
|
||||
public static function toEditorJsonItems(array $items, bool $isStringArray): array
|
||||
{
|
||||
if ($isStringArray) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return array_map(function ($item) {
|
||||
if (! is_array($item)) {
|
||||
return $item;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($item as $key => $value) {
|
||||
if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) {
|
||||
$out[$key] = self::toEditor($value);
|
||||
} else {
|
||||
$out[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}, $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>|array{_value: string}> $items
|
||||
* @return array<int, array<string, mixed>|array{_value: string}>
|
||||
*/
|
||||
public static function fromEditorJsonItems(array $items, bool $isStringArray): array
|
||||
{
|
||||
if ($isStringArray) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return array_map(function ($item) {
|
||||
if (! is_array($item)) {
|
||||
return $item;
|
||||
}
|
||||
$out = [];
|
||||
foreach ($item as $key => $value) {
|
||||
if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) {
|
||||
$out[$key] = self::fromEditor($value);
|
||||
} else {
|
||||
$out[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}, $items);
|
||||
}
|
||||
|
||||
private static function spansWithClassToMarks(string $html): string
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument;
|
||||
$wrapped = '<?xml encoding="utf-8"?><div id="cms-flux-root">'.$html.'</div>';
|
||||
$dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$root = $dom->getElementById('cms-flux-root');
|
||||
if (! $root instanceof DOMElement) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
$expression = '//span[contains(concat(" ", normalize-space(@class), " "), " text-secondary ")]';
|
||||
$nodes = [];
|
||||
foreach ($xpath->query($expression) ?? [] as $node) {
|
||||
$nodes[] = $node;
|
||||
}
|
||||
|
||||
usort($nodes, fn ($a, $b) => self::nodeDepth($b) <=> self::nodeDepth($a));
|
||||
|
||||
foreach ($nodes as $span) {
|
||||
if (! $span instanceof DOMElement || $span->tagName !== 'span') {
|
||||
continue;
|
||||
}
|
||||
$mark = $dom->createElement('mark');
|
||||
while ($span->firstChild) {
|
||||
$mark->appendChild($span->firstChild);
|
||||
}
|
||||
$span->parentNode?->replaceChild($mark, $span);
|
||||
}
|
||||
|
||||
return self::extractInnerHtml($dom, $root);
|
||||
}
|
||||
|
||||
private static function marksToSecondarySpans(string $html): string
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument;
|
||||
$wrapped = '<?xml encoding="utf-8"?><div id="cms-flux-root">'.$html.'</div>';
|
||||
$dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$root = $dom->getElementById('cms-flux-root');
|
||||
if (! $root instanceof DOMElement) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$xpath = new DOMXPath($dom);
|
||||
$nodes = [];
|
||||
foreach ($xpath->query('//mark') ?? [] as $node) {
|
||||
$nodes[] = $node;
|
||||
}
|
||||
|
||||
usort($nodes, fn ($a, $b) => self::nodeDepth($b) <=> self::nodeDepth($a));
|
||||
|
||||
foreach ($nodes as $mark) {
|
||||
if (! $mark instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
$span = $dom->createElement('span');
|
||||
$span->setAttribute('class', 'text-secondary');
|
||||
while ($mark->firstChild) {
|
||||
$span->appendChild($mark->firstChild);
|
||||
}
|
||||
$mark->parentNode?->replaceChild($span, $mark);
|
||||
}
|
||||
|
||||
return self::extractInnerHtml($dom, $root);
|
||||
}
|
||||
|
||||
private static function nodeDepth(DOMNode $node): int
|
||||
{
|
||||
$d = 0;
|
||||
while ($node->parentNode) {
|
||||
$d++;
|
||||
$node = $node->parentNode;
|
||||
}
|
||||
|
||||
return $d;
|
||||
}
|
||||
|
||||
private static function extractInnerHtml(DOMDocument $dom, DOMElement $root): string
|
||||
{
|
||||
$html = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$html .= $dom->saveHTML($child);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
119
app/Services/DisplayMediaService.php
Normal file
119
app/Services/DisplayMediaService.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DisplayMedia;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DisplayMediaService
|
||||
{
|
||||
/**
|
||||
* Store an uploaded file and create a DisplayMedia record.
|
||||
*/
|
||||
public function storeUpload(UploadedFile $file, ?string $collection = null): DisplayMedia
|
||||
{
|
||||
$filename = $file->getClientOriginalName();
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
$storageName = Str::uuid().'.'.$extension;
|
||||
$datePath = now()->format('Y/m');
|
||||
$relativePath = "display-media/{$datePath}/{$storageName}";
|
||||
|
||||
Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName);
|
||||
|
||||
$type = in_array($extension, ['mp4', 'webm', 'mov']) ? 'video' : 'image';
|
||||
|
||||
$metadata = [];
|
||||
if ($type === 'image') {
|
||||
$dimensions = @getimagesize($file->getRealPath());
|
||||
if ($dimensions) {
|
||||
$metadata['width'] = $dimensions[0];
|
||||
$metadata['height'] = $dimensions[1];
|
||||
}
|
||||
}
|
||||
|
||||
return DisplayMedia::create([
|
||||
'filename' => $filename,
|
||||
'disk' => 'public',
|
||||
'path' => $relativePath,
|
||||
'source_type' => 'upload',
|
||||
'type' => $type,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'collection' => $collection,
|
||||
'metadata' => ! empty($metadata) ? $metadata : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DisplayMedia record from an external URL.
|
||||
*/
|
||||
public function createFromUrl(string $url, string $type = 'video', ?string $title = null, ?string $collection = null): DisplayMedia
|
||||
{
|
||||
$filename = $title ?: $this->extractFilenameFromUrl($url);
|
||||
|
||||
return DisplayMedia::create([
|
||||
'filename' => $filename,
|
||||
'disk' => 'public',
|
||||
'path' => null,
|
||||
'external_url' => $url,
|
||||
'source_type' => 'external',
|
||||
'type' => $type,
|
||||
'mime_type' => null,
|
||||
'file_size' => 0,
|
||||
'title' => $title,
|
||||
'collection' => $collection,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an external URL is accessible.
|
||||
*/
|
||||
public function validateExternalUrl(string $url): bool
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->withOptions(['allow_redirects' => true])
|
||||
->head($url);
|
||||
|
||||
return $response->successful() || $response->status() === 302 || $response->status() === 301;
|
||||
} catch (\Throwable) {
|
||||
// Some services block HEAD requests, try GET with stream
|
||||
try {
|
||||
$response = Http::timeout(10)
|
||||
->withOptions(['allow_redirects' => true, 'stream' => true])
|
||||
->get($url);
|
||||
|
||||
return $response->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a DisplayMedia record and its associated files.
|
||||
*/
|
||||
public function delete(DisplayMedia $media): void
|
||||
{
|
||||
if ($media->isUpload() && $media->path) {
|
||||
Storage::disk($media->disk)->delete($media->path);
|
||||
}
|
||||
|
||||
if ($media->thumbnail_path) {
|
||||
Storage::disk($media->disk)->delete($media->thumbnail_path);
|
||||
}
|
||||
|
||||
$media->delete();
|
||||
}
|
||||
|
||||
private function extractFilenameFromUrl(string $url): string
|
||||
{
|
||||
$parsed = parse_url($url, PHP_URL_PATH);
|
||||
$basename = $parsed ? basename($parsed) : 'external-media';
|
||||
|
||||
return Str::limit($basename, 100);
|
||||
}
|
||||
}
|
||||
90
app/Services/ProjectDocumentationContent.php
Normal file
90
app/Services/ProjectDocumentationContent.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
|
||||
class ProjectDocumentationContent
|
||||
{
|
||||
public static function markdownPath(): string
|
||||
{
|
||||
return base_path('dev/entwicklung.md');
|
||||
}
|
||||
|
||||
public static function html(): string
|
||||
{
|
||||
$mdPath = self::markdownPath();
|
||||
|
||||
if (! file_exists($mdPath)) {
|
||||
return '<p class="text-red-600">Dokumentation nicht gefunden.</p>';
|
||||
}
|
||||
|
||||
$markdown = file_get_contents($mdPath);
|
||||
|
||||
$environment = new Environment([
|
||||
'html_input' => 'allow',
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||
|
||||
$converter = new MarkdownConverter($environment);
|
||||
|
||||
return $converter->convert($markdown)->getContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{level: int, title: string, slug: string}>
|
||||
*/
|
||||
public static function tableOfContents(): array
|
||||
{
|
||||
$mdPath = self::markdownPath();
|
||||
|
||||
if (! file_exists($mdPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$markdown = file_get_contents($mdPath);
|
||||
$toc = [];
|
||||
|
||||
preg_match_all('/^(#{2,3})\s+(.+)$/m', $markdown, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$level = strlen($match[1]);
|
||||
$title = trim($match[2]);
|
||||
$slug = Str::slug($title);
|
||||
|
||||
$toc[] = [
|
||||
'level' => $level,
|
||||
'title' => $title,
|
||||
'slug' => $slug,
|
||||
];
|
||||
}
|
||||
|
||||
return $toc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{size: string, modified: string, lines: int}|null
|
||||
*/
|
||||
public static function fileInfo(): ?array
|
||||
{
|
||||
$mdPath = self::markdownPath();
|
||||
|
||||
if (! file_exists($mdPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'size' => round(filesize($mdPath) / 1024, 1).' KB',
|
||||
'modified' => Carbon::parse(filemtime($mdPath))->format('d.m.Y H:i'),
|
||||
'lines' => count(file($mdPath)),
|
||||
];
|
||||
}
|
||||
}
|
||||
34
app/View/Components/WebPicture.php
Normal file
34
app/View/Components/WebPicture.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\View\Component;
|
||||
|
||||
class WebPicture extends Component
|
||||
{
|
||||
public string $webpSrc;
|
||||
|
||||
public bool $hasWebp;
|
||||
|
||||
public function __construct(
|
||||
public string $src,
|
||||
public string $alt = '',
|
||||
public string $class = '',
|
||||
public string $loading = 'lazy',
|
||||
public string $width = '',
|
||||
public string $height = '',
|
||||
) {
|
||||
$this->webpSrc = preg_replace('/\.(jpe?g|png)$/i', '.webp', $this->src);
|
||||
$this->hasWebp = file_exists(public_path(
|
||||
str_replace(asset(''), '', $this->webpSrc)
|
||||
));
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('components.web-picture');
|
||||
}
|
||||
}
|
||||
171
app/helpers.php
Normal file
171
app/helpers.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
|
||||
if (! function_exists('legal_page')) {
|
||||
/**
|
||||
* Rechtstexte: zuerst CMS (Gruppe „legal“), sonst Lang-Datei b2in_legal.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function legal_page(string $key): array
|
||||
{
|
||||
$fromCms = app(CmsContentService::class)->get('legal.'.$key);
|
||||
|
||||
if (is_array($fromCms) && isset($fromCms['content'])) {
|
||||
return $fromCms;
|
||||
}
|
||||
|
||||
return trans('b2in_legal.'.$key);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('cms')) {
|
||||
/**
|
||||
* Resolve a CMS content value by dot-notation key.
|
||||
*
|
||||
* First segment is the group, rest is the key: "home.hero.title"
|
||||
* Falls back to __() if no DB entry exists.
|
||||
*
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
function cms(string $key, array $replace = [], ?string $locale = null): mixed
|
||||
{
|
||||
return app(CmsContentService::class)->get($key, $replace, $locale);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('cms_theme_section')) {
|
||||
/**
|
||||
* Theme-Sektion: zuerst CMS (gemäß cms_section_map), sonst Lang b2in.themes.{theme}.{section}.
|
||||
*
|
||||
* @return array<string, mixed>|string|mixed
|
||||
*/
|
||||
function cms_theme_section(string $sectionKey, ?string $theme = null): mixed
|
||||
{
|
||||
$theme = $theme ?? config('app.theme', 'b2in');
|
||||
|
||||
/** @var array<string, array{0: string, 1: string}> $sections */
|
||||
$sections = config('cms_section_map.sections', []);
|
||||
/** @var array<string, string> $langKeys */
|
||||
$langKeys = config('cms_section_map.lang_keys', []);
|
||||
|
||||
if (isset($sections[$sectionKey])) {
|
||||
[$group, $key] = $sections[$sectionKey];
|
||||
$fromCms = app(CmsContentService::class)->getIfExists("{$group}.{$key}");
|
||||
|
||||
if ($fromCms !== null) {
|
||||
return $fromCms;
|
||||
}
|
||||
}
|
||||
|
||||
$langKey = $langKeys[$sectionKey] ?? $sectionKey;
|
||||
|
||||
return trans("b2in.themes.{$theme}.{$langKey}");
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('theme_image_url')) {
|
||||
/**
|
||||
* Bild-URL für Theme-/CMS-Inhalte: zuerst Medienbibliothek (CmsMedia, oft nur Dateiname z. B. *.webp),
|
||||
* sonst statische Assets unter public/img/assets/ (Legacy-Pfade wie b2in/…).
|
||||
*/
|
||||
function theme_image_url(?string $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$trimmed = ltrim(trim($value), '/');
|
||||
if ($trimmed === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
$candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)])));
|
||||
foreach ($candidates as $candidate) {
|
||||
if (CmsMedia::query()->where('filename', $candidate)->exists()) {
|
||||
return media_url($candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (str_contains($trimmed, '/')) {
|
||||
return asset('img/assets/'.$trimmed);
|
||||
}
|
||||
|
||||
return media_url($trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('tcms')) {
|
||||
/**
|
||||
* Typed CMS – always returns a string.
|
||||
*
|
||||
* @param array<string, string> $replace
|
||||
*/
|
||||
function tcms(string $key, array $replace = [], ?string $locale = null): string
|
||||
{
|
||||
$text = cms($key, $replace, $locale);
|
||||
|
||||
return is_string($text) ? $text : (string) $text;
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('cms_media_url')) {
|
||||
/**
|
||||
* Resolve a CMS content key (type=image) to a full media URL.
|
||||
* Falls back to asset('assets/images/...') if not found in media library.
|
||||
*/
|
||||
function cms_media_url(string $key, string $profile = ''): string
|
||||
{
|
||||
$filename = cms($key);
|
||||
|
||||
if (! $filename || ! is_string($filename) || $filename === $key) {
|
||||
$fallback = str_replace('.', '/', $key);
|
||||
|
||||
return asset('assets/images/'.basename($fallback));
|
||||
}
|
||||
|
||||
return media_url($filename, $profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('media_url')) {
|
||||
/**
|
||||
* Resolve a CmsMedia filename to its full storage URL.
|
||||
* Uses in-memory cache to avoid repeated DB queries.
|
||||
*/
|
||||
function media_url(?string $filename, string $profile = ''): string
|
||||
{
|
||||
if (! $filename || $filename === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
static $resolved = [];
|
||||
$cacheKey = $filename.'|'.$profile;
|
||||
|
||||
if (isset($resolved[$cacheKey])) {
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
|
||||
$media = CmsMedia::where('filename', $filename)->first();
|
||||
|
||||
if (! $media) {
|
||||
$resolved[$cacheKey] = asset('assets/images/'.$filename);
|
||||
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
|
||||
if ($profile && $media->hasConversion($profile)) {
|
||||
$resolved[$cacheKey] = $media->getConversionUrl($profile);
|
||||
} else {
|
||||
$resolved[$cacheKey] = $media->getUrl();
|
||||
}
|
||||
|
||||
return $resolved[$cacheKey];
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
// um sicherzustellen, dass url() und asset() die richtige Domain verwenden
|
||||
$middleware->prepend(\App\Http\Middleware\SetDomainUrl::class);
|
||||
|
||||
// Sprachwechsel aus Session anwenden
|
||||
$middleware->appendToGroup('web', \App\Http\Middleware\SetLocale::class);
|
||||
|
||||
// Partner Setup-Zwang für eingeloggte User
|
||||
$middleware->alias([
|
||||
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"acme/contact-form": "*",
|
||||
"acme/cookie-consent": "@dev",
|
||||
"blade-ui-kit/blade-heroicons": "^2.6",
|
||||
"flux-cms/core": "@dev",
|
||||
"intervention/image": "*",
|
||||
"laravel/fortify": "^1.27",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.1",
|
||||
|
|
@ -37,6 +41,9 @@
|
|||
"reliese/laravel": "^1.4"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"app/helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
|
|
@ -55,7 +62,8 @@
|
|||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
|
||||
"@php artisan boost:update --ansi"
|
||||
"@php artisan livewire:publish --assets --ansi",
|
||||
"@php artisan boost:update --ansi || true"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
|
|
|
|||
959
composer.lock
generated
959
composer.lock
generated
File diff suppressed because it is too large
Load diff
93
config/cms_section_map.php
Normal file
93
config/cms_section_map.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Theme-Section-Schlüssel (wie in b2in.themes.{theme}.*) → [CMS-Gruppe, CMS-Key].
|
||||
*
|
||||
* Wird von cms_theme_section() und CmsContentSeeder verwendet.
|
||||
*/
|
||||
return [
|
||||
'lang_keys' => [
|
||||
'card_section' => 'partner_card_section',
|
||||
],
|
||||
|
||||
'sections' => [
|
||||
'announcement_bar' => ['global', 'announcement_bar'],
|
||||
'header' => ['global', 'header'],
|
||||
'cta_section' => ['global', 'cta_section'],
|
||||
'cta_section_portfolio' => ['global', 'cta_section_portfolio'],
|
||||
|
||||
'hero' => ['home', 'hero'],
|
||||
'founder_bar' => ['home', 'founder_bar'],
|
||||
'synergie_section' => ['home', 'synergie_section'],
|
||||
'ecosystem_core' => ['home', 'ecosystem_core'],
|
||||
'vision_section' => ['home', 'vision_section'],
|
||||
'brand_worlds' => ['home', 'brand_worlds'],
|
||||
'integriertes_modell_b2in' => ['home', 'integriertes_modell_b2in'],
|
||||
|
||||
'immobilien_hero' => ['immobilien', 'hero'],
|
||||
'immobilien_hero_v2' => ['immobilien', 'hero_v2'],
|
||||
'immobilien_projects' => ['immobilien', 'projects_meta'],
|
||||
'immobilien_moebel_vorteil' => ['immobilien', 'moebel_vorteil'],
|
||||
'immobilien_trust' => ['immobilien', 'trust'],
|
||||
'immobilien_warum_dubai' => ['immobilien', 'warum_dubai'],
|
||||
'immobilien_image_break' => ['immobilien', 'image_break'],
|
||||
'immobilien_kaufprozess' => ['immobilien', 'kaufprozess'],
|
||||
'immobilien_bruecke' => ['immobilien', 'bruecke'],
|
||||
'immobilien_mindset' => ['immobilien', 'mindset'],
|
||||
'portfolio' => ['immobilien', 'portfolio'],
|
||||
|
||||
'netzwerk_hero' => ['netzwerk', 'hero'],
|
||||
'netzwerk_image_break' => ['netzwerk', 'image_break'],
|
||||
'netzwerk_teasers' => ['netzwerk', 'teasers'],
|
||||
'netzwerk_cta' => ['netzwerk', 'cta'],
|
||||
'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'],
|
||||
'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'],
|
||||
|
||||
'interior_hero' => ['netzwerk', 'interior_hero'],
|
||||
'interior_concept' => ['netzwerk', 'interior_concept'],
|
||||
'interior_brands' => ['netzwerk', 'interior_brands'],
|
||||
'interior_zielgruppen' => ['netzwerk', 'interior_zielgruppen'],
|
||||
'interior_process' => ['netzwerk', 'interior_process'],
|
||||
'interior_trust' => ['netzwerk', 'interior_trust'],
|
||||
|
||||
'faq' => ['faq', 'faq'],
|
||||
|
||||
'contact_form' => ['contact', 'contact_form'],
|
||||
|
||||
'about_hero' => ['about', 'hero'],
|
||||
'our_story' => ['about', 'our_story'],
|
||||
'about_image_break' => ['about', 'image_break'],
|
||||
'our_values' => ['about', 'our_values'],
|
||||
'leadership_team' => ['about', 'leadership_team'],
|
||||
|
||||
'broker_section' => ['netzwerk', 'broker_section'],
|
||||
'commitment_section' => ['netzwerk', 'commitment_section'],
|
||||
'dark_stats_section' => ['netzwerk', 'dark_stats_section'],
|
||||
'ecosystem_hero' => ['netzwerk', 'ecosystem_hero'],
|
||||
'ecosystem_stats' => ['netzwerk', 'ecosystem_stats'],
|
||||
'ecosystem_start' => ['netzwerk', 'ecosystem_start'],
|
||||
'ecosystem_hub' => ['netzwerk', 'ecosystem_hub'],
|
||||
'ecosystem_result' => ['netzwerk', 'ecosystem_result'],
|
||||
'end_customer_section' => ['netzwerk', 'end_customer_section'],
|
||||
'final_commitment' => ['netzwerk', 'final_commitment'],
|
||||
'digital_core' => ['netzwerk', 'digital_core'],
|
||||
'supply_chain_intro' => ['netzwerk', 'supply_chain_intro'],
|
||||
|
||||
'partner_hero' => ['netzwerk', 'partner_hero'],
|
||||
'partner_benefits' => ['netzwerk', 'partner_benefits'],
|
||||
'partner_cta' => ['netzwerk', 'partner_cta'],
|
||||
'partner_card_section' => ['netzwerk', 'partner_card_section'],
|
||||
'card_section' => ['netzwerk', 'partner_card_section'],
|
||||
'partner_benefits_developer' => ['netzwerk', 'partner_benefits_developer'],
|
||||
'partner_benefits_retailer' => ['netzwerk', 'partner_benefits_retailer'],
|
||||
'partner_benefits_supplier' => ['netzwerk', 'partner_benefits_supplier'],
|
||||
'partner_benefits_broker' => ['netzwerk', 'partner_benefits_broker'],
|
||||
'partner_process' => ['netzwerk', 'partner_process'],
|
||||
'supplier_section' => ['netzwerk', 'supplier_section'],
|
||||
|
||||
'magazin_detail' => ['magazin', 'detail'],
|
||||
'magazin_list' => ['magazin', 'list'],
|
||||
],
|
||||
];
|
||||
1526
config/content.php
1526
config/content.php
File diff suppressed because it is too large
Load diff
127
config/cookie-consent.php
Normal file
127
config/cookie-consent.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Consent aktivieren
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Hilfreich, um den Manager in bestimmten Umgebungen komplett auszuschalten.
|
||||
|
|
||||
*/
|
||||
'enabled' => env('COOKIE_CONSENT_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Analytics ID
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Hier trägst du deine Tracking ID ein (z.B. G-XXXXXXXXXX).
|
||||
| Wenn der Wert leer ist, wird der Analytics-Toggle nicht angezeigt.
|
||||
|
|
||||
*/
|
||||
'analytics_id' => env('GOOGLE_ANALYTICS_ID', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Tag Manager ID
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Optional: Wenn du den Google Tag Manager statt direktem Analytics nutzt,
|
||||
| trage hier deine GTM-ID ein (z.B. GTM-XXXXXXXX).
|
||||
|
|
||||
| Wenn sowohl GTM als auch Analytics-ID gesetzt sind, wird GTM bevorzugt.
|
||||
| Der Tag Manager lädt dann alle weiteren Tags (inkl. GA4) selbst.
|
||||
|
|
||||
*/
|
||||
'gtm_id' => env('GOOGLE_TAG_MANAGER_ID', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Lebensdauer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Wie viele Tage soll die Entscheidung des Nutzers gespeichert bleiben?
|
||||
| Standard: 365 Tage (1 Jahr). DSGVO empfiehlt max. 12 Monate.
|
||||
|
|
||||
*/
|
||||
'cookie_lifetime' => env('COOKIE_CONSENT_LIFETIME', 365),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Der Name des Cookies, in dem die Einwilligung gespeichert wird.
|
||||
|
|
||||
*/
|
||||
'cookie_name' => env('COOKIE_CONSENT_NAME', 'cookie_consent'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| URLs für Datenschutz und Impressum.
|
||||
|
|
||||
*/
|
||||
'links' => [
|
||||
'privacy' => '/privacy',
|
||||
'imprint' => '/impressum',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Position des Floating Buttons
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Wo soll der Cookie-Settings-Button angezeigt werden?
|
||||
| Optionen: 'bottom-left', 'bottom-right'
|
||||
|
|
||||
*/
|
||||
'button_position' => 'bottom-left',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| IP-Anonymisierung
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Google Analytics IP-Anonymisierung aktivieren (empfohlen für DSGVO).
|
||||
|
|
||||
*/
|
||||
'anonymize_ip' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Farben (Theme)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Passe die Farben an dein Projekt-Design an.
|
||||
| Du kannst Tailwind-Klassen oder CSS-Farbwerte verwenden.
|
||||
|
|
||||
| Für Tailwind-Projekte: Nutze deine definierten Farben wie 'primary', 'secondary'
|
||||
| Für Standard-CSS: Nutze HEX-Werte wie '#009bdd'
|
||||
|
|
||||
*/
|
||||
'colors' => [
|
||||
// Primärfarbe für Akzente, aktive Toggles, Icons
|
||||
'primary' => env('COOKIE_CONSENT_COLOR_PRIMARY', '#0088cc'),
|
||||
|
||||
// Sekundärfarbe / Hover-Zustand der Primärfarbe
|
||||
'primary_hover' => env('COOKIE_CONSENT_COLOR_PRIMARY_HOVER', '#006699'),
|
||||
|
||||
// Akzeptieren-Button (grün)
|
||||
'accept' => env('COOKIE_CONSENT_COLOR_ACCEPT', '#16a34a'),
|
||||
'accept_hover' => env('COOKIE_CONSENT_COLOR_ACCEPT_HOVER', '#15803d'),
|
||||
|
||||
// Einstellungen speichern Button
|
||||
'save' => env('COOKIE_CONSENT_COLOR_SAVE', '#a5a5a5'),
|
||||
'save_hover' => env('COOKIE_CONSENT_COLOR_SAVE_HOVER', '#b3b3b3'),
|
||||
|
||||
// Floating Button
|
||||
'button_bg' => env('COOKIE_CONSENT_COLOR_BUTTON_BG', '#0088cc'),
|
||||
'button_hover' => env('COOKIE_CONSENT_COLOR_BUTTON_HOVER', '#006699'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -20,6 +20,14 @@ return [
|
|||
*/
|
||||
'protocol' => env('APP_PROTOCOL', 'https://'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cabinet Quick-Status Key
|
||||
|--------------------------------------------------------------------------
|
||||
| Secret key for the keyless-login quick status page at /info/status?k=...
|
||||
*/
|
||||
'cabinet_status_key' => env('CABINET_STATUS_KEY', null),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Domain-Namen (ohne Protokoll)
|
||||
|
|
|
|||
356
config/flux-cms.php
Normal file
356
config/flux-cms.php
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Flux CMS Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file contains the configuration options for Flux CMS.
|
||||
|
|
||||
*/
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Locale
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default locale for the CMS content.
|
||||
|
|
||||
*/
|
||||
'default_locale' => env('FLUX_CMS_DEFAULT_LOCALE', 'de'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Available Locales
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The available locales for the CMS content.
|
||||
|
|
||||
*/
|
||||
'locales' => [
|
||||
'de' => 'Deutsch',
|
||||
'en' => 'English',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Component Paths
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The namespaces where the CMS will scan for components.
|
||||
|
|
||||
*/
|
||||
'component_paths' => [
|
||||
'App\\Livewire\\Components',
|
||||
'FluxCms\\StarterComponents\\Components',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for caching component registry and other CMS data.
|
||||
|
|
||||
*/
|
||||
'cache' => [
|
||||
'enabled' => env('FLUX_CMS_CACHE_ENABLED', true),
|
||||
'ttl' => env('FLUX_CMS_CACHE_TTL', 3600), // 1 hour
|
||||
'key_prefix' => 'flux_cms',
|
||||
'store' => env('FLUX_CMS_CACHE_STORE', null), // null = default cache store
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Database table configuration for Flux CMS.
|
||||
|
|
||||
*/
|
||||
'database' => [
|
||||
'table_prefix' => 'flux_cms_',
|
||||
'connection' => env('FLUX_CMS_DB_CONNECTION', null), // null = default connection
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication & Authorization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for user authentication and authorization.
|
||||
|
|
||||
*/
|
||||
'auth' => [
|
||||
'guard' => env('FLUX_CMS_AUTH_GUARD', 'web'),
|
||||
'default_access' => env('FLUX_CMS_DEFAULT_ACCESS', false),
|
||||
'super_admin_role' => 'admin',
|
||||
'cms_role' => 'flux-cms',
|
||||
'permissions' => [
|
||||
'view' => 'flux-cms.view',
|
||||
'edit' => 'flux-cms.edit',
|
||||
'publish' => 'flux-cms.publish',
|
||||
'delete' => 'flux-cms.delete',
|
||||
'admin' => 'flux-cms.admin',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Routes Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for CMS routes.
|
||||
|
|
||||
*/
|
||||
'routes' => [
|
||||
'enabled' => env('FLUX_CMS_ROUTES_ENABLED', false),
|
||||
'prefix' => env('FLUX_CMS_ROUTE_PREFIX', ''),
|
||||
'middleware' => ['web'],
|
||||
'admin_prefix' => env('FLUX_CMS_ADMIN_PREFIX', 'admin/cms'),
|
||||
'admin_middleware' => ['web', 'auth'],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SEO Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| SEO-related configuration options.
|
||||
|
|
||||
*/
|
||||
'seo' => [
|
||||
'site_name' => env('FLUX_CMS_SITE_NAME', config('app.name')),
|
||||
'separator' => env('FLUX_CMS_SEO_SEPARATOR', ' - '),
|
||||
'meta_keywords_limit' => 10,
|
||||
'meta_description_limit' => 160,
|
||||
'auto_sitemap' => true,
|
||||
'auto_robots' => true,
|
||||
'canonical_urls' => true,
|
||||
'og_image_default' => null,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Media Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for media handling.
|
||||
|
|
||||
*/
|
||||
'media' => [
|
||||
'disk' => env('FLUX_CMS_MEDIA_DISK', 'public'),
|
||||
'max_file_size' => env('FLUX_CMS_MAX_FILE_SIZE', 10240), // 10MB in KB
|
||||
'originals_path' => 'cms/media/originals',
|
||||
'conversions_path' => 'cms/media/conversions',
|
||||
'allowed_extensions' => [
|
||||
'images' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'],
|
||||
'documents' => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv'],
|
||||
],
|
||||
'profiles' => [
|
||||
'thumb' => [
|
||||
'width' => 300,
|
||||
'height' => 300,
|
||||
'format' => 'webp',
|
||||
'quality' => 80,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'hero' => [
|
||||
'width' => 1920,
|
||||
'height' => 800,
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'card' => [
|
||||
'width' => 768,
|
||||
'height' => 512,
|
||||
'format' => 'webp',
|
||||
'quality' => 80,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'gallery' => [
|
||||
'width' => 1200,
|
||||
'height' => 900,
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'service' => [
|
||||
'width' => 800,
|
||||
'height' => 600,
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'avatar' => [
|
||||
'width' => 400,
|
||||
'height' => 400,
|
||||
'format' => 'webp',
|
||||
'quality' => 85,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'thumbnail' => [
|
||||
'width' => 600,
|
||||
'height' => 400,
|
||||
'format' => 'webp',
|
||||
'quality' => 80,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
'og_image' => [
|
||||
'width' => 1200,
|
||||
'height' => 630,
|
||||
'format' => 'jpg',
|
||||
'quality' => 90,
|
||||
'fit' => 'cover',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Editor Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for WYSIWYG editors.
|
||||
|
|
||||
*/
|
||||
'editor' => [
|
||||
'default' => env('FLUX_CMS_EDITOR', 'tiptap'), // tiptap, tinymce, quill
|
||||
'upload_images' => true,
|
||||
'max_image_size' => 2048, // 2MB in KB
|
||||
'toolbar_presets' => [
|
||||
'basic' => ['bold', 'italic'],
|
||||
'standard' => ['bold', 'italic', 'link', 'bulletList', 'orderedList'],
|
||||
'full' => [
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strike',
|
||||
'heading1',
|
||||
'heading2',
|
||||
'heading3',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'link',
|
||||
'image',
|
||||
'table',
|
||||
'code',
|
||||
'codeBlock',
|
||||
'quote',
|
||||
'rule',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Versioning Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for content versioning.
|
||||
|
|
||||
*/
|
||||
'versioning' => [
|
||||
'enabled' => env('FLUX_CMS_VERSIONING_ENABLED', true),
|
||||
'auto_version' => env('FLUX_CMS_AUTO_VERSION', true),
|
||||
'max_versions' => env('FLUX_CMS_MAX_VERSIONS', 50),
|
||||
'cleanup_old_versions' => env('FLUX_CMS_CLEANUP_VERSIONS', true),
|
||||
'cleanup_after_days' => env('FLUX_CMS_CLEANUP_AFTER_DAYS', 365),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Performance Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Performance-related configuration options.
|
||||
|
|
||||
*/
|
||||
'performance' => [
|
||||
'eager_load_relations' => ['components', 'media'],
|
||||
'pagination_size' => 20,
|
||||
'component_registry_cache' => true,
|
||||
'query_cache_ttl' => 300, // 5 minutes
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Development Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration options for development mode.
|
||||
|
|
||||
*/
|
||||
'development' => [
|
||||
'debug_mode' => env('FLUX_CMS_DEBUG', false),
|
||||
'show_component_info' => env('FLUX_CMS_SHOW_COMPONENT_INFO', false),
|
||||
'log_queries' => env('FLUX_CMS_LOG_QUERIES', false),
|
||||
'hot_reload_components' => env('FLUX_CMS_HOT_RELOAD', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Frontend Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for frontend rendering.
|
||||
|
|
||||
*/
|
||||
'frontend' => [
|
||||
'layout' => env('FLUX_CMS_LAYOUT', 'layouts.app'),
|
||||
'theme' => env('FLUX_CMS_THEME', 'default'),
|
||||
'css_framework' => env('FLUX_CMS_CSS_FRAMEWORK', 'tailwind'), // tailwind, bootstrap
|
||||
'component_wrapper' => env('FLUX_CMS_COMPONENT_WRAPPER', true),
|
||||
'preview_mode' => env('FLUX_CMS_PREVIEW_MODE', true),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for CMS API endpoints.
|
||||
|
|
||||
*/
|
||||
'api' => [
|
||||
'enabled' => env('FLUX_CMS_API_ENABLED', false),
|
||||
'prefix' => env('FLUX_CMS_API_PREFIX', 'api/cms'),
|
||||
'middleware' => ['api', 'auth:sanctum'],
|
||||
'rate_limiting' => env('FLUX_CMS_API_RATE_LIMIT', '60,1'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Backup Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for content backups.
|
||||
|
|
||||
*/
|
||||
'backup' => [
|
||||
'enabled' => env('FLUX_CMS_BACKUP_ENABLED', true),
|
||||
'disk' => env('FLUX_CMS_BACKUP_DISK', 'local'),
|
||||
'auto_backup' => env('FLUX_CMS_AUTO_BACKUP', true),
|
||||
'backup_schedule' => env('FLUX_CMS_BACKUP_SCHEDULE', 'daily'),
|
||||
'keep_backups' => env('FLUX_CMS_KEEP_BACKUPS', 30),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Domain Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Multi-domain support configuration.
|
||||
|
|
||||
*/
|
||||
'domains' => [
|
||||
'enabled' => env('FLUX_CMS_MULTI_DOMAIN', true),
|
||||
'config_source' => 'domains', // 'domains' config key or 'database'
|
||||
'default_domain' => env('FLUX_CMS_DEFAULT_DOMAIN', 'default'),
|
||||
'auto_detect' => env('FLUX_CMS_AUTO_DETECT_DOMAIN', true),
|
||||
],
|
||||
|
||||
];
|
||||
10
database/data/b2in-en/about_hero.json
Normal file
10
database/data/b2in-en/about_hero.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"title": "<span class=\"text-secondary\">About B2in:</span> Our Mission",
|
||||
"quote": "“My mission is to connect two worlds that belong together: international real estate and exclusive furnishings. B2in gives the local subject matter expert the digital tools and the real estate developer the operational partner on site.<br><br>We build bridges – between European design, international markets and people's homes.\"",
|
||||
"founder_name": "Marcel Scheibe",
|
||||
"founder_title": "Founder & CEO, B2in",
|
||||
"image": "b2in/about-hero.jpg",
|
||||
"image_alt": "Marcel Scheibe, founder and CEO of B2in",
|
||||
"card_title": "B2in",
|
||||
"card_text": "Connecting Design and <span class=\"text-secondary\">Property</span>"
|
||||
}
|
||||
6
database/data/b2in-en/about_image_break.json
Normal file
6
database/data/b2in-en/about_image_break.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"image": "b2in/best-of-two-worlds.jpg",
|
||||
"image_alt": "B2in – Connecting real estate and furnishings",
|
||||
"quote": "Regionally rooted, internationally networked – that's B2in.",
|
||||
"author": "Marcel Scheibe"
|
||||
}
|
||||
8
database/data/b2in-en/announcement_bar.json
Normal file
8
database/data/b2in-en/announcement_bar.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"enabled": true,
|
||||
"id": "azizi-launch-2026",
|
||||
"badge": "NEW LAUNCH",
|
||||
"text": "Azizi Creek Views 4 – Exclusive off-market project in Dubai from AED 1,125,000 (approx. EUR 283,000/USD 306,000)",
|
||||
"link_text": "View Exposé",
|
||||
"link_url": "/immobilien/azizi-creek-views-4"
|
||||
}
|
||||
33
database/data/b2in-en/brand_worlds.json
Normal file
33
database/data/b2in-en/brand_worlds.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"title": "<span class=\"text-secondary\">Our worlds:</span><br> design, real estate and international trade",
|
||||
"subtitle": "From international real estate investments to exclusive furnishing concepts to transatlantic trade – B2in connects the worlds that belong together.",
|
||||
"worlds": [
|
||||
{
|
||||
"image": "b2in/stileigentum.jpg",
|
||||
"title": "Style Property",
|
||||
"description": "The premium segment: exclusive and high-quality furnishing concepts for demanding customers who value quality and tradition.",
|
||||
"link": "https://stileigentum.test",
|
||||
"logo": "img/logos/style-ownership-logo-positive.svg",
|
||||
"logo_width": "w-35",
|
||||
"external": true
|
||||
},
|
||||
{
|
||||
"image": "b2in/style2own.jpg",
|
||||
"title": "Style2own",
|
||||
"description": "The lifestyle channel: Modern furnishing concepts for young professionals and trend-oriented customers – urban, flexible, inspiring.",
|
||||
"link": "https://style2own.test",
|
||||
"logo": "img/logos/style2own-logo-positive.svg",
|
||||
"logo_width": "W 28",
|
||||
"external": true
|
||||
},
|
||||
{
|
||||
"image": "b2in/b2a.jpg",
|
||||
"title": "B2A",
|
||||
"description": "Our logistics power for transatlantic trade. We give manufacturers direct access to international markets.",
|
||||
"link": "https://b2a.test",
|
||||
"logo": "img/logos/b2a-logo-positive.svg",
|
||||
"logo_width": "W.18.",
|
||||
"external": true
|
||||
}
|
||||
]
|
||||
}
|
||||
32
database/data/b2in-en/broker_section.json
Normal file
32
database/data/b2in-en/broker_section.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"title": "Lifetime <span class=\"text-secondary\">remuneration</span> for brokers",
|
||||
"subtitle": "Benefit from a revolutionary compensation model that goes beyond one-time sales. Build long-term customer relationships and generate continuous revenue.",
|
||||
"card_title": "Lifetime Compensation",
|
||||
"compensation": {
|
||||
"initial_sale": "3.5%",
|
||||
"follow_up": "1.5%"
|
||||
},
|
||||
"compensation_text": "Continuous revenue across the entire customer relationship",
|
||||
"benefits": [
|
||||
{
|
||||
"title": "Lifetime Compensation Model",
|
||||
"description": "Continuous commissions through long-term customer relationships and recurring business",
|
||||
"icon": "trending-up"
|
||||
},
|
||||
{
|
||||
"title": "Faster time to market",
|
||||
"description": "Thoughtful living concepts reduce sales time and increase the chances of success",
|
||||
"icon": "clock"
|
||||
},
|
||||
{
|
||||
"title": "Qualified leads",
|
||||
"description": "Pre-filtered, interested customers through the B2in portal and premium memberships",
|
||||
"icon": "target"
|
||||
},
|
||||
{
|
||||
"title": "Premium positioning",
|
||||
"description": "Exclusive marketing of high-quality living concepts for demanding target groups",
|
||||
"icon": "German Comedy Award"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
database/data/b2in-en/commitment_section.json
Normal file
27
database/data/b2in-en/commitment_section.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"title": "The <span class=\"text-secondary\">trust</span> of our partners",
|
||||
"subtitle": "Real opinions from real partners. Your success is our greatest incentive.",
|
||||
"testimonials": [
|
||||
{
|
||||
"image": "b2in/testo-1.jpg",
|
||||
"rating": 5,
|
||||
"quote": "Working with B2in exceeded our expectations. Professional, efficient and always solution-oriented.",
|
||||
"author": "John Smith",
|
||||
"author_title": ": Furniture manufacturer"
|
||||
},
|
||||
{
|
||||
"image": "b2in/testo-2.jpg",
|
||||
"rating": 5,
|
||||
"quote": "Thanks to the B2in platform, we were able to significantly increase our reach and open up new markets.",
|
||||
"author": "John Doe",
|
||||
"author_title": "local furniture retailer"
|
||||
},
|
||||
{
|
||||
"image": "b2in/testo-3.jpg",
|
||||
"rating": 5,
|
||||
"quote": "The B2in portal has revolutionized the way I market real estate. The staging enhances my properties, and the furniture commission is an extremely attractive additional income.",
|
||||
"author": "John Doe",
|
||||
"author_title": "Real estate agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
87
database/data/b2in-en/contact_form.json
Normal file
87
database/data/b2in-en/contact_form.json
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"hero": {
|
||||
"title": "Send us a Message",
|
||||
"subtitle": "We look forward to hearing from you and will be in touch as soon as possible.Your query"
|
||||
},
|
||||
"form": {
|
||||
"labels": {
|
||||
"first_name": "First name *",
|
||||
"last_name": "Last name *",
|
||||
"company": "Company (optional)",
|
||||
"email": "Email *",
|
||||
"phone": "Phone (optional)",
|
||||
"subject": "Subject *",
|
||||
"message": "Your message *"
|
||||
},
|
||||
"subjects": {
|
||||
"": "please choose a subject",
|
||||
"immobilien": "International",
|
||||
"supply_chain": "Supply chain management",
|
||||
"general": "General enquiry",
|
||||
"partnership": "Partnership",
|
||||
"press": "Press",
|
||||
"career": "Career"
|
||||
},
|
||||
"placeholders": {
|
||||
"message": "Your message"
|
||||
},
|
||||
"button_text": "Submit",
|
||||
"button_loading": "Submitting…",
|
||||
"success_message": "General Terms Sincere thanks for your message. We will reply to you soon."
|
||||
},
|
||||
"contact_info": [
|
||||
{
|
||||
"title": "Our office location",
|
||||
"info": [
|
||||
"Rathausstraße 11",
|
||||
"33602 Bielefeld"
|
||||
],
|
||||
"icon": "map-pin"
|
||||
},
|
||||
{
|
||||
"title": "Our Email",
|
||||
"info": [
|
||||
"info@b2in.eu"
|
||||
],
|
||||
"icon": "mail:"
|
||||
},
|
||||
{
|
||||
"title": "Our contact number",
|
||||
"info": [
|
||||
"+49 (0) 5221 9255055"
|
||||
],
|
||||
"icon": "phone"
|
||||
}
|
||||
],
|
||||
"social_media": {
|
||||
"title": "Follow for<br /><span class=\"text-secondary font-medium\">exclusives</span>",
|
||||
"subtitle": "Stay up to date with exclusive offers and news.",
|
||||
"platforms": [
|
||||
{
|
||||
"name": "Instagram",
|
||||
"handle": "@b2in_official",
|
||||
"url": "https://instagram.com/b2in_official"
|
||||
},
|
||||
{
|
||||
"name": "YouTube",
|
||||
"handle": "B2IN Channel",
|
||||
"url": "https://youtube.com/@b2in"
|
||||
},
|
||||
{
|
||||
"name": "pinterest",
|
||||
"handle": "B2IN Inspiration",
|
||||
"url": "https://pinterest.com/b2in"
|
||||
},
|
||||
{
|
||||
"name": "Facebook",
|
||||
"handle": "B2IN Germany",
|
||||
"url": "https://facebook.com/b2in"
|
||||
},
|
||||
{
|
||||
"name": "LinkedIn",
|
||||
"handle": "B2IN Company",
|
||||
"url": "https://linkedin.com/company/b2in"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
6
database/data/b2in-en/cta_section.json
Normal file
6
database/data/b2in-en/cta_section.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"title": "Your next <span class=\"text-primary\">step</span>",
|
||||
"subtitle": "Whether real estate investment, supply chain partnership or furnishing network – talk to us directly.",
|
||||
"button_text": "Get in touch",
|
||||
"button_link": "/contact"
|
||||
}
|
||||
30
database/data/b2in-en/dark_stats_section.json
Normal file
30
database/data/b2in-en/dark_stats_section.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"stats": [
|
||||
{
|
||||
"number": "17+",
|
||||
"text": "Years of Experience"
|
||||
},
|
||||
{
|
||||
"number": "2M",
|
||||
"text": "Happy Guests"
|
||||
}
|
||||
],
|
||||
"title": "Economically Sound and<br>Well-<span class=\"text-secondary\"> Friendly Service</span> for<br>Families and Their<br>Precious Belongings",
|
||||
"description": "We understand that every family is unique, which is why we offer personalized services tailored to meet your specific needs. From luxury amenities to budget-friendly options, we ensure every guest feels valued and comfortable.",
|
||||
"features": [
|
||||
{
|
||||
"title": "Top Consultation",
|
||||
"subtitle": "Expert Guidance/"
|
||||
},
|
||||
{
|
||||
"title": "Finest Meal",
|
||||
"subtitle": "Gourmet Dining."
|
||||
},
|
||||
{
|
||||
"title": "Easy Tax Reduction",
|
||||
"subtitle": "• Cost effective."
|
||||
}
|
||||
],
|
||||
"image": "b2in/accommodation-2.jpg",
|
||||
"image_alt": "Luxury interior design"
|
||||
}
|
||||
39
database/data/b2in-en/digital_core.json
Normal file
39
database/data/b2in-en/digital_core.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"title": "The technology that <span class=\"text-secondary\">enables this cycle</span>",
|
||||
"subtitle": "Our central platform is more than just technology. It is the digital heart of transparent processes, data-driven decisions and seamless collaboration across the ecosystem.",
|
||||
"features": [
|
||||
{
|
||||
"title": "Maximize delivery reliability",
|
||||
"description": "Our platform is accessible anytime, anywhere. It grows with your success and guarantees a stable operation you can rely on.",
|
||||
"icon": "cloud",
|
||||
"icon_style": "solid"
|
||||
},
|
||||
{
|
||||
"title": "Safety without compromise!",
|
||||
"description": "Your data and that of your customers is our greatest asset. We protect them with state-of-the-art security architectures and guarantee the fullest data protection.",
|
||||
"icon": "shield-check"
|
||||
},
|
||||
{
|
||||
"title": "Sustainable integration",
|
||||
"description": "The B2in portal is open for the future. It integrates seamlessly with other systems and is ready for future enhancements and technologies.",
|
||||
"icon": "squares-2x2"
|
||||
},
|
||||
{
|
||||
"title": "Data-driven insights",
|
||||
"description": "As a broker, track your customers' activities or as a supplier, track the performance of your products – all in real time. Make better decisions based on valid data.",
|
||||
"icon": "chart-bar",
|
||||
"icon_style": "solid"
|
||||
},
|
||||
{
|
||||
"title": "Intelligent personalisation",
|
||||
"description": "Artificial intelligence supports you and your customers – from personalized set-up suggestions to automating processes for maximum efficiency.",
|
||||
"icon": "cpu chip"
|
||||
},
|
||||
{
|
||||
"title": "Excellent user experience",
|
||||
"description": "Our platform is optimized for maximum speed and offers intuitive and fluid operation on any device – from desktop to smartphone.",
|
||||
"icon": "users",
|
||||
"icon_style": "solid"
|
||||
}
|
||||
]
|
||||
}
|
||||
24
database/data/b2in-en/ecosystem_core.json
Normal file
24
database/data/b2in-en/ecosystem_core.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"title": "One ecosystem, <span class=\"text-secondary\">three pillars</span>",
|
||||
"subtitle": "",
|
||||
"pillars": [
|
||||
{
|
||||
"icon": "building-office-2",
|
||||
"title": "Real Estate & Investments",
|
||||
"description": "Exclusive off-market projects & high-yield investment properties.",
|
||||
"link": "/immobilien"
|
||||
},
|
||||
{
|
||||
"icon": "squares-2x2",
|
||||
"title": "Local-for-Local Marketplace",
|
||||
"description": "The network for regional furniture retailers and brokers.",
|
||||
"link": "/netzwerk"
|
||||
},
|
||||
{
|
||||
"icon": "clipboard-document-check",
|
||||
"title": "Supply chain management",
|
||||
"description": "German contract security for international real estate developers.",
|
||||
"link": "/netzwerk"
|
||||
}
|
||||
]
|
||||
}
|
||||
39
database/data/b2in-en/ecosystem_hero.json
Normal file
39
database/data/b2in-en/ecosystem_hero.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"title": "How our <span class=\"text-secondary\">ecosystem</span> generates growth for all <span class=\"text-secondary\">partners</span>",
|
||||
"subtitle": "An intelligent network that seamlessly connects real estate buyers, local experts, international manufacturers, brokers and developers – for exceptional real estate and furnishing experiences.",
|
||||
"features": [
|
||||
{
|
||||
"title": "Real estate",
|
||||
"description": "International Investments",
|
||||
"icon": "globe-alt"
|
||||
},
|
||||
{
|
||||
"title": "setup",
|
||||
"description": "Local for Local",
|
||||
"icon": "cube-transparent"
|
||||
},
|
||||
{
|
||||
"title": "Supply Chain",
|
||||
"description": "Procurement & Control",
|
||||
"icon": "clipboard-document-check"
|
||||
},
|
||||
{
|
||||
"title": "Technology",
|
||||
"description": "Digital centerpiece",
|
||||
"icon": "cpu chip"
|
||||
}
|
||||
],
|
||||
"image": "b2in/ecosystem-hero.jpg",
|
||||
"image_alt": "Ecosystem Hero Image",
|
||||
"card_title": "B2in Portal",
|
||||
"card_text": "The technology behind it that enables the ecosystem",
|
||||
"hub": {
|
||||
"title": "B2in Portal",
|
||||
"subtitle": "The technology behind it that enables the ecosystem"
|
||||
},
|
||||
"stats": [
|
||||
"Exclusive Selection",
|
||||
"Service that is personal",
|
||||
"Werte, die bleiben."
|
||||
]
|
||||
}
|
||||
11
database/data/b2in-en/ecosystem_hub.json
Normal file
11
database/data/b2in-en/ecosystem_hub.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"title": "Local for Local <span class=\"text-secondary\">meets</span> <span class=\"text-secondary\">international expertise at the</span> hub",
|
||||
"paragraphs": [
|
||||
"As soon as a customer chooses their region, our platform plays to its strength: the <span class=\"text-secondary font-bold\">\"Local First\"logic</span> prominently displays the offers of the <span class=\"text-secondary font-bold\">local subject matter experts</span>.",
|
||||
"The range is supplemented by <span class=\"text-secondary font-bold\">European manufacturers</span>. And for real estate developers, we deliver the operational procurement right away – from the contract to quality control."
|
||||
],
|
||||
"image": "b2in/ecosystem_hub.jpg",
|
||||
"image_alt": "Local for Local – local expertise meets international procurement",
|
||||
"image_caption": "Local for Local: Local expertise, international reach",
|
||||
"image_description": "A clear infographic showing: [Local Supply] + [International Procurement] = The B2in Ecosystem."
|
||||
}
|
||||
24
database/data/b2in-en/ecosystem_result.json
Normal file
24
database/data/b2in-en/ecosystem_result.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"title": "A cycle where <span class=\"text-secondary\">everyone wins</span>",
|
||||
"paragraphs": [
|
||||
"This interaction creates <span class=\"text-secondary font-bold\">clear advantages</span> for each participant:"
|
||||
],
|
||||
"list": [
|
||||
{
|
||||
"icon": "globe-alt",
|
||||
"title": "The real estate developer receives a reliable partner for procurement and quality control in Germany – transparently and on time."
|
||||
},
|
||||
{
|
||||
"icon": "building-storefront",
|
||||
"title": "The local trader gains qualified customers he would not have reached on his own and strengthens his position on the ground."
|
||||
},
|
||||
{
|
||||
"icon": "home-modern",
|
||||
"title": "The broker offers its customers unique added value after closing and benefits from attractive additional commissions."
|
||||
}
|
||||
],
|
||||
"image": "b2in/ecosystem_result.jpg",
|
||||
"image_alt": "Your success and partnership with B2in",
|
||||
"image_caption": "Your success and partnership with B2in",
|
||||
"image_description": "A stylized graphic that shows how your success and partnership are related to B2in."
|
||||
}
|
||||
11
database/data/b2in-en/ecosystem_start.json
Normal file
11
database/data/b2in-en/ecosystem_start.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"title": "It all starts with the <span class=\"text-secondary\">moment of need:</span>",
|
||||
"paragraphs": [
|
||||
"Our ecosystem starts with the customer – exactly when they need it: when <span class=\"text-secondary font-bold\">buying real estate</span>.",
|
||||
"The broker gives the customer access to the B2in ecosystem. Our style2own and <span class=\"text-secondary font-bold\">style</span> property <span class=\"text-secondary font-bold\">brands</span> create the right framework – depending on the target group and lifestyle."
|
||||
],
|
||||
"image": "b2in/ecosystem_start.jpg",
|
||||
"image_alt": "The entry into the ecosystem – via the purchase of real estate",
|
||||
"image_caption": "Getting Started: Buying Real Estate as a Trigger",
|
||||
"image_description": "A stylized graphic showing how real estate buying triggers entry into the B2in ecosystem."
|
||||
}
|
||||
26
database/data/b2in-en/ecosystem_stats.json
Normal file
26
database/data/b2in-en/ecosystem_stats.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"title": "Our <span class=\"text-secondary\">Ecosystem</span> in Numbers",
|
||||
"subtitle": "Figures that reflect the strength and confidence in our connected business model.",
|
||||
"stats": [
|
||||
{
|
||||
"number": "1.7K+",
|
||||
"label": "Partners & experts in the network",
|
||||
"description": "Growing community of developers, brokers, dealers and manufacturers"
|
||||
},
|
||||
{
|
||||
"number": "510+",
|
||||
"label": "Projects completed",
|
||||
"description": "Real estate and furnishing projects through our network"
|
||||
},
|
||||
{
|
||||
"number": "98%",
|
||||
"label": "Partner Satisfaction",
|
||||
"description": "Satisfaction across all ecosystem participants"
|
||||
},
|
||||
{
|
||||
"number": "All day",
|
||||
"label": "Partner Support",
|
||||
"description": "Continuous availability of digital infrastructure"
|
||||
}
|
||||
]
|
||||
}
|
||||
37
database/data/b2in-en/end_customer_section.json
Normal file
37
database/data/b2in-en/end_customer_section.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"tag": "For end customers",
|
||||
"title": "Exclusive <span class=\"text-secondary\">experiences</span> for you",
|
||||
"subtitle": "With your personal login card, you get access to a unique world of experiences that is specially tailored to your living wishes and lifestyle. Discover curated properties and services that are otherwise unavailable.",
|
||||
"benefits": [
|
||||
{
|
||||
"title": "Exclusive Login Card",
|
||||
"description": "Personalized access to select real estate experiences and premium services",
|
||||
"icon": "- Credit Card"
|
||||
},
|
||||
{
|
||||
"title": "Personalised experience",
|
||||
"description": "Tailored property offers based on individual preferences and needs",
|
||||
"icon": "star"
|
||||
},
|
||||
{
|
||||
"title": "Curated living concepts",
|
||||
"description": "High-quality, well thought-out real estate solutions from verified partners",
|
||||
"icon": "home"
|
||||
},
|
||||
{
|
||||
"title": "Quality guarantee",
|
||||
"description": "Tested vendors and standardized quality processes for maximum safety",
|
||||
"icon": "shield"
|
||||
}
|
||||
],
|
||||
"image": "b2in/end-customer-section.jpg",
|
||||
"image_alt": "End Customer Section Image",
|
||||
"card_title": "Login Card",
|
||||
"card_text": "Your key to exclusive real estate experiences",
|
||||
"card": {
|
||||
"title": "Login Card",
|
||||
"subtitle": "Your key to exclusive real estate experiences",
|
||||
"member_number_label": "Membership number",
|
||||
"member_number": "B2IN-2024-VIP"
|
||||
}
|
||||
}
|
||||
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