10-04-2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:18:17 +02:00
parent 4d6b4930b2
commit 4bb89aad8c
836 changed files with 52961 additions and 5950 deletions

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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'],
};
}
}

View 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)";
}
}

View 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'],
]);
}
}

View 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,
];
}
}

View file

@ -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);
}

View 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);
}
}

View file

@ -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,
]);

View 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');
}
}

View 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,
]);
}
}

View 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);
}
}

View 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,
]);
}
}

View 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(),
]);
}
}

View 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');
}
}

View 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);
}
}

View 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');
}
}

View file

@ -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()

View file

@ -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');

View file

@ -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'] ?? '';

View file

@ -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()

View file

@ -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;
}

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -10,12 +10,11 @@ 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()
{
return view('livewire.web.components.sections.ecosystem-hero');
}
}
}

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View 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');
}
}

View file

@ -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()

View file

@ -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()

View file

@ -7,24 +7,24 @@ 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()
{
if (!empty($this->content['slides'])) {
if (! empty($this->content['slides'])) {
$this->currentSlide = ($this->currentSlide + 1) % count($this->content['slides']);
}
}
public function previousSlide()
{
if (!empty($this->content['slides'])) {
if (! empty($this->content['slides'])) {
$totalSlides = count($this->content['slides']);
$this->currentSlide = ($this->currentSlide - 1 + $totalSlides) % $totalSlides;
}
@ -32,7 +32,7 @@ class HeroSlider extends Component
public function setSlide($index)
{
if (!empty($this->content['slides']) && $index >= 0 && $index < count($this->content['slides'])) {
if (! empty($this->content['slides']) && $index >= 0 && $index < count($this->content['slides'])) {
$this->currentSlide = $index;
}
}

View file

@ -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()

View 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');
}
}

View 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');
}
}

View file

@ -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()

View file

@ -7,8 +7,11 @@ use Livewire\Component;
class MagazinDetail extends Component
{
public $articleId;
public $article;
public $relatedArticles;
public $content;
public function mount($id = 1)
@ -29,7 +32,7 @@ class MagazinDetail extends Component
{
$articles = $this->getArticlesData();
$this->relatedArticles = collect($articles)
->filter(fn($article, $key) => $key != $this->articleId)
->filter(fn ($article, $key) => $key != $this->articleId)
->take(2)
->values()
->toArray();
@ -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,
]);
}
}

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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;
}

View file

@ -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,
]);
}
}

View file

@ -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()

View file

@ -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;

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

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

View file

@ -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');
}

View file

@ -10,17 +10,20 @@ 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'])) {
$this->content['portal_login'] = '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;
}

View file

@ -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',
);
}

View 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
View 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
View 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
View 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
View 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);
}
}

View 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);
}
}

View 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');
}
}

View 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;
}
}

View 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);
}
}

View 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)),
];
}
}

View 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
View 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];
}
}