23-01-2026
|
|
@ -10,6 +10,15 @@ class ThemeHelper
|
|||
public static function getLogoPath(string $type = 'positive'): string
|
||||
{
|
||||
$theme = config('app.theme', 'b2in');
|
||||
return self::getLogoPathForBrand($theme, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logo path for a specific brand
|
||||
*/
|
||||
public static function getLogoPathForBrand(?string $brand = null, string $type = 'positive'): string
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
|
||||
$logoMap = [
|
||||
'b2in' => [
|
||||
|
|
@ -30,7 +39,7 @@ class ThemeHelper
|
|||
]
|
||||
];
|
||||
|
||||
return $logoMap[$theme][$type] ?? $logoMap['b2in'][$type];
|
||||
return $logoMap[$brand][$type] ?? $logoMap['b2in'][$type];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,4 +119,45 @@ class ThemeHelper
|
|||
$config = self::getDomainConfig();
|
||||
return $config['url'] ?? config('app.url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand colors for a specific brand
|
||||
*/
|
||||
public static function getBrandColors(?string $brand = null): array
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
$config = config("domains.domains.{$brand}", []);
|
||||
|
||||
return [
|
||||
'primary' => $config['color_scheme']['primary'] ?? '#2b3f51',
|
||||
'secondary' => $config['color_scheme']['secondary'] ?? '#20a0da',
|
||||
'accent' => $config['color_scheme']['accent'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand name for a specific brand
|
||||
*/
|
||||
public static function getBrandName(?string $brand = null): string
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
|
||||
$brandNames = [
|
||||
'b2in' => 'B2IN',
|
||||
'b2a' => 'B2A',
|
||||
'stileigentum' => 'StilEigentum',
|
||||
'style2own' => 'Style2Own',
|
||||
];
|
||||
|
||||
return $brandNames[$brand] ?? strtoupper($brand);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all brand configuration for a specific brand
|
||||
*/
|
||||
public static function getBrandConfig(?string $brand = null): array
|
||||
{
|
||||
$brand = $brand ?? config('app.theme', 'b2in');
|
||||
return config("domains.domains.{$brand}", []);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
app/Http/Controllers/Api/DisplayConfigController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DisplayVideo;
|
||||
use App\Models\DisplayFooterContent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DisplayConfigController extends Controller
|
||||
{
|
||||
/**
|
||||
* Gibt die Konfiguration für die Display-Seite zurück
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$videos = DisplayVideo::active()->get()->map(function ($video) {
|
||||
return [
|
||||
'src' => $video->full_path,
|
||||
'position' => $video->position,
|
||||
];
|
||||
});
|
||||
|
||||
$footerContent = DisplayFooterContent::active()->get()->map(function ($footer) {
|
||||
$data = [
|
||||
'headline' => $footer->headline,
|
||||
'subline' => $footer->subline,
|
||||
];
|
||||
|
||||
// URL nur hinzufügen wenn vorhanden
|
||||
if ($footer->url) {
|
||||
$data['url'] = $footer->short_url;
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'videoPlaylist' => $videos,
|
||||
'footerContent' => $footerContent,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,16 @@ class BasicAuthMiddleware
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
|
||||
if (str_starts_with($path, 'flux/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Skip Basic Auth für Display-API und Short-Links (öffentlicher Zugriff für Display-Seite)
|
||||
if ($request->is('api/display/*') || $request->is('_cabinet/*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Credentials from .env file
|
||||
$user = config('auth.basic.user');
|
||||
$pass = config('auth.basic.password');
|
||||
|
|
|
|||
59
app/Http/Middleware/SetDomainUrl.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Middleware um die URL-Konfiguration basierend auf der aktuellen Domain zu setzen.
|
||||
*
|
||||
* Diese Middleware muss sehr früh im Request-Lifecycle ausgeführt werden,
|
||||
* um sicherzustellen, dass url() und asset() die richtige Domain verwenden.
|
||||
*/
|
||||
class SetDomainUrl
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = $request->getHost();
|
||||
|
||||
// Suche nach der Domain-Konfiguration
|
||||
$domainConfig = null;
|
||||
$domains = config('domains.domains', []);
|
||||
|
||||
foreach ($domains as $name => $config) {
|
||||
if (is_array($config) && isset($config['domain_name']) && $config['domain_name'] === $host) {
|
||||
$domainConfig = $config;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn eine Domain-Konfiguration gefunden wurde, setze die URL
|
||||
if ($domainConfig && isset($domainConfig['url'])) {
|
||||
$domainUrl = $domainConfig['url'];
|
||||
|
||||
// URL-Generator konfigurieren
|
||||
URL::forceRootUrl($domainUrl);
|
||||
URL::forceScheme(parse_url($domainUrl, PHP_URL_SCHEME) ?: 'https');
|
||||
|
||||
// Asset-Root setzen
|
||||
/** @var UrlGenerator $urlGenerator */
|
||||
$urlGenerator = app('url');
|
||||
$urlGenerator->useAssetOrigin($domainUrl);
|
||||
|
||||
// Config aktualisieren
|
||||
config([
|
||||
'app.url' => $domainUrl,
|
||||
'app.asset_url' => $domainUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,13 +19,13 @@ class ThemeMiddleware
|
|||
$path = $request->path();
|
||||
|
||||
// Theme-Switching über Subdomains
|
||||
if (str_contains($host, 'b2in.test')) {
|
||||
if (str_contains($host, 'b2in')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
} elseif (str_contains($host, 'b2a.test')) {
|
||||
} elseif (str_contains($host, 'b2a') || str_contains($host, 'bridges2america')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
} elseif (str_contains($host, 'stileigentum.test')) {
|
||||
} elseif (str_contains($host, 'stileigentum')) {
|
||||
config(['app.theme' => 'stileigentum']);
|
||||
} elseif (str_contains($host, 'style2own.test')) {
|
||||
} elseif (str_contains($host, 'style2own')) {
|
||||
config(['app.theme' => 'style2own']);
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ class ThemeMiddleware
|
|||
if (str_starts_with($path, 'b2in/')) {
|
||||
config(['app.theme' => 'b2in']);
|
||||
$request->server->set('REQUEST_URI', '/' . substr($path, 5)); // Entferne 'b2in/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'b2a/')) {
|
||||
} elseif (str_starts_with($path, 'b2a/') || str_starts_with($path, 'bridges2america/')) {
|
||||
config(['app.theme' => 'b2a']);
|
||||
$request->server->set('REQUEST_URI', '/' . substr($path, 4)); // Entferne 'b2a/' vom Pfad
|
||||
} elseif (str_starts_with($path, 'stileigentum/')) {
|
||||
|
|
|
|||
292
app/Livewire/Admin/CMS/CabinetDisplay.php
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin\CMS;
|
||||
|
||||
use App\Models\DisplayVideo;
|
||||
use App\Models\DisplayFooterContent;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\On;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class CabinetDisplay extends Component
|
||||
{
|
||||
// Video-Verwaltung
|
||||
public $videoId = null;
|
||||
public $videoFilename = '';
|
||||
public $videoTitle = '';
|
||||
public $videoPosition = 25;
|
||||
public $videoIsActive = true;
|
||||
public $showVideoModal = false;
|
||||
public $availableVideos = [];
|
||||
|
||||
// Footer-Content-Verwaltung
|
||||
public $footerId = null;
|
||||
public $footerHeadline = '';
|
||||
public $footerSubline = '';
|
||||
public $footerUrl = '';
|
||||
public $footerIsActive = true;
|
||||
public $showFooterModal = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->loadAvailableVideos();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle verfügbaren Video-Dateien aus dem assets-Ordner
|
||||
*/
|
||||
public function loadAvailableVideos()
|
||||
{
|
||||
$assetsPath = public_path('_cabinet/assets');
|
||||
|
||||
if (File::exists($assetsPath)) {
|
||||
$files = File::files($assetsPath);
|
||||
$this->availableVideos = collect($files)
|
||||
->map(fn($file) => $file->getFilename())
|
||||
->filter(fn($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']))
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// VIDEO-VERWALTUNG
|
||||
// ========================================
|
||||
|
||||
public function openVideoModal($id = null)
|
||||
{
|
||||
if ($id) {
|
||||
$video = DisplayVideo::findOrFail($id);
|
||||
$this->videoId = $video->id;
|
||||
$this->videoFilename = $video->filename;
|
||||
$this->videoTitle = $video->title ?? '';
|
||||
$this->videoPosition = $video->position;
|
||||
$this->videoIsActive = $video->is_active;
|
||||
} else {
|
||||
$this->resetVideoForm();
|
||||
}
|
||||
$this->showVideoModal = true;
|
||||
}
|
||||
|
||||
public function saveVideo()
|
||||
{
|
||||
$this->validate([
|
||||
'videoFilename' => 'required|string',
|
||||
'videoPosition' => 'required|integer|min:0|max:100',
|
||||
], [
|
||||
'videoFilename.required' => 'Bitte wählen Sie ein Video aus.',
|
||||
'videoPosition.required' => 'Die Position ist erforderlich.',
|
||||
'videoPosition.min' => 'Die Position muss zwischen 0 und 100 liegen.',
|
||||
'videoPosition.max' => 'Die Position muss zwischen 0 und 100 liegen.',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'filename' => $this->videoFilename,
|
||||
'title' => $this->videoTitle,
|
||||
'position' => $this->videoPosition,
|
||||
'is_active' => $this->videoIsActive,
|
||||
];
|
||||
|
||||
if ($this->videoId) {
|
||||
$video = DisplayVideo::findOrFail($this->videoId);
|
||||
$video->update($data);
|
||||
session()->flash('success', 'Video erfolgreich aktualisiert!');
|
||||
} else {
|
||||
$maxSortOrder = DisplayVideo::max('sort_order') ?? 0;
|
||||
$data['sort_order'] = $maxSortOrder + 1;
|
||||
DisplayVideo::create($data);
|
||||
session()->flash('success', 'Video erfolgreich hinzugefügt!');
|
||||
}
|
||||
|
||||
$this->closeVideoModal();
|
||||
}
|
||||
|
||||
public function deleteVideo($id)
|
||||
{
|
||||
DisplayVideo::findOrFail($id)->delete();
|
||||
session()->flash('success', 'Video erfolgreich gelöscht!');
|
||||
}
|
||||
|
||||
public function toggleVideoStatus($id)
|
||||
{
|
||||
$video = DisplayVideo::findOrFail($id);
|
||||
$video->update(['is_active' => !$video->is_active]);
|
||||
}
|
||||
|
||||
public function moveVideo($id, $direction)
|
||||
{
|
||||
$video = DisplayVideo::findOrFail($id);
|
||||
$currentOrder = $video->sort_order;
|
||||
|
||||
if ($direction === 'up' && $currentOrder > 0) {
|
||||
$swapVideo = DisplayVideo::where('sort_order', $currentOrder - 1)->first();
|
||||
if ($swapVideo) {
|
||||
$video->update(['sort_order' => $currentOrder - 1]);
|
||||
$swapVideo->update(['sort_order' => $currentOrder]);
|
||||
}
|
||||
} elseif ($direction === 'down') {
|
||||
$swapVideo = DisplayVideo::where('sort_order', $currentOrder + 1)->first();
|
||||
if ($swapVideo) {
|
||||
$video->update(['sort_order' => $currentOrder + 1]);
|
||||
$swapVideo->update(['sort_order' => $currentOrder]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resetVideoForm()
|
||||
{
|
||||
$this->videoId = null;
|
||||
$this->videoFilename = '';
|
||||
$this->videoTitle = '';
|
||||
$this->videoPosition = 25;
|
||||
$this->videoIsActive = true;
|
||||
}
|
||||
|
||||
public function closeVideoModal()
|
||||
{
|
||||
$this->showVideoModal = false;
|
||||
$this->resetVideoForm();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// FOOTER-CONTENT-VERWALTUNG
|
||||
// ========================================
|
||||
|
||||
public function openFooterModal($id = null)
|
||||
{
|
||||
if ($id) {
|
||||
$footer = DisplayFooterContent::findOrFail($id);
|
||||
$this->footerId = $footer->id;
|
||||
$this->footerHeadline = $footer->headline;
|
||||
$this->footerSubline = $footer->subline;
|
||||
$this->footerUrl = $footer->url;
|
||||
$this->footerIsActive = $footer->is_active;
|
||||
} else {
|
||||
$this->resetFooterForm();
|
||||
}
|
||||
$this->showFooterModal = true;
|
||||
}
|
||||
|
||||
public function saveFooter()
|
||||
{
|
||||
$this->validate([
|
||||
'footerHeadline' => 'required|string|max:255',
|
||||
'footerSubline' => 'required|string|max:255',
|
||||
'footerUrl' => 'nullable|url',
|
||||
], [
|
||||
'footerHeadline.required' => 'Die Überschrift ist erforderlich.',
|
||||
'footerSubline.required' => 'Die Unterzeile ist erforderlich.',
|
||||
'footerUrl.url' => 'Bitte geben Sie eine gültige URL ein.',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'headline' => $this->footerHeadline,
|
||||
'subline' => $this->footerSubline,
|
||||
'url' => $this->footerUrl ?: null,
|
||||
'is_active' => $this->footerIsActive,
|
||||
];
|
||||
|
||||
if ($this->footerId) {
|
||||
$footer = DisplayFooterContent::findOrFail($this->footerId);
|
||||
$footer->update($data);
|
||||
|
||||
// Short-Code generieren falls URL vorhanden aber noch kein Short-Code
|
||||
if ($footer->url && !$footer->short_code) {
|
||||
$footer->short_code = DisplayFooterContent::generateUniqueShortCode();
|
||||
$footer->save();
|
||||
}
|
||||
|
||||
session()->flash('success', 'Footer-Inhalt erfolgreich aktualisiert!');
|
||||
} else {
|
||||
$maxSortOrder = DisplayFooterContent::max('sort_order') ?? 0;
|
||||
$data['sort_order'] = $maxSortOrder + 1;
|
||||
|
||||
$footer = DisplayFooterContent::create($data);
|
||||
|
||||
// Short-Code nur generieren wenn URL vorhanden
|
||||
if ($footer->url) {
|
||||
$footer->short_code = DisplayFooterContent::generateUniqueShortCode();
|
||||
$footer->save();
|
||||
session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! Short-Link: ' . $footer->short_url);
|
||||
} else {
|
||||
session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! (Ohne QR-Code)');
|
||||
}
|
||||
}
|
||||
|
||||
$this->closeFooterModal();
|
||||
}
|
||||
|
||||
public function regenerateShortCode($id)
|
||||
{
|
||||
$footer = DisplayFooterContent::findOrFail($id);
|
||||
$footer->short_code = DisplayFooterContent::generateUniqueShortCode();
|
||||
$footer->save();
|
||||
session()->flash('success', 'Short-Code wurde neu generiert!');
|
||||
}
|
||||
|
||||
public function resetClicks($id)
|
||||
{
|
||||
$footer = DisplayFooterContent::findOrFail($id);
|
||||
$footer->clicks = 0;
|
||||
$footer->save();
|
||||
session()->flash('success', 'Klick-Zähler wurde zurückgesetzt!');
|
||||
}
|
||||
|
||||
public function deleteFooter($id)
|
||||
{
|
||||
DisplayFooterContent::findOrFail($id)->delete();
|
||||
session()->flash('success', 'Footer-Inhalt erfolgreich gelöscht!');
|
||||
}
|
||||
|
||||
public function toggleFooterStatus($id)
|
||||
{
|
||||
$footer = DisplayFooterContent::findOrFail($id);
|
||||
$footer->update(['is_active' => !$footer->is_active]);
|
||||
}
|
||||
|
||||
public function moveFooter($id, $direction)
|
||||
{
|
||||
$footer = DisplayFooterContent::findOrFail($id);
|
||||
$currentOrder = $footer->sort_order;
|
||||
|
||||
if ($direction === 'up' && $currentOrder > 0) {
|
||||
$swapFooter = DisplayFooterContent::where('sort_order', $currentOrder - 1)->first();
|
||||
if ($swapFooter) {
|
||||
$footer->update(['sort_order' => $currentOrder - 1]);
|
||||
$swapFooter->update(['sort_order' => $currentOrder]);
|
||||
}
|
||||
} elseif ($direction === 'down') {
|
||||
$swapFooter = DisplayFooterContent::where('sort_order', $currentOrder + 1)->first();
|
||||
if ($swapFooter) {
|
||||
$footer->update(['sort_order' => $currentOrder + 1]);
|
||||
$swapFooter->update(['sort_order' => $currentOrder]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function resetFooterForm()
|
||||
{
|
||||
$this->footerId = null;
|
||||
$this->footerHeadline = '';
|
||||
$this->footerSubline = '';
|
||||
$this->footerUrl = '';
|
||||
$this->footerIsActive = true;
|
||||
}
|
||||
|
||||
public function closeFooterModal()
|
||||
{
|
||||
$this->showFooterModal = false;
|
||||
$this->resetFooterForm();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$videos = DisplayVideo::orderBy('sort_order')->get();
|
||||
$footerContents = DisplayFooterContent::orderBy('sort_order')->get();
|
||||
|
||||
return view('livewire.admin.c-m-s.cabinet-display', [
|
||||
'videos' => $videos,
|
||||
'footerContents' => $footerContents,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,18 @@ class Header extends Component
|
|||
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
|
||||
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
|
||||
$theme = config('app.theme', 'b2in');
|
||||
$this->content = config("content.themes.{$theme}.header", []);
|
||||
$this->content = config("content.themes.{$theme}.header", [
|
||||
'portal_login' => 'Portal Login',
|
||||
'navigation' => []
|
||||
]);
|
||||
|
||||
// Ensure required keys exist
|
||||
if (!isset($this->content['portal_login'])) {
|
||||
$this->content['portal_login'] = 'Portal Login';
|
||||
}
|
||||
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
|
||||
$this->content['navigation'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleMobileMenu()
|
||||
|
|
|
|||
89
app/Models/DisplayFooterContent.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DisplayFooterContent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'headline',
|
||||
'subline',
|
||||
'url',
|
||||
'short_code',
|
||||
'clicks',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'clicks' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Boot-Methode für automatische Short-Code-Generierung
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
// Short-Code nur generieren wenn URL vorhanden ist
|
||||
if ($model->url && empty($model->short_code)) {
|
||||
$model->short_code = self::generateUniqueShortCode();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einen eindeutigen Short-Code
|
||||
*/
|
||||
public static function generateUniqueShortCode(): string
|
||||
{
|
||||
do {
|
||||
// Generiere einen 6-stelligen alphanumerischen Code
|
||||
$code = strtolower(substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 6));
|
||||
} while (self::where('short_code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Short-URL zurück (dynamisch je nach Umgebung)
|
||||
*/
|
||||
public function getShortUrlAttribute(): string
|
||||
{
|
||||
$basePath = config('display.base_path', '_display-b2in-eu');
|
||||
$subdomain = config('display.subdomain');
|
||||
$domain = config('display.domain', 'b2in.eu');
|
||||
|
||||
// Wenn Subdomain konfiguriert ist, verwende diese (Live-Server)
|
||||
if ($subdomain) {
|
||||
// Nutze HTTPS für Produktion
|
||||
$protocol = config('app.env') === 'production' ? 'https' : 'http';
|
||||
return "{$protocol}://{$subdomain}.{$domain}/go.php?z={$this->short_code}";
|
||||
}
|
||||
|
||||
// Ansonsten verwende APP_URL + Base-Path (Testserver)
|
||||
$baseUrl = rtrim(config('app.url'), '/');
|
||||
return "{$baseUrl}/{$basePath}/go.php?z={$this->short_code}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Erhöht den Klick-Zähler
|
||||
*/
|
||||
public function incrementClicks(): void
|
||||
{
|
||||
$this->increment('clicks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope für aktive Footer-Inhalte in sortierter Reihenfolge
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
38
app/Models/DisplayVideo.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DisplayVideo extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'filename',
|
||||
'title',
|
||||
'position',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'position' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Scope für aktive Videos in sortierter Reihenfolge
|
||||
*/
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den vollständigen Pfad zum Video zurück
|
||||
*/
|
||||
public function getFullPathAttribute(): string
|
||||
{
|
||||
return "assets/{$this->filename}";
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class Partner extends Model
|
||||
{
|
||||
|
|
@ -13,9 +14,22 @@ class Partner extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'company_name',
|
||||
'display_name',
|
||||
'slug',
|
||||
'type',
|
||||
'brand',
|
||||
'hub_id',
|
||||
'parent_partner_id',
|
||||
'salutation',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'street',
|
||||
'house_number',
|
||||
'zip',
|
||||
'city',
|
||||
'country',
|
||||
'phone',
|
||||
'website',
|
||||
'description',
|
||||
'logo_url',
|
||||
'is_active',
|
||||
|
|
@ -50,6 +64,46 @@ class Partner extends Model
|
|||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Für Kunden: Zugeordneter Makler/Händler (Parent-Partner)
|
||||
*/
|
||||
public function parentPartner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Partner::class, 'parent_partner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Für Makler/Händler: Zugeordnete Kunden (Child-Partners)
|
||||
*/
|
||||
public function childPartners(): HasMany
|
||||
{
|
||||
return $this->hasMany(Partner::class, 'parent_partner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias für parentPartner (für bessere Lesbarkeit im Code)
|
||||
*/
|
||||
public function broker(): BelongsTo
|
||||
{
|
||||
return $this->parentPartner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias für childPartners (für bessere Lesbarkeit im Code)
|
||||
*/
|
||||
public function customers(): HasMany
|
||||
{
|
||||
return $this->childPartners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Partner (Manufacturer) kann eine Marke haben
|
||||
*/
|
||||
public function brand(): HasOne
|
||||
{
|
||||
return $this->hasOne(Brand::class);
|
||||
}
|
||||
|
||||
// TODO: Später die Beziehung zu Products hinzufügen
|
||||
// public function products(): HasMany
|
||||
// {
|
||||
|
|
|
|||
87
app/Models/RegistrationCode.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class RegistrationCode extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_AVAILABLE = 'available';
|
||||
public const STATUS_USED = 'used';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'role',
|
||||
'name',
|
||||
'status',
|
||||
'broker_partner_id',
|
||||
'partner_id',
|
||||
'assigned_to_code_id',
|
||||
'used_by_user_id',
|
||||
'used_at',
|
||||
'expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function broker(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Partner::class, 'broker_partner_id');
|
||||
}
|
||||
|
||||
public function partner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Partner::class);
|
||||
}
|
||||
|
||||
public function usedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'used_by_user_id');
|
||||
}
|
||||
|
||||
public function assignedToCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RegistrationCode::class, 'assigned_to_code_id');
|
||||
}
|
||||
|
||||
public function scopeAvailable($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_AVAILABLE);
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_AVAILABLE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->expires_at && now()->greaterThan($this->expires_at)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function markUsed(?User $user = null): void
|
||||
{
|
||||
$this->status = self::STATUS_USED;
|
||||
$this->used_at = now();
|
||||
if ($user) {
|
||||
$this->used_by_user_id = $user->id;
|
||||
if ($user->partner_id && !$this->partner_id) {
|
||||
$this->partner_id = $user->partner_id;
|
||||
}
|
||||
}
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Notifications\CustomResetPasswordNotification;
|
||||
use App\Notifications\CustomVerifyEmailNotification;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -11,11 +14,12 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
|||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class User extends Authenticatable
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasApiTokens, HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable;
|
||||
use HasApiTokens, HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable, SoftDeletes;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -25,6 +29,7 @@ class User extends Authenticatable
|
|||
protected $fillable = [
|
||||
'partner_id',
|
||||
'name',
|
||||
'display_name',
|
||||
'email',
|
||||
'password',
|
||||
'email_verified_at',
|
||||
|
|
@ -49,6 +54,7 @@ class User extends Authenticatable
|
|||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
|
@ -57,6 +63,15 @@ class User extends Authenticatable
|
|||
{
|
||||
return $this->belongsTo(Partner::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registration code used by this user
|
||||
*/
|
||||
public function registrationCode(): HasOne
|
||||
{
|
||||
return $this->hasOne(RegistrationCode::class, 'used_by_user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's initials
|
||||
*/
|
||||
|
|
@ -67,4 +82,56 @@ class User extends Authenticatable
|
|||
->map(fn(string $name) => Str::of($name)->substr(0, 1))
|
||||
->implode('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Anonymize user data (for users with dependencies)
|
||||
*/
|
||||
public function anonymize(): void
|
||||
{
|
||||
$this->update([
|
||||
'name' => 'Gelöschter Benutzer #' . $this->id,
|
||||
'display_name' => null,
|
||||
'email' => 'deleted_' . $this->id . '@anonymized.local',
|
||||
'password' => bcrypt(Str::random(64)),
|
||||
]);
|
||||
|
||||
// Entferne alle Rollen
|
||||
$this->syncRoles([]);
|
||||
|
||||
// Soft Delete
|
||||
$this->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has dependencies that require anonymization instead of deletion
|
||||
*/
|
||||
public function hasDependencies(): bool
|
||||
{
|
||||
// TODO: Später erweitern mit weiteren Verknüpfungen
|
||||
// Beispiele: Orders, Projects, Documents, etc.
|
||||
|
||||
// Aktuell: Prüfe ob Partner existiert
|
||||
return $this->partner_id !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the password reset notification.
|
||||
*
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function sendPasswordResetNotification($token): void
|
||||
{
|
||||
$this->notify(new CustomResetPasswordNotification($token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the email verification notification.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
$this->notify(new CustomVerifyEmailNotification);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
app/Notifications/CustomResetPasswordNotification.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class CustomResetPasswordNotification extends ResetPassword
|
||||
{
|
||||
/**
|
||||
* Get the reset password notification mail message for the given URL.
|
||||
*
|
||||
* @param string $url
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
protected function buildMailMessage($url)
|
||||
{
|
||||
$expiryMinutes = config('auth.passwords.' . config('auth.defaults.passwords') . '.expire');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('Passwort zurücksetzen - ' . config('app.name'))
|
||||
->greeting('Hallo!')
|
||||
->line('Sie erhalten diese E-Mail, weil wir eine Anfrage zum Zurücksetzen des Passworts für Ihr Konto erhalten haben.')
|
||||
->line('Klicken Sie auf den folgenden Button, um ein neues Passwort zu vergeben:')
|
||||
->action('Passwort zurücksetzen', $url)
|
||||
->line('Dieser Link ist **' . $expiryMinutes . ' Minuten** gültig.')
|
||||
->line('Falls Sie keine Passwort-Zurücksetzung angefordert haben, ist keine weitere Aktion erforderlich. Ihr Passwort bleibt unverändert.')
|
||||
->salutation('Mit freundlichen Grüßen, ' . PHP_EOL . 'Ihr **' . config('app.name') . '** Team');
|
||||
}
|
||||
}
|
||||
29
app/Notifications/CustomVerifyEmailNotification.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class CustomVerifyEmailNotification extends VerifyEmail
|
||||
{
|
||||
/**
|
||||
* Get the verify email notification mail message for the given URL.
|
||||
*
|
||||
* @param string $url
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
protected function buildMailMessage($url)
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('E-Mail-Adresse bestätigen - ' . config('app.name'))
|
||||
->greeting('Willkommen bei ' . config('app.name') . '!')
|
||||
->line('Vielen Dank für Ihre Registrierung!')
|
||||
->line('Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Button klicken:')
|
||||
->action('E-Mail-Adresse bestätigen', $url)
|
||||
->line('Dieser Bestätigungslink läuft in **60 Minuten** ab.')
|
||||
->line('Falls Sie kein Konto bei uns erstellt haben, ignorieren Sie bitte diese E-Mail.')
|
||||
->salutation('Mit freundlichen Grüßen, ' . PHP_EOL . 'Ihr **' . config('app.name') . '** Team');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
|
@ -55,23 +57,55 @@ class ThemeServiceProvider extends ServiceProvider
|
|||
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
|
||||
}
|
||||
|
||||
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
|
||||
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
|
||||
$assetUrl = $domainConfig['url'];
|
||||
|
||||
// Grundlegende Konfiguration im Anwendungskontext verfügbar machen
|
||||
config([
|
||||
'app.theme' => $domainConfig['theme'],
|
||||
'app.view_prefix' => $domainConfig['view_prefix'],
|
||||
'app.domain_name' => $domainConfig['domain_name'],
|
||||
'app.url' => $domainConfig['url'],
|
||||
'app.asset_url' => $assetUrl, // Dynamische Asset-URL für die aktuelle Domain
|
||||
]);
|
||||
|
||||
// URL-Generator für die aktuelle Domain konfigurieren
|
||||
// Dies ist wichtig, damit asset() und url() die richtige Domain verwenden
|
||||
URL::forceRootUrl($domainConfig['url']);
|
||||
URL::forceScheme(parse_url($domainConfig['url'], PHP_URL_SCHEME) ?: 'https');
|
||||
|
||||
// WICHTIG: Asset-Root direkt im UrlGenerator setzen
|
||||
// Der asset() Helper verwendet einen separaten Asset-Root
|
||||
/** @var UrlGenerator $urlGenerator */
|
||||
$urlGenerator = app('url');
|
||||
$urlGenerator->useAssetOrigin($assetUrl);
|
||||
|
||||
// Spezifischere Daten für die Views verfügbar machen
|
||||
View::share('theme', $domainConfig['theme']);
|
||||
View::share('viewPrefix', $domainConfig['view_prefix']);
|
||||
View::share('domainName', $domainConfig['domain_name']);
|
||||
View::share('domainConfig', $domainConfig);
|
||||
View::share('domainUrl', $domainConfig['url']);
|
||||
View::share('assetUrl', $assetUrl);
|
||||
|
||||
// Vite-Assets-Konfiguration für die aktuelle Domain
|
||||
if (! app()->runningInConsole() && isset($domainConfig['assets_dir'])) {
|
||||
if (! app()->runningInConsole()) {
|
||||
if (isset($domainConfig['assets_dir'])) {
|
||||
Vite::useBuildDirectory($domainConfig['assets_dir']);
|
||||
}
|
||||
|
||||
if (app()->environment('local')) {
|
||||
// Entwicklung: Vite Dev Server mit HMR
|
||||
$viteDevServerUrl = env('VITE_DEV_SERVER_URL', 'https://assets.b2in.test');
|
||||
Vite::useHotFile(public_path('hot'));
|
||||
config(['app.vite_dev_server_url' => $viteDevServerUrl]);
|
||||
View::share('viteDevServerUrl', $viteDevServerUrl);
|
||||
} else {
|
||||
// Produktion: Assets von der aktuellen Domain laden (kein CORS nötig)
|
||||
Vite::useScriptTagAttributes(['crossorigin' => false]);
|
||||
Vite::useStyleTagAttributes(['crossorigin' => false]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Domain-URL Konfiguration - muss ganz am Anfang ausgeführt werden
|
||||
// um sicherzustellen, dass url() und asset() die richtige Domain verwenden
|
||||
$middleware->prepend(\App\Http\Middleware\SetDomainUrl::class);
|
||||
|
||||
// Partner Setup-Zwang für eingeloggte User
|
||||
$middleware->alias([
|
||||
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\ThemeServiceProvider::class, // Muss früh geladen werden für URL-Konfiguration
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\ThemeServiceProvider::class,
|
||||
App\Providers\VoltServiceProvider::class,
|
||||
Barryvdh\Debugbar\ServiceProvider::class,
|
||||
FluxPro\FluxProServiceProvider::class,
|
||||
|
|
|
|||
|
|
@ -74,7 +74,9 @@
|
|||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
"dont-discover": [
|
||||
"orchestra/workbench"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
|
|
|
|||
1181
composer.lock
generated
|
|
@ -55,6 +55,19 @@ return [
|
|||
'url' => env('APP_URL', 'https://localhost'),
|
||||
'domain_name' => env('APP_DOMAIN_NAME', 'localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Asset URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used when generating asset URLs. By default, this is null
|
||||
| which means assets will be served from the same domain. For multi-domain
|
||||
| setups, this is dynamically overwritten by the ThemeServiceProvider.
|
||||
|
|
||||
*/
|
||||
|
||||
'asset_url' => env('ASSET_URL'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
|
|
|||
62
config/cors.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Diese Konfiguration ermöglicht dynamische CORS-Unterstützung für alle
|
||||
| konfigurierten Domains im Multi-Domain-Setup.
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie', '@vite/*', '@fs/*', 'build/*'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [
|
||||
// Dynamisch alle konfigurierten Domains erlauben
|
||||
env('DOMAIN_PORTAL_URL', 'https://portal.b2in.eu'),
|
||||
env('DOMAIN_B2IN_URL', 'https://b2in.eu'),
|
||||
env('DOMAIN_B2A_URL', 'https://bridges2america.online'),
|
||||
env('DOMAIN_STILEIGENTUM_URL', 'https://stileigentum.de'),
|
||||
env('DOMAIN_STYLE2OWN_URL', 'https://style2own.de'),
|
||||
env('VITE_DEV_SERVER_URL', 'https://assets.b2in.eu'),
|
||||
// HTTP-Varianten für Entwicklung
|
||||
'http://portal.b2in.test',
|
||||
'http://b2in.test',
|
||||
'http://b2a.test',
|
||||
'http://stileigentum.test',
|
||||
'http://style2own.test',
|
||||
'http://assets.b2in.test',
|
||||
// Localhost für lokale Entwicklung
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5174',
|
||||
// Live Domains
|
||||
'https://portal.b2in.eu',
|
||||
'https://api.b2in.eu',
|
||||
'https://b2in.eu',
|
||||
'https://cabinet.b2in.eu', // Digital Signage Display
|
||||
'https://bridges2america.online',
|
||||
'https://stileigentum.de',
|
||||
'https://style2own.de',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [
|
||||
// Erlaube alle Subdomains von .b2in.eu für Produktion
|
||||
'#^https?://.*\.b2in\.eu$#',
|
||||
// Erlaube alle Subdomains von .test und .local für Entwicklung
|
||||
'#^https?://.*\.b2in\.test$#',
|
||||
'#^https?://.*\.test$#',
|
||||
'#^https?://.*\.local$#',
|
||||
],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => true,
|
||||
];
|
||||
25
config/display.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Display Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Konfiguration für die Cabinet Display Digital Signage Seite
|
||||
|
|
||||
*/
|
||||
|
||||
// Basispfad für die Display-Seite (relativ zu public/)
|
||||
// Immer '_cabinet' - sowohl für Test als auch Live
|
||||
'base_path' => env('DISPLAY_BASE_PATH', '_cabinet'),
|
||||
|
||||
// Subdomain für den Live-Server (optional)
|
||||
// Wird verwendet, um die korrekte URL zu generieren
|
||||
'subdomain' => env('DISPLAY_SUBDOMAIN', null), // z.B. 'cabinet'
|
||||
|
||||
// Haupt-Domain
|
||||
'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'),
|
||||
|
||||
];
|
||||
|
|
@ -20,13 +20,25 @@ return [
|
|||
*/
|
||||
'protocol' => env('APP_PROTOCOL', 'https://'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Domain-Namen (ohne Protokoll)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'domain_portal' => env('DOMAIN_PORTAL', 'portal.b2in.test'),
|
||||
'domain_api' => env('DOMAIN_API', 'api.b2in.test'),
|
||||
'domain_b2in' => env('DOMAIN_B2IN', 'b2in.test'),
|
||||
'domain_b2a' => env('DOMAIN_B2A', 'b2a.test'),
|
||||
'domain_stileigentum' => env('DOMAIN_STILEIGENTUM', 'stileigentum.test'),
|
||||
'domain_style2own' => env('DOMAIN_STYLE2OWN', 'style2own.test'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Vollständige Domain-URLs (mit Protokoll)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'domain_portal_url' => env('DOMAIN_PORTAL_URL', 'https://portal.b2in.test'),
|
||||
'domain_api_url' => env('DOMAIN_API_URL', 'https://api.b2in.test'),
|
||||
'domain_b2in_url' => env('DOMAIN_B2IN_URL', 'https://b2in.test'),
|
||||
'domain_b2a_url' => env('DOMAIN_B2A_URL', 'https://b2a.test'),
|
||||
'domain_stileigentum_url' => env('DOMAIN_STILEIGENTUM_URL', 'https://stileigentum.test'),
|
||||
|
|
|
|||
|
|
@ -69,22 +69,9 @@ return [
|
|||
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
|
||||
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
|
||||
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'svg',
|
||||
'wav',
|
||||
'mp4',
|
||||
'mov',
|
||||
'avi',
|
||||
'wmv',
|
||||
'mp3',
|
||||
'm4a',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'mpga',
|
||||
'webp',
|
||||
'wma',
|
||||
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
|
||||
'mov', 'avi', 'wmv', 'mp3', 'm4a',
|
||||
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
|
||||
],
|
||||
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
|
||||
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
|
||||
|
|
@ -158,6 +145,19 @@ return [
|
|||
|
||||
'inject_morph_markers' => true,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Smart Wire Keys
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire uses loops and keys used within loops to generate smart keys that
|
||||
| are applied to nested components that don't have them. This makes using
|
||||
| nested components more reliable by ensuring that they all have keys.
|
||||
|
|
||||
*/
|
||||
|
||||
'smart_wire_keys' => false,
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Pagination Theme
|
||||
|
|
@ -170,4 +170,17 @@ return [
|
|||
*/
|
||||
|
||||
'pagination_theme' => 'tailwind',
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Release Token
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| This token is stored client-side and sent along with each request to check
|
||||
| a users session to see if a new release has invalidated it. If there is
|
||||
| a mismatch it will throw an error and prompt for a browser refresh.
|
||||
|
|
||||
*/
|
||||
|
||||
'release_token' => 'a',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ return new class extends Migration
|
|||
$table->string('slug')->unique();
|
||||
$table->string('logo_url')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('registration_codes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code')->unique();
|
||||
$table->string('role'); // broker|customer|retailer|manufacturer
|
||||
$table->string('status')->default('available'); // available|used|expired
|
||||
$table->foreignId('broker_partner_id')->nullable()->constrained('partners')->nullOnDelete();
|
||||
$table->foreignId('partner_id')->nullable()->constrained('partners')->nullOnDelete();
|
||||
$table->foreignId('used_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['role', 'status']);
|
||||
$table->index('broker_partner_id');
|
||||
$table->index('partner_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('registration_codes');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('registration_codes', function (Blueprint $table) {
|
||||
$table->string('name')->nullable()->after('role');
|
||||
$table->foreignId('assigned_to_code_id')->nullable()->after('broker_partner_id')->constrained('registration_codes')->nullOnDelete();
|
||||
|
||||
// Indizes für bessere Performance
|
||||
$table->index('name');
|
||||
$table->index('assigned_to_code_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('registration_codes', function (Blueprint $table) {
|
||||
$table->dropIndex(['name']);
|
||||
$table->dropIndex(['assigned_to_code_id']);
|
||||
$table->dropForeign(['assigned_to_code_id']);
|
||||
$table->dropColumn(['name', 'assigned_to_code_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->string('reg_prefix', 1)->nullable()->after('can_be_invited');
|
||||
$table->string('reg_description')->nullable()->after('reg_prefix');
|
||||
$table->integer('reg_start_number')->nullable()->after('reg_description');
|
||||
|
||||
// Index für Prefix-Suche
|
||||
$table->index('reg_prefix');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('roles', function (Blueprint $table) {
|
||||
$table->dropIndex(['reg_prefix']);
|
||||
$table->dropColumn(['reg_prefix', 'reg_description', 'reg_start_number']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Name für die Zuordnung von Kunden (für Makler, Händler, Hersteller)
|
||||
$table->string('display_name')->nullable()->after('name');
|
||||
$table->index('display_name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropIndex(['display_name']);
|
||||
$table->dropColumn('display_name');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Hinweis: Diese Spalte wurde später in parent_partner_id umbenannt.
|
||||
* Siehe Migration: 2025_12_17_123422_rename_broker_partner_id_to_parent_partner_id_in_partners_table
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
// Für Kunden: Verknüpfung zum zugeordneten Makler/Händler
|
||||
// Wird später zu parent_partner_id umbenannt
|
||||
$table->foreignId('broker_partner_id')
|
||||
->nullable()
|
||||
->after('hub_id')
|
||||
->constrained('partners')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->index('broker_partner_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
$table->dropIndex(['broker_partner_id']);
|
||||
$table->dropForeign(['broker_partner_id']);
|
||||
$table->dropColumn('broker_partner_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
// Umbenennen der Spalte für generische Verwendung
|
||||
// (sowohl für Makler/Estate-Agent als auch Händler/Retailer)
|
||||
$table->renameColumn('broker_partner_id', 'parent_partner_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
$table->renameColumn('parent_partner_id', 'broker_partner_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
// Brand/Theme speichern für Wiedererkennung im Portal
|
||||
// Werte: b2in, b2a, stileigentum, style2own
|
||||
$table->string('brand')->nullable()->after('type');
|
||||
|
||||
$table->index('brand');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
$table->dropIndex(['brand']);
|
||||
$table->dropColumn('brand');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
// Kontaktdaten und Adresse
|
||||
$table->string('salutation')->nullable()->after('brand'); // Herr, Frau, Divers
|
||||
$table->string('first_name')->nullable()->after('salutation');
|
||||
$table->string('last_name')->nullable()->after('first_name');
|
||||
$table->string('street')->nullable()->after('description');
|
||||
$table->string('house_number')->nullable()->after('street');
|
||||
$table->string('zip')->nullable()->after('house_number');
|
||||
$table->string('city')->nullable()->after('zip');
|
||||
$table->string('country')->default('Deutschland')->after('city');
|
||||
$table->string('phone')->nullable()->after('country');
|
||||
$table->string('website')->nullable()->after('phone');
|
||||
|
||||
// Display Name für Broker (kann vom Firmennamen abweichen)
|
||||
$table->string('display_name')->nullable()->after('company_name');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('partners', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'salutation',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'street',
|
||||
'house_number',
|
||||
'zip',
|
||||
'city',
|
||||
'country',
|
||||
'phone',
|
||||
'website',
|
||||
'display_name',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
$table->foreignId('partner_id')->after('id')->nullable()->constrained('partners')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('brands', function (Blueprint $table) {
|
||||
$table->dropForeign(['partner_id']);
|
||||
$table->dropColumn('partner_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('display_videos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('filename'); // Dateiname des Videos (z.B. herbst_2025.mp4)
|
||||
$table->string('title')->nullable(); // Optionaler Titel für bessere Verwaltung
|
||||
$table->integer('position')->default(25); // Position in % (0-100)
|
||||
$table->integer('sort_order')->default(0); // Reihenfolge der Wiedergabe
|
||||
$table->boolean('is_active')->default(true); // Aktiv/Inaktiv
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('display_videos');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('display_footer_contents', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('headline'); // Überschrift (z.B. "Beratung & Termin")
|
||||
$table->string('subline'); // Unterzeile (z.B. "Jetzt Termin vereinbaren.")
|
||||
$table->string('url'); // URL für den QR-Code
|
||||
$table->integer('sort_order')->default(0); // Reihenfolge der Anzeige
|
||||
$table->boolean('is_active')->default(true); // Aktiv/Inaktiv
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('display_footer_contents');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('display_footer_contents', function (Blueprint $table) {
|
||||
$table->string('short_code', 10)->unique()->nullable()->after('url');
|
||||
$table->unsignedInteger('clicks')->default(0)->after('short_code');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('display_footer_contents', function (Blueprint $table) {
|
||||
$table->dropColumn(['short_code', 'clicks']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('display_footer_contents', function (Blueprint $table) {
|
||||
$table->string('url')->nullable()->change();
|
||||
$table->string('short_code', 10)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('display_footer_contents', function (Blueprint $table) {
|
||||
$table->string('url')->nullable(false)->change();
|
||||
$table->string('short_code', 10)->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
68
database/seeders/DisplayContentSeeder.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\DisplayVideo;
|
||||
use App\Models\DisplayFooterContent;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DisplayContentSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Videos aus der bestehenden Konfiguration
|
||||
$videos = [
|
||||
['filename' => 'herbst_2025.mp4', 'title' => 'Herbst 2025', 'position' => 25, 'sort_order' => 0],
|
||||
['filename' => 'fruehjahr_2025.mp4', 'title' => 'Frühjahr 2025', 'position' => 10, 'sort_order' => 1],
|
||||
['filename' => 'fruehjahr_2024.mp4', 'title' => 'Frühjahr 2024', 'position' => 25, 'sort_order' => 2],
|
||||
['filename' => 'herbst_2024.mp4', 'title' => 'Herbst 2024', 'position' => 25, 'sort_order' => 3],
|
||||
];
|
||||
|
||||
foreach ($videos as $video) {
|
||||
DisplayVideo::create($video);
|
||||
}
|
||||
|
||||
// Footer-Inhalte aus der bestehenden Konfiguration
|
||||
$footerContents = [
|
||||
[
|
||||
'headline' => 'Beratung & Termin',
|
||||
'subline' => 'Jetzt Termin vereinbaren.',
|
||||
'url' => 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393',
|
||||
'sort_order' => 0,
|
||||
],
|
||||
[
|
||||
'headline' => 'Beratung vor Ort',
|
||||
'subline' => 'Einfach reinkommen.',
|
||||
'url' => 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393',
|
||||
'sort_order' => 1,
|
||||
],
|
||||
[
|
||||
'headline' => 'Pinterest',
|
||||
'subline' => 'Inspirationen entdecken.',
|
||||
'url' => 'https://de.pinterest.com/cabinet_AG/',
|
||||
'sort_order' => 2,
|
||||
],
|
||||
[
|
||||
'headline' => 'Instagram',
|
||||
'subline' => 'Tägliche Einblicke & Design.',
|
||||
'url' => 'https://www.instagram.com/cabinet_schranksysteme/',
|
||||
'sort_order' => 3,
|
||||
],
|
||||
[
|
||||
'headline' => 'Facebook',
|
||||
'subline' => 'News, Aktionen & Community.',
|
||||
'url' => 'https://de-de.facebook.com/cabinetschranksysteme/',
|
||||
'sort_order' => 4,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($footerContents as $content) {
|
||||
DisplayFooterContent::create($content);
|
||||
}
|
||||
|
||||
$this->command->info('Display-Inhalte erfolgreich eingefügt!');
|
||||
}
|
||||
}
|
||||
|
|
@ -65,7 +65,10 @@ class RoleSeeder extends Seeder
|
|||
'display_name' => 'Customer (Kunde)',
|
||||
'icon' => 'user',
|
||||
'color' => 'indigo',
|
||||
'can_be_invited' => true
|
||||
'can_be_invited' => true,
|
||||
'reg_prefix' => 'K',
|
||||
'reg_description' => 'Kundencodes werden Maklern oder Händlern zugeordnet',
|
||||
'reg_start_number' => 10000000,
|
||||
]);
|
||||
$customerRole->givePermissionTo([
|
||||
'view products',
|
||||
|
|
@ -79,7 +82,10 @@ class RoleSeeder extends Seeder
|
|||
'display_name' => 'Estate-Agent (Makler)',
|
||||
'icon' => 'home',
|
||||
'color' => 'lime',
|
||||
'can_be_invited' => true
|
||||
'can_be_invited' => true,
|
||||
'reg_prefix' => 'M',
|
||||
'reg_description' => 'Maklercodes für die Registrierung von Maklern',
|
||||
'reg_start_number' => 10000000,
|
||||
]);
|
||||
$estateAgentRole->givePermissionTo([
|
||||
'access dashboard',
|
||||
|
|
@ -94,7 +100,10 @@ class RoleSeeder extends Seeder
|
|||
'display_name' => 'Retailer (Händler)',
|
||||
'icon' => 'building-storefront',
|
||||
'color' => 'teal',
|
||||
'can_be_invited' => true
|
||||
'can_be_invited' => true,
|
||||
'reg_prefix' => 'H',
|
||||
'reg_description' => 'Händlercodes für die Registrierung von Händlern',
|
||||
'reg_start_number' => 10000000,
|
||||
]);
|
||||
$retailerRole->givePermissionTo([
|
||||
'access dashboard',
|
||||
|
|
@ -113,7 +122,10 @@ class RoleSeeder extends Seeder
|
|||
'display_name' => 'Manufacturer (Hersteller)',
|
||||
'icon' => 'wrench-screwdriver',
|
||||
'color' => 'orange',
|
||||
'can_be_invited' => true
|
||||
'can_be_invited' => true,
|
||||
'reg_prefix' => 'P',
|
||||
'reg_description' => 'Herstellercodes für die Registrierung von Herstellern',
|
||||
'reg_start_number' => 10000000,
|
||||
]);
|
||||
$manufacturerRole->givePermissionTo([
|
||||
'access dashboard',
|
||||
|
|
|
|||
319
dev/DISPLAY_CMS_README.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# Cabinet Display CMS - Dokumentation
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das Display CMS ermöglicht die zentrale Verwaltung der Inhalte für die Digital Signage Display-Seite im Cabinet Showroom Bielefeld.
|
||||
|
||||
**Einheitlicher Pfad:** Das System nutzt `public/_cabinet/` für Testserver und Live-Server.
|
||||
|
||||
## Zugriff
|
||||
|
||||
### Testserver
|
||||
- **Display:** `http://portal.b2in.test/_cabinet/`
|
||||
- **CMS:** `http://portal.b2in.test/admin/cms/cabinet`
|
||||
- **API:** `http://portal.b2in.test/api/display/config`
|
||||
|
||||
### Live-Server
|
||||
- **Display:** `https://cabinet.b2in.eu/` (Subdomain zeigt auf `/_cabinet/`)
|
||||
- **CMS:** `https://b2in.eu/admin/cms/cabinet`
|
||||
- **API:** `https://b2in.eu/api/display/config`
|
||||
|
||||
## Funktionen
|
||||
|
||||
### 1. Video-Verwaltung
|
||||
- ✅ Videos aus dem `public/_cabinet/assets/` Ordner verwalten
|
||||
- ✅ Reihenfolge per Pfeiltasten ändern
|
||||
- ✅ Video-Position (0-100%) für optimalen Bildausschnitt einstellen
|
||||
- ✅ Videos aktivieren/deaktivieren
|
||||
- ✅ Titel für bessere Übersicht vergeben
|
||||
|
||||
### 2. Footer-Content-Verwaltung mit Tracking
|
||||
- ✅ Überschrift, Unterzeile und **optional** Ziel-URL eingeben
|
||||
- ✅ **Automatische Short-Link-Generierung** (nur wenn URL angegeben)
|
||||
- ✅ **Echtzeit-Klick-Tracking** über Short-Links
|
||||
- ✅ **Klick-Statistiken** direkt im CMS
|
||||
- ✅ **Ohne URL:** Nur Text wird angezeigt, kein QR-Code
|
||||
- ✅ **Mit URL:** QR-Code mit Short-Link wird automatisch generiert
|
||||
- ✅ Reihenfolge ändern (werden alle 30 Sekunden rotiert)
|
||||
- ✅ Inhalte aktivieren/deaktivieren
|
||||
- ✅ Short-Code neu generieren
|
||||
- ✅ Klick-Zähler zurücksetzen
|
||||
|
||||
### 3. Short-Link-System
|
||||
|
||||
#### Wie funktioniert es?
|
||||
|
||||
**Mit URL (QR-Code wird angezeigt):**
|
||||
1. **Beim Erstellen** mit URL wird automatisch ein 6-stelliger Short-Code generiert (z.B. `c59kjb`)
|
||||
2. **Short-URL** wird automatisch erstellt:
|
||||
- Testserver: `http://portal.b2in.test/_cabinet/go.php?z=c59kjb`
|
||||
- Live-Server: `https://cabinet.b2in.eu/go.php?z=c59kjb`
|
||||
3. **QR-Code** auf dem Display zeigt die Short-URL
|
||||
4. **Bei Klick** wird der User zur Original-URL weitergeleitet
|
||||
5. **Klicks werden gezählt** und im CMS angezeigt
|
||||
|
||||
**Ohne URL (nur Text):**
|
||||
1. **Beim Erstellen** ohne URL wird kein Short-Code generiert
|
||||
2. **Nur Text** wird im Footer angezeigt (Überschrift + Unterzeile)
|
||||
3. **Kein QR-Code** wird angezeigt
|
||||
4. **Text-Bereich** nutzt die volle Breite des Footers
|
||||
|
||||
#### Vorteile
|
||||
|
||||
- ✅ Kürzere URLs für bessere QR-Codes
|
||||
- ✅ Automatisches Tracking aller Scans
|
||||
- ✅ Zentrale Verwaltung aller Links
|
||||
- ✅ Statistiken direkt im CMS
|
||||
- ✅ Links können geändert werden ohne QR-Code neu zu generieren
|
||||
- ✅ Funktioniert identisch auf Test- und Live-Server
|
||||
|
||||
## CMS-Interface
|
||||
|
||||
### Video-Playlist
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Video-Playlist [+] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ↑↓ [Aktiv] Herbst 2025 │
|
||||
│ 📁 herbst_2025.mp4 📍 Position: 25% │
|
||||
│ [👁] [✏] [🗑] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ↑↓ [Aktiv] Frühjahr 2025 │
|
||||
│ 📁 fruehjahr_2025.mp4 📍 Position: 10% │
|
||||
│ [👁] [✏] [🗑] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Footer-Inhalte
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Footer-Inhalte [+] │
|
||||
│ 📊 Gesamt-Klicks: 47 │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ↑↓ [Aktiv] Beratung & Termin [🔵 23 Klicks]│
|
||||
│ Jetzt Termin vereinbaren. │
|
||||
│ 🔗 c59kjb [📋 Short-Link] │
|
||||
│ 🔗 https://www.cabinet.de/bielefeld... │
|
||||
│ [👁] [⋮] │
|
||||
│ ├─ Bearbeiten │
|
||||
│ ├─ Short-Code neu generieren │
|
||||
│ ├─ Klicks zurücksetzen │
|
||||
│ └─ Löschen │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Ordnerstruktur
|
||||
|
||||
```
|
||||
public/_cabinet/
|
||||
├── index.html # Display-Seite (lädt dynamisch via API)
|
||||
├── go.php # Short-Link-Handler mit Tracking
|
||||
├── assets/ # Video-Dateien
|
||||
│ ├── herbst_2025.mp4
|
||||
│ ├── fruehjahr_2025.mp4
|
||||
│ └── ...
|
||||
└── clicks.log # Tracking-Log (wird automatisch erstellt)
|
||||
```
|
||||
|
||||
### Datenbank-Tabellen
|
||||
|
||||
#### `display_videos`
|
||||
```sql
|
||||
- id (PK)
|
||||
- filename -- Dateiname des Videos
|
||||
- title -- Optionaler Titel
|
||||
- position -- Vertikale Position (0-100%)
|
||||
- sort_order -- Reihenfolge der Wiedergabe
|
||||
- is_active -- Aktiv/Inaktiv
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
#### `display_footer_contents`
|
||||
```sql
|
||||
- id (PK)
|
||||
- headline -- Überschrift
|
||||
- subline -- Unterzeile
|
||||
- url -- Original-Ziel-URL
|
||||
- short_code -- 6-stelliger eindeutiger Code
|
||||
- clicks -- Anzahl der Klicks
|
||||
- sort_order -- Reihenfolge der Anzeige
|
||||
- is_active -- Aktiv/Inaktiv
|
||||
- created_at, updated_at
|
||||
```
|
||||
|
||||
### Konfiguration
|
||||
|
||||
**Datei:** `config/display.php`
|
||||
|
||||
```php
|
||||
'base_path' => env('DISPLAY_BASE_PATH', '_cabinet'),
|
||||
'subdomain' => env('DISPLAY_SUBDOMAIN', null),
|
||||
'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'),
|
||||
```
|
||||
|
||||
**Umgebungsvariablen (.env):**
|
||||
|
||||
```env
|
||||
# Testserver
|
||||
DISPLAY_BASE_PATH=_cabinet
|
||||
DISPLAY_SUBDOMAIN=
|
||||
DISPLAY_DOMAIN=b2in.test
|
||||
|
||||
# Live-Server
|
||||
DISPLAY_BASE_PATH=_cabinet
|
||||
DISPLAY_SUBDOMAIN=cabinet
|
||||
DISPLAY_DOMAIN=b2in.eu
|
||||
APP_ENV=production
|
||||
```
|
||||
|
||||
### API-Response-Format
|
||||
|
||||
**Endpunkt:** `/api/display/config`
|
||||
|
||||
```json
|
||||
{
|
||||
"videoPlaylist": [
|
||||
{
|
||||
"src": "assets/herbst_2025.mp4",
|
||||
"position": 25
|
||||
}
|
||||
],
|
||||
"footerContent": [
|
||||
{
|
||||
"headline": "Beratung & Termin",
|
||||
"subline": "Jetzt Termin vereinbaren.",
|
||||
"url": "https://cabinet.b2in.eu/go.php?z=c59kjb"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow-Beispiele
|
||||
|
||||
### Neuen Footer-Inhalt hinzufügen
|
||||
|
||||
1. Im CMS auf **"Inhalt hinzufügen"** klicken
|
||||
2. Formular ausfüllen:
|
||||
- Überschrift: "Beratung & Termin"
|
||||
- Unterzeile: "Jetzt Termin vereinbaren."
|
||||
- URL: `https://www.cabinet.de/bielefeld?utm_source=...`
|
||||
3. **Speichern**
|
||||
4. ✅ Short-Code wird automatisch generiert (z.B. `c59kjb`)
|
||||
5. ✅ Short-URL wird erstellt: `https://cabinet.b2in.eu/go.php?z=c59kjb`
|
||||
6. ✅ Display lädt neue Konfiguration (max. 5 Minuten)
|
||||
7. ✅ QR-Code zeigt automatisch den Short-Link
|
||||
8. ✅ Klicks werden gezählt und angezeigt
|
||||
|
||||
### Video hinzufügen
|
||||
|
||||
1. Video-Datei in `public/_cabinet/assets/` hochladen
|
||||
2. Im CMS auf **"Video hinzufügen"** klicken
|
||||
3. Video aus Dropdown auswählen
|
||||
4. Titel vergeben (optional)
|
||||
5. Position einstellen (0-100%)
|
||||
6. **Speichern**
|
||||
7. ✅ Video wird zur Playlist hinzugefügt
|
||||
8. ✅ Display spielt Video ab
|
||||
|
||||
### Klick-Statistiken ansehen
|
||||
|
||||
1. Im CMS unter **"Footer-Inhalte"** öffnen
|
||||
2. Bei jedem Inhalt wird die Anzahl der Klicks angezeigt
|
||||
3. **Gesamt-Klicks** werden oben summiert angezeigt
|
||||
4. Zum Zurücksetzen: **⋮ → Klicks zurücksetzen**
|
||||
|
||||
### Short-Code neu generieren
|
||||
|
||||
1. Bei gewünschtem Footer-Inhalt auf **⋮** klicken
|
||||
2. **"Short-Code neu generieren"** wählen
|
||||
3. ✅ Neuer Code wird erstellt
|
||||
4. ⚠️ Alter Code funktioniert nicht mehr
|
||||
5. ✅ QR-Code aktualisiert sich automatisch beim nächsten Reload
|
||||
|
||||
## Logging
|
||||
|
||||
### Datenbank-Tracking (Primär)
|
||||
- Alle Klicks werden in der Datenbank gezählt
|
||||
- Echtzeit-Statistiken im CMS
|
||||
- Auswertungen nach Inhalt möglich
|
||||
|
||||
### Datei-Logging (Backup)
|
||||
- Zusätzliches Log: `public/_cabinet/clicks.log`
|
||||
- Format: `YYYY-MM-DD HH:MM:SS - Code: c59kjb - Headline: ... - URL: ...`
|
||||
- Nützlich für detaillierte Analysen
|
||||
|
||||
**Log ansehen:**
|
||||
```bash
|
||||
tail -f public/_cabinet/clicks.log
|
||||
```
|
||||
|
||||
## Wartung & Backup
|
||||
|
||||
### Wichtige Daten für Backup
|
||||
- **Datenbank:** `display_videos`, `display_footer_contents`
|
||||
- **Video-Dateien:** `public/_cabinet/assets/`
|
||||
- **Tracking-Log:** `public/_cabinet/clicks.log`
|
||||
|
||||
### Videos hinzufügen
|
||||
```bash
|
||||
# Upload via SCP
|
||||
scp video.mp4 user@server:/var/www/html/public/_cabinet/assets/
|
||||
|
||||
# Oder via FTP/SFTP
|
||||
# Dann im CMS hinzufügen
|
||||
```
|
||||
|
||||
### Cache leeren
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan cache:clear
|
||||
php artisan route:clear
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Display zeigt "LADEN..."
|
||||
**Problem:** API nicht erreichbar
|
||||
**Lösung:** Browser-Console (F12) prüfen, API-URL testen
|
||||
|
||||
### Short-Links funktionieren nicht
|
||||
**Problem:** Datenbankverbindung oder Berechtigungen
|
||||
**Lösung:** `go.php` Berechtigungen prüfen, Logs prüfen
|
||||
|
||||
### Klicks werden nicht gezählt
|
||||
**Problem:** Datenbankupdate fehlgeschlagen
|
||||
**Lösung:** `clicks.log` prüfen, Datenbank-Logs prüfen
|
||||
|
||||
### Videos werden nicht abgespielt
|
||||
**Problem:** Falsches Format oder Pfad
|
||||
**Lösung:** Browser-Console prüfen, `.mp4` mit H.264 verwenden
|
||||
|
||||
## Performance-Tipps
|
||||
|
||||
### Video-Optimierung
|
||||
```bash
|
||||
# FFmpeg für kleinere Dateien
|
||||
ffmpeg -i input.mp4 -c:v libx264 -crf 23 \
|
||||
-preset medium -c:a aac -b:a 128k output.mp4
|
||||
```
|
||||
|
||||
### Auto-Reload
|
||||
- Display lädt alle 5 Minuten neue Konfiguration
|
||||
- Für sofortiges Update: Display-Seite neu laden
|
||||
|
||||
## Support & Dokumentation
|
||||
|
||||
- **Setup-Guide:** `DISPLAY_SETUP_LIVE.md`
|
||||
- **ENV-Variablen:** `ENV_VARIABLES_DISPLAY.md`
|
||||
- **Laravel-Logs:** `storage/logs/laravel.log`
|
||||
- **Tracking-Log:** `public/_cabinet/clicks.log`
|
||||
|
||||
Bei Problemen:
|
||||
1. Logs prüfen
|
||||
2. Browser-Console prüfen
|
||||
3. API manuell testen
|
||||
4. Cache leeren
|
||||
295
dev/DISPLAY_SETUP_LIVE.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Display Setup für Live-Server (cabinet.b2in.eu)
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das Display-System nutzt den Ordner `public/_cabinet/` für beide Umgebungen:
|
||||
|
||||
- **Testserver:** `portal.b2in.test/_cabinet/`
|
||||
- **Live-Server:** `cabinet.b2in.eu/` (Subdomain zeigt direkt auf `public/_cabinet/`)
|
||||
|
||||
## Setup-Schritte für Live-Server
|
||||
|
||||
### 1. Dateien vorbereiten
|
||||
|
||||
Die Dateien sind bereits im Ordner `public/_cabinet/` vorhanden:
|
||||
|
||||
```bash
|
||||
public/_cabinet/
|
||||
├── index.html # Display-Seite
|
||||
├── go.php # Short-Link-Handler
|
||||
├── assets/ # Video-Dateien
|
||||
│ ├── herbst_2025.mp4
|
||||
│ ├── fruehjahr_2025.mp4
|
||||
│ └── ...
|
||||
└── clicks.log # Tracking-Log (wird automatisch erstellt)
|
||||
```
|
||||
|
||||
### 2. Subdomain konfigurieren
|
||||
|
||||
Auf dem Live-Server die Subdomain `cabinet.b2in.eu` so einrichten, dass sie direkt auf `/public/_cabinet/` zeigt.
|
||||
|
||||
**Apache VirtualHost Beispiel:**
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName cabinet.b2in.eu
|
||||
DocumentRoot /var/www/html/public/_cabinet
|
||||
|
||||
<Directory /var/www/html/public/_cabinet>
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
# SSL Konfiguration
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/certs/certificate.crt
|
||||
SSLCertificateKeyFile /etc/ssl/private/private.key
|
||||
|
||||
# Optionale Sicherheits-Header
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
**Nginx Beispiel:**
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cabinet.b2in.eu;
|
||||
root /var/www/html/public/_cabinet;
|
||||
|
||||
index index.html index.php;
|
||||
|
||||
# SSL Konfiguration
|
||||
ssl_certificate /etc/ssl/certs/certificate.crt;
|
||||
ssl_certificate_key /etc/ssl/private/private.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/cabinet.b2in.eu-access.log;
|
||||
error_log /var/log/nginx/cabinet.b2in.eu-error.log;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Umgebungsvariablen setzen
|
||||
|
||||
In der `.env` Datei auf dem Live-Server folgende Variablen hinzufügen/ändern:
|
||||
|
||||
```env
|
||||
# Display Configuration für Live-Server
|
||||
DISPLAY_BASE_PATH=_cabinet
|
||||
DISPLAY_SUBDOMAIN=cabinet
|
||||
DISPLAY_DOMAIN=b2in.eu
|
||||
APP_ENV=production
|
||||
```
|
||||
|
||||
**Wichtig:** Nach dem Ändern der `.env`:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan cache:clear
|
||||
```
|
||||
|
||||
### 4. API-Zugriff sicherstellen
|
||||
|
||||
Die API kann über zwei Wege erreichbar sein:
|
||||
|
||||
**Option A: API über Hauptdomain (empfohlen)**
|
||||
- API-URL: `https://b2in.eu/api/display/config`
|
||||
- Display-Seite lädt von Hauptdomain
|
||||
|
||||
**Option B: API auch auf Subdomain**
|
||||
```bash
|
||||
cd /var/www/html/public/_cabinet
|
||||
ln -s ../api api
|
||||
```
|
||||
- API-URL: `https://cabinet.b2in.eu/api/display/config`
|
||||
|
||||
### 5. Videos hochladen
|
||||
|
||||
Videos in den Cabinet-Ordner kopieren:
|
||||
|
||||
```bash
|
||||
# Lokal zum Server
|
||||
scp -r videos/*.mp4 user@liveserver:/var/www/html/public/_cabinet/assets/
|
||||
|
||||
# Im CMS hinzufügen
|
||||
# Admin → CMS → Cabinet → Video hinzufügen
|
||||
```
|
||||
|
||||
### 6. Testen
|
||||
|
||||
1. **Display-Seite:** `https://cabinet.b2in.eu/`
|
||||
2. **API-Test:** `https://b2in.eu/api/display/config`
|
||||
3. **Short-Link-Test:** `https://cabinet.b2in.eu/go.php?z=c59kjb`
|
||||
4. **CMS:** Admin-Login → CMS → Cabinet
|
||||
5. **QR-Code scannen** und Tracking prüfen
|
||||
|
||||
## Zugriffswege
|
||||
|
||||
### Testserver
|
||||
- Display: `http://portal.b2in.test/_cabinet/`
|
||||
- Short-Link: `http://portal.b2in.test/_cabinet/go.php?z=abc123`
|
||||
- API: `http://portal.b2in.test/api/display/config`
|
||||
|
||||
### Live-Server
|
||||
- Display: `https://cabinet.b2in.eu/`
|
||||
- Short-Link: `https://cabinet.b2in.eu/go.php?z=abc123`
|
||||
- API: `https://b2in.eu/api/display/config`
|
||||
|
||||
## Wartung
|
||||
|
||||
### Videos aktualisieren
|
||||
|
||||
1. Neue Videos in `public/_cabinet/assets/` hochladen
|
||||
2. Im CMS unter "Video-Playlist" hinzufügen
|
||||
3. Display lädt automatisch neue Konfiguration (max. 5 Min.)
|
||||
|
||||
### Footer-Inhalte ändern
|
||||
|
||||
1. Im CMS unter "Footer-Inhalte" bearbeiten
|
||||
2. Short-Links bleiben gleich (nur Ziel-URL ändert sich)
|
||||
3. Klick-Statistiken bleiben erhalten
|
||||
|
||||
### Logs prüfen
|
||||
|
||||
```bash
|
||||
# Klick-Tracking
|
||||
tail -f /var/www/html/public/_cabinet/clicks.log
|
||||
|
||||
# Laravel-Logs
|
||||
tail -f /var/www/html/storage/logs/laravel.log
|
||||
|
||||
# Nginx-Logs
|
||||
tail -f /var/log/nginx/cabinet.b2in.eu-access.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Display zeigt "LADEN..." und lädt nicht
|
||||
|
||||
**Ursache:** API nicht erreichbar
|
||||
|
||||
**Lösung:**
|
||||
1. Browser-Console öffnen (F12)
|
||||
2. Netzwerk-Tab prüfen
|
||||
3. API-URL testen: `curl https://b2in.eu/api/display/config`
|
||||
4. CORS-Fehler? → API auch auf Subdomain verfügbar machen (siehe oben)
|
||||
|
||||
### Short-Links funktionieren nicht
|
||||
|
||||
**Ursache:** `go.php` nicht ausführbar oder Datenbankverbindung fehlerhaft
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
# Dateiberechtigungen prüfen
|
||||
ls -la /var/www/html/public/_cabinet/go.php
|
||||
|
||||
# Manuell testen
|
||||
curl -I https://cabinet.b2in.eu/go.php?z=c59kjb
|
||||
|
||||
# Logs prüfen
|
||||
tail -f /var/www/html/public/_cabinet/clicks.log
|
||||
```
|
||||
|
||||
### Videos werden nicht abgespielt
|
||||
|
||||
**Ursache:** Dateipfad falsch oder Video-Format nicht unterstützt
|
||||
|
||||
**Lösung:**
|
||||
1. Pfad prüfen: Videos müssen in `assets/` liegen
|
||||
2. Format prüfen: `.mp4` mit H.264-Codec empfohlen
|
||||
3. Browser-Console prüfen auf Fehler
|
||||
4. Im CMS prüfen: Sind Videos aktiv?
|
||||
|
||||
### API gibt leere Arrays zurück
|
||||
|
||||
**Ursache:** Keine aktiven Inhalte in der Datenbank
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
# Prüfen
|
||||
php artisan tinker
|
||||
>>> App\Models\DisplayVideo::count()
|
||||
>>> App\Models\DisplayFooterContent::count()
|
||||
|
||||
# Falls leer: Seeder ausführen
|
||||
php artisan db:seed --class=DisplayContentSeeder
|
||||
```
|
||||
|
||||
## Performance-Optimierung
|
||||
|
||||
### Video-Komprimierung
|
||||
|
||||
```bash
|
||||
# FFmpeg für optimale Dateigröße
|
||||
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium \
|
||||
-c:a aac -b:a 128k output.mp4
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
Nginx-Caching für statische Assets:
|
||||
|
||||
```nginx
|
||||
location ~* \.(mp4|jpg|jpeg|png|gif|ico|css|js)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### SSL/TLS
|
||||
- SSL-Zertifikat für `cabinet.b2in.eu` installieren
|
||||
- Automatische Erneuerung einrichten (z.B. Certbot)
|
||||
|
||||
### Zugriffsbeschränkung
|
||||
Falls gewünscht, Display-Seite per IP einschränken:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
allow 1.2.3.4; # Ihre IP
|
||||
deny all;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment-Checkliste
|
||||
|
||||
- [ ] `public/_cabinet/` Ordner vorhanden
|
||||
- [ ] Videos in `public/_cabinet/assets/` hochgeladen
|
||||
- [ ] Subdomain `cabinet.b2in.eu` konfiguriert
|
||||
- [ ] Subdomain zeigt auf `public/_cabinet/`
|
||||
- [ ] `.env` aktualisiert mit korrekten Werten
|
||||
- [ ] `php artisan config:clear` ausgeführt
|
||||
- [ ] SSL-Zertifikat installiert
|
||||
- [ ] Display-Seite getestet: `https://cabinet.b2in.eu/`
|
||||
- [ ] API getestet: `https://b2in.eu/api/display/config`
|
||||
- [ ] Short-Link getestet
|
||||
- [ ] QR-Code gescannt und Tracking geprüft
|
||||
- [ ] Logs eingerichtet und überwacht
|
||||
|
||||
## Support
|
||||
|
||||
Bei Problemen:
|
||||
|
||||
1. **Logs prüfen:** clicks.log, laravel.log, nginx/apache logs
|
||||
2. **Browser-Console:** F12 → Console & Network Tabs
|
||||
3. **API manuell testen:** `curl https://b2in.eu/api/display/config`
|
||||
4. **Datenbankverbindung testen:** `php artisan tinker`
|
||||
5. **Cache leeren:** `php artisan config:clear && php artisan cache:clear`
|
||||
90
dev/PARTNER-SETUP-WIZARD.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Partner-Einladung & Setup-Wizard
|
||||
|
||||
Kurze Notizen zur aktuellen Einladungskette und dem Setup-Wizard, plus offene To-dos.
|
||||
|
||||
## Ablauf Einladung
|
||||
- Admin lädt Partner über `resources/views/livewire/admin/partners/invite.blade.php` ein.
|
||||
- Gültigkeit wird im Formular gewählt (1–4 Wochen) und als `expires_at` in `PartnerInvitation` gespeichert.
|
||||
- Einladungslink: `route('partner.invitation.accept', ['token' => $token])`.
|
||||
- Zusätzliche Statusseiten: `/partner/invitation/expired/{token}` und `/partner/invitation/used/{token}`.
|
||||
|
||||
## Routen
|
||||
- `/partner/invitation/{token}` → Volt-Komponente `partner.invitation-accept`.
|
||||
- `/partner/invitation/expired/{token}` und `/partner/invitation/used/{token}` → statische Blade-Views.
|
||||
- `/partner/setup` → Volt-Komponente `partner.setup-wizard`, geschützt mit `auth`-Middleware.
|
||||
|
||||
## Setup-Wizard (partner.setup-wizard)
|
||||
- Basisdaten: Partner wird über eingeloggten User geladen (`Auth::user()->partner_id`); Rolle bestimmt Icon/Label.
|
||||
- Schritte je Partner-Typ:
|
||||
- Retailer: `Stammdaten` → `Liefergebiete` → `Fertig`.
|
||||
- Manufacturer: `Stammdaten` → `Marke anlegen` → `Fertig`.
|
||||
- Estate-Agent (und Default): `Profil`/`Stammdaten` → `Fertig`.
|
||||
- Schritt 1 (alle):
|
||||
- Felder: Firmenname (pflicht), Logo-Upload (optional, 2 MB, jpg/png/webp), Kurzbeschreibung, Straße/PLZ/Stadt (pflicht), Website (optional, URL).
|
||||
- Logo wird bei Upload in `public/partner-logos` gespeichert; Partner wird mit Name/Beschreibung/Logo aktualisiert.
|
||||
- TODO im Code: Adresse noch separat speichern (Adresse aktuell nicht persistiert).
|
||||
- Schritt 2 Retailer:
|
||||
- Felder: Lieferradius km, Montageradius km (1–500, pflicht).
|
||||
- Speichert `delivery_radius_km` und `assembly_radius_km` im Partner; danach Abschluss.
|
||||
- Schritt 2 Manufacturer:
|
||||
- Felder: Markenname (pflicht), Markenlogo (optional, 2 MB), Markenbeschreibung.
|
||||
- Speichert neue `Brand` mit Slug aus Namen; Logo in `public/brand-logos`.
|
||||
- Abschluss:
|
||||
- Partner wird `is_active=true`, `setup_completed=true`, `setup_completed_at=now()`.
|
||||
- Dashboard-Button; für Nicht-Makler CTA „Erstes Produkt anlegen“ (derzeit ohne Aktion).
|
||||
|
||||
## Offene To-dos / Beobachtungen
|
||||
- Adresse persistieren (separates Modell/JSON), wird aktuell nur validiert.
|
||||
- Website-Feld wird vorausgefüllt? momentan leer trotz vorhandener Partner-Daten.
|
||||
- CTA „Erstes Produkt anlegen“ ohne Link/Action; Zielseite definieren.
|
||||
- Hersteller: Slug-Kollision/Mehrmarken-Handling prüfen (derzeit einfacher `Str::slug` mit create).
|
||||
- Estate-Agent: Gibt es spezifische Felder? Wizard zeigt nur Schritt 1 → Fertig; klären ob ausreichend.
|
||||
|
||||
## QR-/Registrierungscode-Flow (neu)
|
||||
- Öffentliche Landing unter `/reg/{role}` auf `b2in.test`, Rollen-Codes:
|
||||
- `c` = Kunde, `e` = Makler, `m` = Hersteller, `r` = Händler.
|
||||
- Gleiches Blade-Template, Inhalte/CTA dynamisch nach Role.
|
||||
- QR-Codes enthalten nur den Link (`/reg/{role}`), kein Code-Query. Der Code steht z.B. auf der Visitenkarte und wird manuell eingegeben.
|
||||
- Registrierungscode ist zwingend, wird einmalig eingelöst und danach als „verbraucht“ markiert.
|
||||
- Validierter Code schaltet erst das passende Registrierungsformular frei (bestehender Flow wiederverwenden, kein zweites System).
|
||||
- Rollen-spezifische Registrierung:
|
||||
- Makler (`/reg/e`): braucht gültige Makler-Nummer (einzigartig, vorab im System hinterlegt).
|
||||
- Kunde (`/reg/c`): braucht gültige Kundennummer, die einem Makler zugeordnet ist; jeder Kunde muss einem Makler zugeordnet werden (Provisions-Tracking).
|
||||
- Händler (`/reg/r`) und Hersteller (`/reg/m`): eigener Flow, ebenfalls mit einmaligem Code; Hersteller haben keine Kunden, Händler pflegen Sortiment und können Kundenzugänge verschaffen.
|
||||
|
||||
### Vorschlag Datenmodell Codes (anzulegen)
|
||||
- Tabelle `registration_codes` (oder separate `broker_codes` + `customer_codes`):
|
||||
- `code` (unique), `role` (`broker|customer|retailer|manufacturer`), `status` (`available|used|expired`), `partner_id` (optional, z.B. für Händler/Hersteller), `broker_id` (für Kundennummern-Block), `used_by_user_id` (nullable), `used_at`, `expires_at` (optional), `metadata` (json für Notizen/Quelle).
|
||||
- Für Maklernummern: `role=broker`, `status=available`, keine `broker_id`.
|
||||
- Für Kundennummern: `role=customer`, `broker_id` Pflicht, damit Zuordnung bei Registrierung klar ist.
|
||||
- Einlösung:
|
||||
- Lookup `code` + `role` passend zur Route.
|
||||
- Wenn `status != available` → Fehlermeldung.
|
||||
- Bei Erfolg: markiere `used`, setze `used_at`, `used_by_user_id` (nach erfolgreichem User-Create), ggf. `partner_id` koppeln.
|
||||
|
||||
### Nummernformat / Vergabe
|
||||
- Format: 8-stellige Nummern mit optional vorangestelltem Buchstaben (Alias). Beispiele: `00100001`, `M00100001`, `K01102513`.
|
||||
- Buchstabenpräfixe nur Alias (Marketing), intern immer über IDs verknüpfen; Code bleibt als Alias gespeichert.
|
||||
- Nummern sind fortlaufend; Kundennummern werden blockweise einem Makler zugewiesen (z.B. `K01102510`–`K01102560` = 50 Stück).
|
||||
- Ein Code kann nur einmal eingelöst werden und wird danach als „used“ markiert.
|
||||
|
||||
### Umsetzung QR-/Reg-Landing (Stand)
|
||||
- Neue Route `/reg/{role}` (Volt) mit Rollen-Slugs: `c` Kunde, `e` Makler, `m` Hersteller, `r` Händler.
|
||||
- Gemeinsames Template `resources/views/livewire/reg/landing.blade.php`, Inhalte dynamisch nach Rolle.
|
||||
- Eingabe und Prüfung des Registrierungscodes; Normalisierung (Leerzeichen/Bindestriche entfernt, uppercase).
|
||||
- Valider Code (Status `available`, Rolle passend) wird in Session abgelegt (`registration_code_id`, `registration_role`) und leitet zu `reg/create-account` weiter. Verbrauch/Markierung erfolgt beim Account-Create bzw. Wizard-Abschluss.
|
||||
- Neues Modell `App\Models\RegistrationCode` + Migration `registration_codes` mit Feldern für Status, Rolle, Broker-/Partner-Referenzen, Used-Infos.
|
||||
|
||||
### Account-Create nach Code (neu)
|
||||
- Route `/reg/create-account` (Volt) nutzt `reg.create-account`-Komponente.
|
||||
- Felder: Vorname, Nachname, E-Mail, Passwort + Bestätigung, AGB-Checkbox.
|
||||
- Prüft Session-Code; mappt Rollen auf Partner-Typ (`broker→Estate-Agent`, `retailer→Retailer`, `manufacturer→Manufacturer`; Customer aktuell nicht unterstützt).
|
||||
- Erstellt Partner (minimal), User, weist Rolle zu, markiert Code als verwendet, loggt ein, leitet in den Setup-Wizard.
|
||||
|
||||
### Landing-/Flow-Idee
|
||||
- Route/Controller/Volt für `/reg/{role}`:
|
||||
- Zeigt Rolle-spezifischen Text und Eingabefeld „Registrierungscode“.
|
||||
- Nach erfolgreicher Code-Validierung: Formular mit bestehenden Feldern je Rolle anzeigen (Reuse: bestehende Registrierung/Setup-Logik).
|
||||
- Kopplung mit bestehendem Einladungssystem:
|
||||
- Kein zweites System; nach Code-Check wird derselbe Registrierungspfad genutzt (User/Partner anlegen, Setup-Wizard etc.).
|
||||
- To-do: Admin-Oberfläche bereitstellen, um Maklernummern und Kundennummern-Blöcke zu erzeugen und einem Makler zuzuweisen.
|
||||
60
dev/b2in-layout-v10/ENV_VARIABLES_DISPLAY.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Display Umgebungsvariablen
|
||||
|
||||
Fügen Sie diese Variablen zu Ihrer `.env` Datei hinzu:
|
||||
|
||||
## Testserver (portal.b2in)
|
||||
|
||||
```env
|
||||
# Display Configuration - Testserver
|
||||
DISPLAY_BASE_PATH=_cabinet
|
||||
DISPLAY_SUBDOMAIN=
|
||||
DISPLAY_DOMAIN=b2in.test
|
||||
```
|
||||
|
||||
**Resultierende URLs:**
|
||||
- Display-Seite: `http://portal.b2in.test/_cabinet/`
|
||||
- Short-URLs: `http://portal.b2in.test/_cabinet/go.php?z=abc123`
|
||||
- API: `http://portal.b2in.test/api/display/config`
|
||||
|
||||
## Live-Server (cabinet.b2in.eu)
|
||||
|
||||
```env
|
||||
# Display Configuration - Live-Server
|
||||
DISPLAY_BASE_PATH=_cabinet
|
||||
DISPLAY_SUBDOMAIN=cabinet
|
||||
DISPLAY_DOMAIN=b2in.eu
|
||||
APP_ENV=production
|
||||
```
|
||||
|
||||
**Resultierende URLs:**
|
||||
- Display-Seite: `https://cabinet.b2in.eu/`
|
||||
- Short-URLs: `https://cabinet.b2in.eu/go.php?z=abc123`
|
||||
- API: `https://cabinet.b2in.eu/api/display/config` oder `https://b2in.eu/api/display/config`
|
||||
|
||||
## Vereinfachtes System
|
||||
|
||||
Das System nutzt jetzt nur noch den Ordner `public/_cabinet/` für beide Umgebungen:
|
||||
|
||||
- **Testserver:** Zugriff über `/_cabinet/` Pfad
|
||||
- **Live-Server:** Subdomain zeigt direkt auf `/_cabinet/`
|
||||
|
||||
Beide Umgebungen teilen sich denselben Code-Pfad, nur die URLs unterscheiden sich.
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
1. **Mit DISPLAY_SUBDOMAIN gesetzt (Live):**
|
||||
- Generiert absolute URLs: `https://cabinet.b2in.eu/go.php?z=...`
|
||||
- Subdomain zeigt auf `public/_cabinet/`
|
||||
|
||||
2. **Ohne DISPLAY_SUBDOMAIN (Test):**
|
||||
- Generiert URLs mit Pfad: `http://portal.b2in.test/_cabinet/go.php?z=...`
|
||||
- Zugriff über Hauptdomain mit Unterordner
|
||||
|
||||
## Nach Änderungen
|
||||
|
||||
Cache immer leeren:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan cache:clear
|
||||
```
|
||||
676
dev/entwicklung.md
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
# B2IN - Projekt-Entwicklungsstand
|
||||
|
||||
## Projektübersicht
|
||||
|
||||
Das B2IN-Projekt ist eine umfassende Multi-Domain-Laravel-Anwendung, die als zentrale Plattform für Partner-Management, Produkt-Verwaltung und Digital Signage dient. Die Anwendung basiert auf Laravel 12, Livewire 3 und Flux UI und bietet ein modernes, rollenbasiertes System für verschiedene Geschäftspartner.
|
||||
---
|
||||
|
||||
## 🎯 Wichtige ToDos
|
||||
|
||||
- **System Mails**: Kommen teilweise nicht an (google etc) Probleme mit united domains ...
|
||||
In der Zukunft benötigen wir einen response bounce etc.
|
||||
" host gmail-smtp-in.l.google.com[64.233.184.27]
|
||||
said: 550-5.7.26 Your email has been blocked because the sender is
|
||||
unauthenticated. "
|
||||
---
|
||||
|
||||
## 🎯 Kern-Technologien
|
||||
|
||||
- **Framework**: Laravel 12 mit PHP 8.2+
|
||||
- **Frontend**: Livewire 3 mit Volt (Single-File Components)
|
||||
- **UI-Framework**: Flux UI (Pro-Version) mit Tailwind CSS
|
||||
- **Authentifizierung**: Laravel Fortify mit Sanctum
|
||||
- **Berechtigungen**: Spatie Laravel-Permission
|
||||
- **Icons**: Heroicons (Blade-Integration)
|
||||
- **Entwicklungsumgebung**: Laravel Sail (Docker)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Entwickelte Module & Funktionen
|
||||
|
||||
### 1. Multi-Domain-System
|
||||
|
||||
**Status**: ✅ Vollständig implementiert
|
||||
|
||||
Das System unterstützt mehrere Domains mit individuellen Themes und Konfigurationen:
|
||||
|
||||
- **Admin-Portal** (`portal.b2in.test`) - Verwaltungsbereich für alle Partner-Typen
|
||||
- **Haupt-Website** (`b2in.test`) - Unternehmenswebsite
|
||||
- **Landing-Pages** - Individuelle Landing-Pages mit eigenem Branding
|
||||
- **Domain-spezifische Assets** - Separate CSS/JS-Builds pro Domain
|
||||
- **Theme-Switching** - Dynamisches Theme-System basierend auf Domain oder GET-Parameter
|
||||
- **Entwicklungsmodus** - Domain-Simulation für lokale Entwicklung ohne Hosts-Änderung
|
||||
|
||||
**Technische Details**:
|
||||
- Domain-Erkennung über `ThemeServiceProvider`
|
||||
- Separate Vite-Konfigurationen für Admin und Web
|
||||
- Dynamische Favicon-Generierung
|
||||
- Theme-basiertes Routing
|
||||
|
||||
---
|
||||
|
||||
### 2. Benutzer- & Rollen-Management
|
||||
|
||||
**Status**: ✅ Vollständig implementiert
|
||||
|
||||
Umfassendes Rollen- und Berechtigungssystem mit folgenden Rollen:
|
||||
|
||||
#### Definierte Rollen:
|
||||
- **Super Admin** - Vollzugriff auf alle Funktionen
|
||||
- **Admin** - Verwaltung von Partnern und System
|
||||
- **Retailer (Händler)** - Produktverwaltung, Kundenbetreuung, Liefergebiete
|
||||
- **Manufacturer (Hersteller)** - Marken- und Produktverwaltung
|
||||
- **Broker (Makler)** - Lead-Generierung, Provisionen, Kundenanbindung
|
||||
- **Customer (Endkunde)** - Produktbestellung, Dashboard-Zugriff
|
||||
|
||||
#### Berechtigungen:
|
||||
- Hub-Management (Anzeigen, Erstellen, Bearbeiten, Löschen)
|
||||
- Partner-Management (Anzeigen, Erstellen, Bearbeiten, Löschen, Provisionen)
|
||||
- Produkt-Management (Anzeigen, Erstellen, Bearbeiten, Löschen, Miet-Optionen)
|
||||
- Bestellungs-Management (Anzeigen, Verwalten)
|
||||
- User-Management (Anzeigen, Verwalten, Rollen zuweisen)
|
||||
- Dashboard-Zugriff
|
||||
- Bestellungen aufgeben
|
||||
|
||||
#### Spezielle Features:
|
||||
- **Impersonation** - Admins können sich als andere Benutzer einloggen
|
||||
- **Soft-Delete** - Benutzer werden anonymisiert statt gelöscht (bei Abhängigkeiten)
|
||||
- **Rollenbasierte Navigation** - Dynamische Sidebar basierend auf Berechtigungen
|
||||
- **Display-Namen** - Separate Anzeigenamen für Benutzer
|
||||
|
||||
---
|
||||
|
||||
### 3. Partner-Management-System
|
||||
|
||||
**Status**: ✅ Vollständig implementiert
|
||||
|
||||
Komplettes System zur Verwaltung von Geschäftspartnern:
|
||||
|
||||
#### Partner-Typen:
|
||||
- **Retailer** - Händler mit Liefer- und Montageradius
|
||||
- **Manufacturer** - Hersteller mit Markenverwaltung
|
||||
- **Broker** - Makler mit Provisionsmodell
|
||||
- **Customer** - Endkunden
|
||||
|
||||
#### Funktionen:
|
||||
- **Partner-Einladungssystem**:
|
||||
- E-Mail-Einladungen mit Token-basiertem Zugang
|
||||
- Konfigurierbare Gültigkeit (1-4 Wochen)
|
||||
- Status-Tracking (Offen, Verwendet, Abgelaufen)
|
||||
- Rollenbasierte Einladungen
|
||||
- Spezielle Status-Seiten für abgelaufene/verwendete Einladungen
|
||||
|
||||
- **Partner-Setup-Wizard**:
|
||||
- Mehrstufiger Onboarding-Prozess
|
||||
- Rollenspezifische Schritte und Felder
|
||||
- Logo-Upload (Partner & Marke)
|
||||
- Adressverwaltung (Straße, PLZ, Stadt, Land)
|
||||
- Retailer: Liefer- und Montageradius (1-500 km)
|
||||
- Manufacturer: Markenerstellung mit Logo
|
||||
- Setup-Status-Tracking (`setup_completed`, `setup_completed_at`)
|
||||
- Middleware-Schutz für unvollständige Setups
|
||||
|
||||
- **Partner-Hierarchie **:
|
||||
- Parent-Child-Beziehungen (Broker → Kunden)
|
||||
- Hub-Zuordnung für regionale Partner 🔄 Grundarchitektur vorhanden
|
||||
- Provisionsmodelle (Festbetrag oder Prozentsatz) 🔄 Grundarchitektur vorhanden
|
||||
|
||||
- **Registrierungs-Codes**:
|
||||
- QR-Code-basierte Registrierung
|
||||
- Rollenspezifische Landing-Pages (`/reg/{role}`)
|
||||
- Code-Tracking (Verwendet von, Verwendungsdatum)
|
||||
- Code-Namen für bessere Organisation
|
||||
- Zuordnung zu übergeordnetem Partner
|
||||
|
||||
---
|
||||
|
||||
### 4. Hub-Management
|
||||
|
||||
**Status**: 🔄 Grundarchitektur vorhanden
|
||||
|
||||
Regionale Hub-Verwaltung:
|
||||
|
||||
#### Funktionen:
|
||||
- Hub-Erstellung und -Verwaltung
|
||||
- Standortverwaltung (HubLocations)
|
||||
- Partner-Zuordnung zu Hubs
|
||||
- Hub-spezifische Dashboard-Ansichten
|
||||
- Indexseite mit Hub-Übersicht
|
||||
- Erstellen/Bearbeiten von Hubs
|
||||
|
||||
#### Datenstruktur:
|
||||
- Hub-Stammdaten
|
||||
- Mehrere Standorte pro Hub
|
||||
- Partner-Beziehungen
|
||||
|
||||
---
|
||||
|
||||
### 5. Produkt-Management-System
|
||||
|
||||
**Status**: 🔄 Grundarchitektur vorhanden
|
||||
|
||||
Umfassendes Produktverwaltungssystem:
|
||||
|
||||
#### Features:
|
||||
- **Produktliste** (`/products`):
|
||||
- Suchfunktion (Name, Artikelnummer)
|
||||
- Filter nach Status (Aktiv, Entwurf, Inaktiv)
|
||||
- Filter nach Kategorie
|
||||
- Aktive Filter-Anzeige
|
||||
- Responsive Tabellen-Ansicht
|
||||
|
||||
- **Produkt-Erstellung** (`/products/create`):
|
||||
- Umfangreiches Formular für Produktdaten
|
||||
- Mehrstufige Eingabe
|
||||
- Bild-Upload
|
||||
- Varianten-Verwaltung
|
||||
|
||||
#### Datenmodelle:
|
||||
- **Products** - Hauptprodukte
|
||||
- **ProductVariants** - Produktvarianten mit Attributen
|
||||
- **Attributes** - Produkt-Attribute (z.B. Farbe, Größe)
|
||||
- **AttributeValues** - Attribut-Werte
|
||||
- **Categories** - Produktkategorien (mehrfach zuweisbar)
|
||||
- **Tags** - Produkt-Tags
|
||||
- **Brands** - Marken (Partner-zuweisbar)
|
||||
- **Collections** - Produkt-Kollektionen
|
||||
- **TaxRates** - Steuersätze
|
||||
- **ShippingClasses** - Versandklassen
|
||||
- **ProductLogistics** - Logistik-Informationen
|
||||
|
||||
#### Beziehungen:
|
||||
- Produkte → Kategorien (N:N)
|
||||
- Produkte → Tags (N:N)
|
||||
- Produkte → Verwandte Produkte (N:N)
|
||||
- Produkte → Marken
|
||||
- Varianten → Attribute & Werte
|
||||
|
||||
---
|
||||
|
||||
### 6. Dashboard-System
|
||||
|
||||
**Status**: 🔄 Grundarchitektur vorhanden
|
||||
|
||||
Rollenbasiertes Dashboard mit individuellen KPIs:
|
||||
|
||||
#### Admin-Dashboard:
|
||||
- Aktive & geplante Hubs
|
||||
- Partner-Wachstum (Gesamt, Dieser Monat)
|
||||
- Plattform-Umsatz (Platzhalter)
|
||||
- System-Status
|
||||
- Ausstehende Einladungen
|
||||
- Gesamt-Kunden
|
||||
|
||||
#### Retailer-Dashboard:
|
||||
- Offene Bestellungen
|
||||
- Monatlicher Umsatz
|
||||
- Produkt-Aufrufe
|
||||
- Lagerbestands-Warnungen
|
||||
- Meine Kunden
|
||||
|
||||
#### Manufacturer-Dashboard:
|
||||
- Marken-Reichweite
|
||||
- Aktive Produkte
|
||||
- Entwurfs-Produkte
|
||||
- Gesamt-Aufrufe
|
||||
|
||||
#### Broker-Dashboard:
|
||||
- Gesamt-Provision
|
||||
- Ausstehende Auszahlung
|
||||
- Generierte Leads
|
||||
- Empfehlungs-Link
|
||||
- Broker-Kunden
|
||||
|
||||
#### Customer-Dashboard:
|
||||
- Marken-spezifisches Branding
|
||||
- Top-Angebote
|
||||
- Broker-Information
|
||||
- Individuelles Design basierend auf zugewiesenem Broker
|
||||
|
||||
---
|
||||
|
||||
### 7. Digital Signage / Display-CMS
|
||||
|
||||
**Status**: ✅ Vollständig implementiert
|
||||
|
||||
Professionelles CMS-System für Digital Signage im Cabinet Showroom Bielefeld:
|
||||
|
||||
#### Video-Verwaltung:
|
||||
- Verwaltung von Video-Playlists
|
||||
- Reihenfolge-Verwaltung
|
||||
- Video-Position einstellen (0-100% für optimalen Ausschnitt)
|
||||
- Videos aktivieren/deaktivieren
|
||||
- Titel für bessere Organisation
|
||||
|
||||
#### Footer-Content mit Tracking:
|
||||
- Überschrift und Unterzeile
|
||||
- Optionale Ziel-URL
|
||||
- **Automatische Short-Link-Generierung** (6-stellige Codes)
|
||||
- **QR-Code-Generierung** für Short-Links
|
||||
- **Echtzeit-Klick-Tracking**
|
||||
- Klick-Statistiken im CMS
|
||||
- Reihenfolge-Verwaltung (30-Sekunden-Rotation)
|
||||
- Short-Code neu generieren
|
||||
- Klick-Zähler zurücksetzen
|
||||
- Flexibles Layout (mit/ohne URL)
|
||||
|
||||
#### Short-Link-System:
|
||||
- Automatische Code-Generierung (z.B. `c59kjb`)
|
||||
- Short-URLs: `https://cabinet.b2in.eu/go.php?z=CODE`
|
||||
- Tracking in Datenbank + Log-Datei
|
||||
- Weiterleitung zur Original-URL
|
||||
- Statistik-Dashboard
|
||||
|
||||
#### Technische Details:
|
||||
- API-basierte Konfiguration (`/api/display/config`)
|
||||
- Standalone Display-Seite (`/_cabinet/`)
|
||||
- Auto-Reload alle 5 Minuten
|
||||
- Subdomain-Support (Live: `cabinet.b2in.eu`)
|
||||
- Test- und Live-Umgebung
|
||||
- Einheitlicher Pfad für beide Umgebungen
|
||||
|
||||
---
|
||||
|
||||
### 8. Authentifizierungs-System
|
||||
|
||||
**Status**: ✅ Vollständig implementiert
|
||||
|
||||
Modernes Auth-System basierend auf Laravel Fortify & Sanctum:
|
||||
|
||||
#### Features:
|
||||
- Login/Logout
|
||||
- Registrierung
|
||||
- E-Mail-Verifizierung
|
||||
- Passwort-Reset
|
||||
- Passwort-Bestätigung (für sensible Aktionen)
|
||||
- Two-Factor-Authentication (2FA)
|
||||
- API-Token-Authentifizierung (Sanctum)
|
||||
- "Remember Me" Funktionalität
|
||||
|
||||
#### Views:
|
||||
- Login-Seite (Standard & Vereinfacht)
|
||||
- Registrierungs-Seite
|
||||
- Passwort-vergessen
|
||||
- Passwort-zurücksetzen
|
||||
- E-Mail-Verifizierung
|
||||
|
||||
#### Einstellungen:
|
||||
- Profil-Verwaltung
|
||||
- Passwort-Änderung
|
||||
- Erscheinungsbild (Theme-Einstellungen)
|
||||
- Benutzer löschen/anonymisieren
|
||||
|
||||
---
|
||||
|
||||
### 9. Website-Frontend
|
||||
|
||||
**Status**: ✅ Grundstruktur und Inhalte implementiert
|
||||
|
||||
Moderne, responsive Website mit zahlreichen Sections:
|
||||
|
||||
#### Implementierte Seiten:
|
||||
- **Home** - Hauptseite mit Hero-Slider
|
||||
- **About** - Über uns
|
||||
- **Ecosystem** - Ökosystem-Darstellung
|
||||
- **Partner** - Partner-Informationen
|
||||
- **Portfolio** - Produkt-Portfolio
|
||||
- **Magazin** - Blog/Magazin mit Detail-Ansichten
|
||||
- **Contact** - Kontaktformular
|
||||
- **Service** - Service-Seite
|
||||
- **FAQ** - Häufig gestellte Fragen
|
||||
- **Theme-Demo** - Theme-Vorschau
|
||||
|
||||
#### Wiederverwendbare Sections:
|
||||
- Hero (Standard, mit Bild, Slider, Tiles)
|
||||
- About-Hero
|
||||
- Benefits-Section
|
||||
- Brand-Worlds
|
||||
- Broker-Section
|
||||
- Card-Section
|
||||
- Commitment-Section
|
||||
- Content-Section
|
||||
- CTA-Section
|
||||
- Dark-Stats-Section
|
||||
- Digital-Core
|
||||
- Ecosystem (Core, Hero, Stats)
|
||||
- End-Customer-Section
|
||||
- FAQ
|
||||
- Final-Commitment
|
||||
- Leadership-Team
|
||||
- Magazin (List, Detail)
|
||||
- Our-Story
|
||||
- Our-Values
|
||||
- Partner (Benefits, CTA, Hero, Process)
|
||||
- Portfolio
|
||||
- Spotlights-Section
|
||||
- Supplier-Section
|
||||
- Vision-Section
|
||||
|
||||
#### UI-Komponenten:
|
||||
- Header mit Navigation
|
||||
- Footer
|
||||
- Top-Bar
|
||||
- Kontaktformular
|
||||
- Theme-Switcher (für Demos)
|
||||
|
||||
---
|
||||
|
||||
### 10. E-Mail-System
|
||||
|
||||
**Status**: 🔄 Grundarchitektur vorhanden
|
||||
|
||||
#### E-Mail-Templates:
|
||||
- Partner-Einladung (`PartnerInvitationMail`)
|
||||
- Weitere System-E-Mails über Laravel-Fortify
|
||||
|
||||
---
|
||||
|
||||
### 11. Media-Verwaltung
|
||||
|
||||
**Status**: 🔄 Grundarchitektur vorhanden
|
||||
|
||||
- Media-Tabelle für Datei-Uploads
|
||||
- Unterstützung für verschiedene Medientypen
|
||||
- Partner-Logos
|
||||
- Marken-Logos
|
||||
- Produkt-Bilder (vorbereitet)
|
||||
|
||||
---
|
||||
|
||||
### 12. Flux CMS Package (In Entwicklung)
|
||||
|
||||
**Status**: 🔄 Grundarchitektur vorhanden
|
||||
|
||||
Eigenes CMS-Package in `packages/flux-cms/`:
|
||||
|
||||
#### Geplante Features:
|
||||
- Component-First-Architecture
|
||||
- Code-as-Schema (Felder in PHP definiert)
|
||||
- Multi-Domain-Support
|
||||
- Vollständige Mehrsprachigkeit
|
||||
- Content-Versionierung
|
||||
- Media-Management
|
||||
- Performance-Optimierung
|
||||
|
||||
#### Package-Struktur:
|
||||
- `core/` - Kern-Funktionalität, Models, Services
|
||||
- `components/` - Livewire Backend & Frontend Components
|
||||
- `starter-components/` - Vorgefertigte Starter-Components
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Datenbank-Struktur
|
||||
|
||||
### Implementierte Tabellen:
|
||||
|
||||
#### Benutzer & Authentifizierung:
|
||||
- `users` - Benutzer-Accounts
|
||||
- `roles` - Rollen (Super Admin, Admin, Retailer, etc.)
|
||||
- `permissions` - Berechtigungen
|
||||
- `role_has_permissions` - Rollen-Berechtigungen-Zuordnung
|
||||
- `model_has_roles` - User-Rollen-Zuordnung
|
||||
- `personal_access_tokens` - API-Tokens (Sanctum)
|
||||
|
||||
#### Partner & Geschäftsbeziehungen:
|
||||
- `partners` - Partner-Stammdaten
|
||||
- `partner_invitations` - Einladungen mit Token & Status
|
||||
- `registration_codes` - QR-Code-basierte Registrierung
|
||||
- `hubs` - Regionale Hubs
|
||||
- `hub_locations` - Hub-Standorte
|
||||
|
||||
#### Produkte & Katalog:
|
||||
- `products` - Produkte
|
||||
- `product_variants` - Produkt-Varianten
|
||||
- `product_variant_attributes` - Varianten-Attribute
|
||||
- `product_logistics` - Logistik-Informationen
|
||||
- `brands` - Marken
|
||||
- `categories` - Kategorien
|
||||
- `category_product` - Produkt-Kategorie-Zuordnung
|
||||
- `collections` - Kollektionen
|
||||
- `attributes` - Attribute (Farbe, Größe, etc.)
|
||||
- `attribute_values` - Attribut-Werte
|
||||
- `tags` - Tags
|
||||
- `product_tag` - Produkt-Tag-Zuordnung
|
||||
- `related_products` - Verwandte Produkte
|
||||
- `tax_rates` - Steuersätze
|
||||
- `shipping_classes` - Versandklassen
|
||||
|
||||
#### Display/CMS:
|
||||
- `display_videos` - Video-Playlist für Digital Signage
|
||||
- `display_footer_contents` - Footer-Inhalte mit Short-Links & Tracking
|
||||
|
||||
#### System:
|
||||
- `media` - Media-Verwaltung
|
||||
- `cache` - Cache-Tabellen
|
||||
- `jobs` - Queue-Jobs
|
||||
- `failed_jobs` - Fehlgeschlagene Jobs
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX Features
|
||||
|
||||
### Design-System:
|
||||
- **Flux UI Pro** - Professionelle Komponenten-Bibliothek
|
||||
- **Tailwind CSS** - Utility-First CSS Framework
|
||||
- **Heroicons** - Konsistente Icon-Bibliothek
|
||||
- **Dark Mode** - Vollständige Dark-Mode-Unterstützung
|
||||
- **Responsive Design** - Mobile-First-Ansatz
|
||||
|
||||
### Theme-System:
|
||||
- Domain-basiertes Theme-Switching
|
||||
- Individuelle Farbschemata pro Domain
|
||||
- Dynamische Asset-Verwaltung
|
||||
- Theme-Demo-Seite für Vorschau
|
||||
|
||||
### Komponenten:
|
||||
- Flux Cards, Buttons, Inputs, Selects
|
||||
- Data Tables mit Pagination
|
||||
- Modals & Dialogs
|
||||
- Toast-Notifications
|
||||
- Form-Validierung
|
||||
- Loading States
|
||||
|
||||
---
|
||||
|
||||
## 📝 Dokumentation
|
||||
|
||||
Umfangreiche Entwicklungs-Dokumentation:
|
||||
|
||||
- `README.md` - Projekt-Übersicht & Installation
|
||||
- `CLAUDE.md` - AI-Kontext-Dokumentation
|
||||
- `dev/COMPONENT-STRUCTURE.md` - Komponenten-Architektur
|
||||
- `dev/DOMAINS-CONFIG.md` - Domain-Konfiguration
|
||||
- `dev/FORTIFY-SANCTUM-SETUP.md` - Auth-Setup
|
||||
- `dev/HERO-ICONS-USAGE.md` - Icon-Verwendung
|
||||
- `dev/LOCAL-DEVELOPMENT.md` - Lokale Entwicklung
|
||||
- `dev/PARTNER-SETUP-WIZARD.md` - Partner-Onboarding
|
||||
- `dev/DISPLAY_CMS_README.md` - Display-CMS-Dokumentation
|
||||
- `dev/DISPLAY_SETUP_LIVE.md` - Display Live-Setup
|
||||
- `dev/ENV_VARIABLES_DISPLAY.md` - Display-Umgebungsvariablen
|
||||
- `dev/THEME-SWITCHING.md` - Theme-System
|
||||
- `dev/THEME-DEMO-COMPONENTS.md` - Theme-Demo
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Entwicklungstools
|
||||
|
||||
### Integrierte Tools:
|
||||
- **Laravel Sail** - Docker-Entwicklungsumgebung
|
||||
- **Laravel Pail** - Real-time Log-Viewer
|
||||
- **Laravel Debugbar** - Debug-Informationen
|
||||
- **Laravel Pint** - Code-Formatter
|
||||
- **Pest PHP** - Testing Framework
|
||||
- **Laravel Dusk** - Browser-Testing
|
||||
- **NPM Concurrently** - Parallel-Prozess-Management
|
||||
|
||||
### Development-Scripts:
|
||||
```bash
|
||||
composer dev # Startet Server, Queue, Logs & Vite parallel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment-Bereitschaft
|
||||
|
||||
### Produktions-Features:
|
||||
- ✅ Umgebungsspezifische Konfigurationen
|
||||
- ✅ Asset-Kompilierung (Vite)
|
||||
- ✅ Cache-System
|
||||
- ✅ Queue-System
|
||||
- ✅ Log-Management
|
||||
- ✅ Error-Handling
|
||||
- ✅ HTTPS-Ready
|
||||
|
||||
### Optimierungen:
|
||||
- Autoloader-Optimierung
|
||||
- Route-Caching
|
||||
- Config-Caching
|
||||
- View-Caching
|
||||
|
||||
---
|
||||
|
||||
## 📊 Test-Abdeckung
|
||||
|
||||
### Testing-Setup:
|
||||
- Pest PHP Framework installiert
|
||||
- TestCase-Struktur vorhanden
|
||||
- Browser-Testing mit Laravel Dusk
|
||||
- Feature & Unit Tests vorbereitet
|
||||
|
||||
**Hinweis**: Test-Implementierung kann bei Bedarf erweitert werden.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Sicherheit
|
||||
|
||||
### Implementierte Sicherheitsfeatures:
|
||||
- ✅ CSRF-Protection (Laravel Standard)
|
||||
- ✅ SQL-Injection-Prevention (Eloquent ORM)
|
||||
- ✅ XSS-Protection (Blade Escaping)
|
||||
- ✅ Password-Hashing (Bcrypt)
|
||||
- ✅ E-Mail-Verifizierung
|
||||
- ✅ Two-Factor-Authentication
|
||||
- ✅ Rate-Limiting (vorbereitet)
|
||||
- ✅ Soft-Delete für DSGVO-Compliance
|
||||
- ✅ Benutzer-Anonymisierung
|
||||
|
||||
---
|
||||
|
||||
## 📦 Abhängigkeiten & Packages
|
||||
|
||||
### Haupt-Abhängigkeiten:
|
||||
- `laravel/framework` ^12.0
|
||||
- `laravel/fortify` ^1.27
|
||||
- `laravel/sanctum` ^4.1
|
||||
- `livewire/flux` ^2.1.1
|
||||
- `livewire/flux-pro` ^2.6
|
||||
- `livewire/volt` ^1.7.0
|
||||
- `spatie/laravel-permission` ^6.17
|
||||
- `blade-ui-kit/blade-heroicons` ^2.6
|
||||
|
||||
### Dev-Abhängigkeiten:
|
||||
- `laravel/sail` ^1.41
|
||||
- `laravel/dusk` ^8.2
|
||||
- `pestphp/pest` ^3.8
|
||||
- `barryvdh/laravel-debugbar` ^3.16
|
||||
- `reliese/laravel` ^1.4 (Model-Generator)
|
||||
|
||||
---
|
||||
|
||||
## 📈 Nächste Schritte & Empfehlungen
|
||||
|
||||
### Kurzfristig (bereit für Implementierung):
|
||||
1. **Bestellsystem** - Order-Management für Kunden
|
||||
2. **Produktbilder** - Media-Upload für Produkte
|
||||
3. **E-Mail-Templates** - Erweiterte E-Mail-Kommunikation
|
||||
4. **Benachrichtigungen** - In-App-Notifications
|
||||
|
||||
### Mittelfristig:
|
||||
1. **Provisionsabrechnung** - Automatisierte Provisionsberechnung
|
||||
2. **Reporting & Analytics** - Dashboard-Erweiterungen
|
||||
3. **API-Erweiterung** - REST-API für Externe
|
||||
4. **Suchfunktion** - Globale Suche über alle Inhalte
|
||||
|
||||
### Langfristig:
|
||||
1. **Mobile-App** - Native Apps (via Sanctum-API)
|
||||
2. **Mehrsprachigkeit** - Internationalisierung
|
||||
3. **Erweiterte CMS-Features** - Flux-CMS-Package fertigstellen
|
||||
4. **Payment-Integration** - Zahlungsabwicklung
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Projektstatus Zusammenfassung
|
||||
|
||||
### ✅ Vollständig implementiert:
|
||||
- Multi-Domain-System mit Theme-Switching
|
||||
- Komplettes Benutzer- & Rollen-Management
|
||||
- Partner-Management mit Onboarding
|
||||
- Hub-Verwaltung
|
||||
- Produkt-Management (Grundfunktionen)
|
||||
- Rollenbasierte Dashboards
|
||||
- Digital Signage CMS mit Tracking
|
||||
- Authentifizierung & Sicherheit
|
||||
- Website-Frontend (Grundstruktur)
|
||||
- Dokumentation
|
||||
|
||||
### 🔄 In Arbeit:
|
||||
- Flux-CMS-Package (Architektur steht)
|
||||
- Erweiterte Produkt-Features
|
||||
- Test-Abdeckung
|
||||
|
||||
### 📋 Bereit für Umsetzung:
|
||||
- Bestellsystem
|
||||
- Provisionsabrechnung
|
||||
- Erweiterte Reporting-Funktionen
|
||||
- Payment-Integration
|
||||
|
||||
---
|
||||
|
||||
## 💡 Besonderheiten & Highlights
|
||||
|
||||
### Technische Exzellenz:
|
||||
- **Modern Stack** - Neueste Laravel/Livewire-Versionen
|
||||
- **Component-Architecture** - Wiederverwendbare Livewire-Komponenten
|
||||
- **Type-Safety** - PHP 8.2+ Features
|
||||
- **Performance** - Optimierte Queries, Caching
|
||||
- **Skalierbarkeit** - Modularer Aufbau für Wachstum
|
||||
|
||||
### Business-Features:
|
||||
- **Multi-Tenant-Ready** - Verschiedene Partner-Typen
|
||||
- **Hierarchische Strukturen** - Broker-Kunden-Beziehungen
|
||||
- **Flexibles Provisionsmodell** - Prozentsatz oder Festbetrag
|
||||
- **Umfangreiches Tracking** - Display-Klicks, User-Aktivitäten
|
||||
- **White-Label-Fähigkeit** - Domain-spezifisches Branding
|
||||
|
||||
### User-Experience:
|
||||
- **Intuitives Onboarding** - Setup-Wizard für neue Partner
|
||||
- **Rollenbasierte Ansichten** - Jede Rolle sieht nur relevante Inhalte
|
||||
- **Real-time Updates** - Livewire für nahtlose Interaktion
|
||||
- **Responsive Design** - Funktioniert auf allen Geräten
|
||||
- **Dark Mode** - Moderne UI-Präferenzen
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Wartung
|
||||
|
||||
Das System ist produktionsbereit und kann bei Bedarf mit folgenden Services erweitert werden:
|
||||
|
||||
- Code-Reviews & Optimierungen
|
||||
- Feature-Erweiterungen
|
||||
- Bug-Fixes & Updates
|
||||
- Performance-Tuning
|
||||
- Sicherheits-Audits
|
||||
- Schulungen für Administratoren
|
||||
|
||||
---
|
||||
|
||||
**Entwicklungsstand**: Dezember 2025
|
||||
**Version**: 1.0 (Production-Ready)
|
||||
**Framework**: Laravel 12
|
||||
**PHP**: 8.2+
|
||||
**Lizenz**: MIT
|
||||
|
||||
---
|
||||
|
||||
*Diese Dokumentation wurde automatisch generiert und gibt den aktuellen Stand der Entwicklung wieder.*
|
||||
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
<div x-show="open" @click.away="open = false" class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<a href="{{ route('admin.cms.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Einstellungen</a>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
<form method="POST" action="{{ route('auth.logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Abmelden
|
||||
|
|
|
|||
34
public/_cabinet/.htaccess.example
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# .htaccess für Cabinet Digital Signage
|
||||
# Umbenennen zu .htaccess um zu aktivieren
|
||||
|
||||
# Passwortschutz für Log-Viewer
|
||||
<Files "view-logs.php">
|
||||
AuthType Basic
|
||||
AuthName "Cabinet Logs - Restricted Access"
|
||||
AuthUserFile /var/www/html/public/_cabinet/.htpasswd
|
||||
Require valid-user
|
||||
</Files>
|
||||
|
||||
# CORS Headers für logger.php (falls nötig)
|
||||
<Files "logger.php">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
Header set Access-Control-Allow-Methods "POST, OPTIONS"
|
||||
Header set Access-Control-Allow-Headers "Content-Type"
|
||||
</Files>
|
||||
|
||||
# Logs-Verzeichnis vor direktem Zugriff schützen
|
||||
<DirectoryMatch "^.*/logs/$">
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</DirectoryMatch>
|
||||
|
||||
# .htpasswd vor Zugriff schützen
|
||||
<Files ".htpasswd">
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</Files>
|
||||
|
||||
# PHP Error Logging (optional)
|
||||
php_flag display_errors Off
|
||||
php_flag log_errors On
|
||||
php_value error_log /var/www/html/public/_cabinet/logs/php_errors.log
|
||||
480
public/_cabinet/KIOSK_MODE_SETUP.md
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
# Chrome Kiosk-Mode Setup für Android Digital Signage
|
||||
|
||||
## 🎯 Problem
|
||||
|
||||
Bei einem Page-Reload (z.B. nach 6h automatisch) wird der Vollbildmodus beendet - das ist eine Browser-Sicherheitsmaßnahme. Der Vollbildmodus kann nicht automatisch per Script reaktiviert werden.
|
||||
|
||||
## ✅ Die Lösung: Chrome Kiosk-Mode
|
||||
|
||||
Der **Kiosk-Mode** ist die professionelle Lösung für Digital Signage. Chrome läuft dabei permanent im Vollbild ohne Browser-UI.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Option 1: Chrome Kiosk-Mode (Empfohlen für Android)
|
||||
|
||||
### Voraussetzungen:
|
||||
- Android-Gerät (Tablet/Display)
|
||||
- Chrome Browser installiert
|
||||
- ADB (Android Debug Bridge) für Setup
|
||||
|
||||
### Setup-Schritte:
|
||||
|
||||
#### 1. **Developer-Optionen aktivieren**
|
||||
|
||||
1. Gehe zu **Einstellungen** → **Über das Telefon/Tablet**
|
||||
2. Tippe **7x auf "Build-Nummer"**
|
||||
3. Developer-Optionen sind jetzt aktiv
|
||||
|
||||
#### 2. **USB-Debugging aktivieren**
|
||||
|
||||
1. Gehe zu **Einstellungen** → **Entwickleroptionen**
|
||||
2. Aktiviere **"USB-Debugging"**
|
||||
3. Verbinde Gerät per USB mit Computer
|
||||
|
||||
#### 3. **ADB installieren (auf Computer)**
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
# Download Android Platform Tools
|
||||
https://developer.android.com/studio/releases/platform-tools
|
||||
|
||||
# Entpacken und zu PATH hinzufügen
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install android-platform-tools
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
sudo apt install android-tools-adb
|
||||
```
|
||||
|
||||
#### 4. **Chrome im Kiosk-Mode starten**
|
||||
|
||||
```bash
|
||||
# Verbindung testen
|
||||
adb devices
|
||||
|
||||
# Chrome beenden
|
||||
adb shell am force-stop com.android.chrome
|
||||
|
||||
# Chrome im Kiosk-Mode starten
|
||||
adb shell am start \
|
||||
-n com.android.chrome/com.google.android.apps.chrome.Main \
|
||||
-a android.intent.action.VIEW \
|
||||
-d "https://cabinet.b2in.eu" \
|
||||
--ez create_new_tab true \
|
||||
--activity-clear-task \
|
||||
--activity-clear-top \
|
||||
--activity-single-top
|
||||
|
||||
# Optional: Vollbild erzwingen
|
||||
adb shell settings put global policy_control immersive.full=*
|
||||
```
|
||||
|
||||
#### 5. **Auto-Start beim Boot (Optional)**
|
||||
|
||||
Erstelle eine Boot-App oder nutze Automate-Apps:
|
||||
|
||||
**Mit MacroDroid (kostenlose App):**
|
||||
1. Installiere MacroDroid aus Play Store
|
||||
2. Erstelle Macro:
|
||||
- **Trigger:** "Device Boot"
|
||||
- **Action:** "Launch Application" → Chrome
|
||||
- **Action:** "Load Webpage" → https://cabinet.b2in.eu
|
||||
3. Speichern und aktivieren
|
||||
|
||||
---
|
||||
|
||||
## 📱 Option 2: Dedicated Kiosk-Browser Apps
|
||||
|
||||
### Empfohlene Apps für Android Digital Signage:
|
||||
|
||||
### **1. Fully Kiosk Browser** (⭐ Empfohlen)
|
||||
|
||||
**Features:**
|
||||
- ✅ Echter Kiosk-Modus (keine UI, kein Zurück-Button)
|
||||
- ✅ Auto-Start beim Boot
|
||||
- ✅ Remote-Management
|
||||
- ✅ Screensaver-Funktion
|
||||
- ✅ Keep Screen On
|
||||
- ✅ Remote-Config via Web-Interface
|
||||
|
||||
**Installation:**
|
||||
```
|
||||
1. Download: https://www.fully-kiosk.com
|
||||
2. Installiere APK auf Android
|
||||
3. Öffne App
|
||||
4. Settings:
|
||||
- Start URL: https://cabinet.b2in.eu
|
||||
- Kiosk Mode: ON
|
||||
- Launch on Boot: ON
|
||||
- Hide System UI: ON
|
||||
- Keep Screen On: ON
|
||||
- Reload on Network Restore: ON
|
||||
5. Lock App (Admin Pin setzen)
|
||||
```
|
||||
|
||||
**Kosten:**
|
||||
- Kostenlos für Single-Device
|
||||
- Plus Version: ~€20 (einmalig) für erweiterte Features
|
||||
|
||||
### **2. Kiosk Browser Lockdown**
|
||||
|
||||
**Features:**
|
||||
- ✅ Einfaches Setup
|
||||
- ✅ Kiosk-Mode
|
||||
- ✅ Auto-Start
|
||||
- ✅ Kostenlos
|
||||
|
||||
**Installation:**
|
||||
```
|
||||
1. Play Store: "Kiosk Browser Lockdown"
|
||||
2. URL setzen: https://cabinet.b2in.eu
|
||||
3. Kiosk Mode aktivieren
|
||||
4. PIN setzen
|
||||
```
|
||||
|
||||
### **3. Chrome mit Custom Launcher**
|
||||
|
||||
**Features:**
|
||||
- ✅ Nutzt Chrome Engine
|
||||
- ✅ Custom Launcher ersetzt Home-Screen
|
||||
- ✅ Kostenlos
|
||||
|
||||
**Apps:**
|
||||
- "Screen On" (Play Store)
|
||||
- "Stay Alive!" (Play Store)
|
||||
- "Screen Alive" (Play Store)
|
||||
|
||||
---
|
||||
|
||||
## 💻 Option 3: Chrome Flags (Desktop/Android)
|
||||
|
||||
### Für Desktop-Testing:
|
||||
|
||||
**Chrome starten mit Flags:**
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
|
||||
--kiosk "https://cabinet.b2in.eu" ^
|
||||
--disable-session-crashed-bubble ^
|
||||
--disable-infobars ^
|
||||
--noerrdialogs ^
|
||||
--disable-translate ^
|
||||
--no-first-run ^
|
||||
--fast-start ^
|
||||
--disable-features=TranslateUI ^
|
||||
--disk-cache-dir=NUL ^
|
||||
--overscroll-history-navigation=0
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--kiosk "https://cabinet.b2in.eu" \
|
||||
--disable-session-crashed-bubble \
|
||||
--disable-infobars \
|
||||
--noerrdialogs
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome \
|
||||
--kiosk "https://cabinet.b2in.eu" \
|
||||
--disable-session-crashed-bubble \
|
||||
--disable-infobars \
|
||||
--noerrdialogs \
|
||||
--disable-translate \
|
||||
--no-first-run
|
||||
```
|
||||
|
||||
### Flags Erklärung:
|
||||
- `--kiosk` - Vollbild-Modus ohne UI
|
||||
- `--disable-session-crashed-bubble` - Keine "Chrome wurde nicht korrekt beendet" Meldung
|
||||
- `--disable-infobars` - Keine Info-Leisten
|
||||
- `--noerrdialogs` - Keine Error-Dialoge
|
||||
- `--disable-translate` - Keine Übersetzungs-Popups
|
||||
- `--no-first-run` - Kein First-Run-Dialog
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Option 4: Unsere Fallback-Lösung (bereits implementiert)
|
||||
|
||||
### Was haben wir implementiert:
|
||||
|
||||
#### 1. **LocalStorage-Tracking**
|
||||
```javascript
|
||||
// Beim Aktivieren merken
|
||||
localStorage.setItem('cabinet_fullscreen_was_active', 'true');
|
||||
|
||||
// Nach Reload prüfen
|
||||
if (wasFullscreen) {
|
||||
// Auffälliger Reminder anzeigen
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. **Visueller Reminder**
|
||||
- **Orange pulsierender Button** nach Reload
|
||||
- Text: "⚠️ Vollbild aktivieren!"
|
||||
- Automatisch sichtbar wenn Fullscreen vorher aktiv war
|
||||
|
||||
#### 3. **Auto-Retry (30s Delay)**
|
||||
```javascript
|
||||
// Nach 30s automatisch versuchen (falls Kiosk-Mode aktiv)
|
||||
setTimeout(() => {
|
||||
enterFullscreen(); // Wird ignoriert wenn keine User-Geste
|
||||
}, 30000);
|
||||
```
|
||||
|
||||
#### 4. **Logging**
|
||||
```javascript
|
||||
✅ "Fullscreen aktiviert"
|
||||
⚠️ "Fullscreen-Reminder angezeigt"
|
||||
ℹ️ "Fullscreen verlassen"
|
||||
```
|
||||
|
||||
### Vorteile:
|
||||
- ✅ Funktioniert ohne zusätzliche Apps
|
||||
- ✅ Visueller Hinweis dass Fullscreen reaktiviert werden muss
|
||||
- ✅ Nutzer wird "erinnert" nach Reload
|
||||
- ✅ Logging für Monitoring
|
||||
|
||||
### Nachteile:
|
||||
- ❌ Erfordert manuellen Klick nach Reload
|
||||
- ❌ Nicht vollautomatisch
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Empfohlenes Setup für Production
|
||||
|
||||
### Für Android Digital Signage Displays:
|
||||
|
||||
#### **Best Practice:**
|
||||
|
||||
```
|
||||
1. Hardware: Android Tablet/Display
|
||||
└─ Empfohlen: Android 9+ mit min 2GB RAM
|
||||
|
||||
2. Software: Fully Kiosk Browser (Plus)
|
||||
├─ Start URL: https://cabinet.b2in.eu
|
||||
├─ Kiosk Mode: ON
|
||||
├─ Launch on Boot: ON
|
||||
├─ Hide System UI: ON
|
||||
├─ Keep Screen On: ON
|
||||
├─ Reload on Network Restore: ON
|
||||
└─ Remote Management: ON
|
||||
|
||||
3. Network: Kabelgebunden (LAN)
|
||||
└─ Fallback: 5GHz WiFi mit statischer IP
|
||||
|
||||
4. Power: USV/Surge Protection
|
||||
└─ Auto Power-On nach Stromausfall
|
||||
|
||||
5. Monitoring:
|
||||
├─ Fully Kiosk Remote Admin
|
||||
└─ Unsere Logs: view-logs.php
|
||||
```
|
||||
|
||||
### Setup-Checklist:
|
||||
|
||||
- [ ] Android-Gerät vorbereitet
|
||||
- [ ] Fully Kiosk Browser installiert
|
||||
- [ ] Kiosk-Mode konfiguriert
|
||||
- [ ] Auto-Start aktiviert
|
||||
- [ ] System-UI versteckt
|
||||
- [ ] Keep-Screen-On aktiviert
|
||||
- [ ] Remote-Management aktiviert (Optional)
|
||||
- [ ] PIN-Schutz gesetzt
|
||||
- [ ] Display-Helligkeit eingestellt
|
||||
- [ ] Netzwerk getestet (LAN bevorzugt)
|
||||
- [ ] Power-Management konfiguriert
|
||||
- [ ] Test-Lauf 24h durchgeführt
|
||||
- [ ] Monitoring aktiv (Logs checken)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Problem: Kiosk-Mode wird nicht aktiviert
|
||||
|
||||
**Lösung 1: Developer-Optionen**
|
||||
```
|
||||
Settings → Developer Options → "Stay Awake" ON
|
||||
```
|
||||
|
||||
**Lösung 2: App-Berechtigungen**
|
||||
```
|
||||
Settings → Apps → Fully Kiosk → Permissions
|
||||
- Display over other apps: ALLOW
|
||||
- Auto-start: ALLOW
|
||||
```
|
||||
|
||||
**Lösung 3: Device Admin**
|
||||
```
|
||||
Settings → Security → Device Administrators
|
||||
- Fully Kiosk Browser: ENABLE
|
||||
```
|
||||
|
||||
### Problem: Display schaltet sich ab
|
||||
|
||||
**Lösung:**
|
||||
```
|
||||
1. Fully Kiosk Settings:
|
||||
- Keep Screen On: ON
|
||||
- Screen Saver: OFF
|
||||
- Prevent Sleep: ON
|
||||
|
||||
2. Android Settings:
|
||||
- Display → Sleep: NEVER
|
||||
- Display → Adaptive Brightness: OFF
|
||||
```
|
||||
|
||||
### Problem: Chrome Exit nach Reload
|
||||
|
||||
**Lösung:**
|
||||
```
|
||||
Nutze Fully Kiosk Browser statt Chrome!
|
||||
- Fully ist speziell für Kiosk designed
|
||||
- Kein ungewolltes Exit möglich
|
||||
- Automatischer Neustart bei Crash
|
||||
```
|
||||
|
||||
### Problem: Zurück-Button verlässt App
|
||||
|
||||
**Lösung:**
|
||||
```
|
||||
Fully Kiosk Settings:
|
||||
- Disable Back Button: ON
|
||||
- Disable Home Button: ON (Device Admin nötig)
|
||||
- Kiosk Mode: Advanced
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Vergleich der Optionen
|
||||
|
||||
| Option | Kosten | Komplexität | Zuverlässigkeit | Empfehlung |
|
||||
|--------|--------|-------------|-----------------|------------|
|
||||
| **Fully Kiosk** | €20 | ⭐⭐ | ⭐⭐⭐⭐⭐ | 🥇 **BEST** |
|
||||
| **Chrome Kiosk (ADB)** | Kostenlos | ⭐⭐⭐⭐ | ⭐⭐⭐ | OK für Tech-Versierte |
|
||||
| **Kiosk Browser Free** | Kostenlos | ⭐⭐ | ⭐⭐⭐ | OK für Testing |
|
||||
| **Unsere Fallback-Lösung** | Kostenlos | ⭐ | ⭐⭐ | Fallback |
|
||||
| **Chrome + Custom Launcher** | Kostenlos | ⭐⭐⭐ | ⭐⭐⭐ | Mittel |
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Quick Start: Fully Kiosk (Empfohlen)
|
||||
|
||||
### 5-Minuten-Setup:
|
||||
|
||||
```bash
|
||||
1. Download: https://www.fully-kiosk.com
|
||||
└─ APK auf Android-Gerät installieren
|
||||
|
||||
2. App öffnen → Settings:
|
||||
Start URL: https://cabinet.b2in.eu
|
||||
|
||||
3. Advanced Settings:
|
||||
[x] Kiosk Mode
|
||||
[x] Launch on Boot
|
||||
[x] Hide System UI
|
||||
[x] Keep Screen On
|
||||
[x] Prevent Sleep
|
||||
|
||||
4. Lock Settings (+ Button):
|
||||
└─ PIN setzen (z.B. 1234)
|
||||
|
||||
5. ✅ Fertig! Display läuft 24/7 im Kiosk-Mode
|
||||
```
|
||||
|
||||
### Remote-Management aktivieren:
|
||||
|
||||
```
|
||||
1. Settings → Remote Administration:
|
||||
[x] Enable Remote Administration
|
||||
[x] Remote Admin from Local Network
|
||||
|
||||
2. Notiere IP-Adresse:
|
||||
z.B. http://192.168.1.100:2323
|
||||
|
||||
3. Öffne vom PC:
|
||||
http://192.168.1.100:2323
|
||||
└─ Password: (Dein PIN)
|
||||
|
||||
4. ✅ Remote-Control aktiv!
|
||||
- Screenshots
|
||||
- Reload
|
||||
- Settings ändern
|
||||
- Screen On/Off
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Pro-Tipps
|
||||
|
||||
### 1. **Netzwerk-Stabilität**
|
||||
```
|
||||
- LAN bevorzugen (kein WiFi)
|
||||
- Statische IP vergeben
|
||||
- Router/Switch mit QoS
|
||||
- Fully Kiosk: "Reload on Network Restore" ON
|
||||
```
|
||||
|
||||
### 2. **Power-Management**
|
||||
```
|
||||
- USV verwenden
|
||||
- BIOS: "AC Power Recovery" → ON
|
||||
- Android: "Auto Power On" → ON
|
||||
- Fully Kiosk: "Restart on Crash" → ON
|
||||
```
|
||||
|
||||
### 3. **Display-Pflege**
|
||||
```
|
||||
- Bildschirmschoner nach 22:00 Uhr
|
||||
- Helligkeit reduzieren nachts
|
||||
- Pixel-Shift aktivieren (gegen Burn-In)
|
||||
- Display-Timeout: NEVER
|
||||
```
|
||||
|
||||
### 4. **Monitoring**
|
||||
```
|
||||
- Fully Kiosk Remote Admin
|
||||
- Unsere Logs: view-logs.php
|
||||
- Ping-Monitoring (Nagios/Zabbix)
|
||||
- Wöchentliche Checks
|
||||
```
|
||||
|
||||
### 5. **Security**
|
||||
```
|
||||
- Fully Kiosk mit PIN schützen
|
||||
- Device Administrator aktivieren
|
||||
- USB-Debugging OFF (nach Setup)
|
||||
- Unknown Sources OFF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
### Fully Kiosk Support:
|
||||
- Website: https://www.fully-kiosk.com
|
||||
- Forum: https://www.fully-kiosk.com/forum
|
||||
- Email: support@fully-kiosk.com
|
||||
|
||||
### Unsere Logs:
|
||||
- URL: https://cabinet.b2in.eu/view-logs.php
|
||||
- Check: Fullscreen-Events
|
||||
- Monitor: Memory & Errors
|
||||
|
||||
---
|
||||
|
||||
**Empfehlung:** Investiere die €20 für **Fully Kiosk Browser Plus** - es spart viele Stunden Troubleshooting und ist die stabilste Lösung für Digital Signage! 🎯
|
||||
|
||||
---
|
||||
|
||||
**Last Update:** 2026-01-19
|
||||
**Version:** 1.3
|
||||
215
public/_cabinet/LOGGING_README.md
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# Cabinet Digital Signage - Logging System
|
||||
|
||||
## 📋 Übersicht
|
||||
|
||||
Dieses erweiterte Logging-System ermöglicht es dir, alle Fehler und Events von den Android-Displays remote zu überwachen, ohne physischen Zugriff auf die Geräte zu benötigen.
|
||||
|
||||
## 🎯 Features
|
||||
|
||||
### Automatisches Logging von:
|
||||
- ✅ **JavaScript-Fehler** (Runtime Errors)
|
||||
- ✅ **Unhandled Promise Rejections** (async/await Fehler)
|
||||
- ✅ **Console Errors & Warnings**
|
||||
- ✅ **Resource Loading Failures** (Videos, Bilder)
|
||||
- ✅ **Video Playback Errors** (mit Media Error Codes)
|
||||
- ✅ **Network Status** (Online/Offline Events)
|
||||
- ✅ **Video Stalling** (Buffering-Probleme)
|
||||
- ✅ **Configuration Loading** (API-Fehler)
|
||||
- ✅ **Heartbeat** (alle 5 Minuten - zeigt dass Display läuft)
|
||||
|
||||
### Log-Levels:
|
||||
- `FATAL` - Kritische JavaScript-Fehler
|
||||
- `ERROR` - Fehler (Video-Loading, Network, etc.)
|
||||
- `WARNING` - Warnungen (Buffering, Connection Lost)
|
||||
- `INFO` - Informationen (Heartbeat, Video Started, Config Loaded)
|
||||
|
||||
## 📂 Dateistruktur
|
||||
|
||||
```
|
||||
public/_cabinet/
|
||||
├── index.html # Haupt-Display-Datei (mit Logging)
|
||||
├── logger.php # Backend-Endpoint für Logs
|
||||
├── view-logs.php # Web-Interface zum Ansehen der Logs
|
||||
├── logs/ # Log-Verzeichnis (wird automatisch erstellt)
|
||||
│ ├── all_2026-01-19.log # Alle Logs des Tages
|
||||
│ ├── error_2026-01-19.log # Nur Errors
|
||||
│ ├── fatal_2026-01-19.log # Nur Fatal Errors
|
||||
│ ├── warning_2026-01-19.log # Nur Warnings
|
||||
│ ├── info_2026-01-19.log # Nur Info
|
||||
│ └── json_2026-01-19.log # JSON Format (für Parsing)
|
||||
└── LOGGING_README.md # Diese Datei
|
||||
```
|
||||
|
||||
## 🚀 Setup
|
||||
|
||||
### 1. Logs-Verzeichnis erstellen (falls nicht automatisch erstellt)
|
||||
```bash
|
||||
mkdir -p public/_cabinet/logs
|
||||
chmod 755 public/_cabinet/logs
|
||||
```
|
||||
|
||||
### 2. PHP-Konfiguration (falls nötig)
|
||||
Stelle sicher, dass PHP Schreibrechte auf das `logs/` Verzeichnis hat:
|
||||
```bash
|
||||
chown -R www-data:www-data public/_cabinet/logs
|
||||
# ODER
|
||||
chmod 777 public/_cabinet/logs # Nur für Development!
|
||||
```
|
||||
|
||||
### 3. Logs ansehen
|
||||
Öffne im Browser:
|
||||
```
|
||||
https://cabinet.b2in.eu/view-logs.php
|
||||
```
|
||||
|
||||
## 📊 Log-Viewer Features
|
||||
|
||||
Der `view-logs.php` bietet:
|
||||
- 📈 **Statistiken** (Anzahl Fatal/Error/Warning/Info)
|
||||
- 🎨 **Farbcodierung** nach Log-Level
|
||||
- 🔍 **Dateiauswahl** (verschiedene Log-Dateien)
|
||||
- ⚡ **Auto-Refresh** (alle 10 Sekunden)
|
||||
- 📏 **Zeilenanzahl** wählbar (50/100/500/1000/alle)
|
||||
|
||||
## 🔒 Sicherheit
|
||||
|
||||
**WICHTIG:** Der Log-Viewer sollte in Produktion geschützt werden!
|
||||
|
||||
### Option 1: .htaccess Passwortschutz
|
||||
```apache
|
||||
# In public/_cabinet/.htaccess
|
||||
<Files "view-logs.php">
|
||||
AuthType Basic
|
||||
AuthName "Restricted Access"
|
||||
AuthUserFile /pfad/zu/.htpasswd
|
||||
Require valid-user
|
||||
</Files>
|
||||
```
|
||||
|
||||
### Option 2: PHP Session-basiert
|
||||
Füge am Anfang von `view-logs.php` hinzu:
|
||||
```php
|
||||
<?php
|
||||
session_start();
|
||||
if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||
// Login-Formular oder Redirect
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
```
|
||||
|
||||
## 📝 Kontext-Informationen
|
||||
|
||||
Jeder Log-Eintrag enthält automatisch:
|
||||
- **Timestamp** (ISO 8601)
|
||||
- **IP-Adresse** des Displays
|
||||
- **User Agent** (Browser/OS Info)
|
||||
- **Viewport** (Display-Auflösung)
|
||||
- **Connection Status** (online/offline)
|
||||
- **Aktuelles Video** (Dateiname)
|
||||
- **Video Index** (Position in Playlist)
|
||||
- **Footer Index** (Aktueller Footer-Content)
|
||||
- **Playlist-Länge** (Anzahl Videos)
|
||||
|
||||
## 🎮 Manuelle Logs
|
||||
|
||||
Du kannst auch manuell Logs aus dem JavaScript senden:
|
||||
|
||||
```javascript
|
||||
// Info Log
|
||||
window.displayLogger.log('Meine Info-Nachricht', {
|
||||
customData: 'wert'
|
||||
});
|
||||
|
||||
// Warning
|
||||
window.displayLogger.warn('Warnung', {
|
||||
reason: 'Irgendwas ist komisch'
|
||||
});
|
||||
|
||||
// Error
|
||||
window.displayLogger.error('Fehler aufgetreten', {
|
||||
errorCode: 123
|
||||
});
|
||||
|
||||
// Kontext setzen
|
||||
window.displayLogger.setContext('customKey', 'customValue');
|
||||
```
|
||||
|
||||
## 🔄 Log-Rotation
|
||||
|
||||
Logs werden automatisch rotiert:
|
||||
- Separate Dateien pro Tag
|
||||
- Automatische Löschung von Logs älter als 30 Tage
|
||||
- Verschiedene Dateien pro Log-Level
|
||||
|
||||
## 📱 Debugging-Workflow
|
||||
|
||||
1. **Problem entdeckt** → Öffne `view-logs.php`
|
||||
2. **Wähle Log-Datei** → z.B. `error_2026-01-19.log`
|
||||
3. **Schaue Statistiken** → Wie viele Fehler pro Level?
|
||||
4. **Analysiere Logs** → Welches Video? Welcher Footer? Welche IP?
|
||||
5. **Problem beheben** → Update CMS oder Code
|
||||
6. **Monitoring** → Aktiviere Auto-Refresh zum Live-Monitoring
|
||||
|
||||
## 🐛 Häufige Fehlertypen
|
||||
|
||||
### MEDIA_ERR_NETWORK (Code 2)
|
||||
- **Ursache:** Netzwerkprobleme beim Video-Laden
|
||||
- **Lösung:** Prüfe Internetverbindung, Video-URL, Server-Erreichbarkeit
|
||||
|
||||
### MEDIA_ERR_DECODE (Code 3)
|
||||
- **Ursache:** Video-Codec nicht unterstützt oder Datei korrupt
|
||||
- **Lösung:** Video neu encodieren (H.264, AAC)
|
||||
|
||||
### MEDIA_ERR_SRC_NOT_SUPPORTED (Code 4)
|
||||
- **Ursache:** Video-Format nicht unterstützt
|
||||
- **Lösung:** Format zu MP4/WebM ändern
|
||||
|
||||
### Promise Rejection
|
||||
- **Ursache:** Async-Fehler (meist API-Calls)
|
||||
- **Lösung:** Prüfe API-Endpoint, CORS-Settings
|
||||
|
||||
### Stalled Video
|
||||
- **Ursache:** Buffering-Probleme
|
||||
- **Lösung:** Video-Bitrate reduzieren, Netzwerk prüfen
|
||||
|
||||
## 💡 Best Practices
|
||||
|
||||
1. **Regelmäßig Logs checken** - Mindestens 1x pro Woche
|
||||
2. **Statistiken beachten** - Viele Errors/Warnings? → Aktion nötig
|
||||
3. **Heartbeat überwachen** - Kommt alle 5 Min? Display läuft
|
||||
4. **JSON-Logs für Analyse** - Nutze `json_*.log` für automatische Auswertung
|
||||
5. **Log-Level richtig nutzen**:
|
||||
- `FATAL` → Sofort reagieren
|
||||
- `ERROR` → Zeitnah prüfen
|
||||
- `WARNING` → Im Auge behalten
|
||||
- `INFO` → Für Debugging
|
||||
|
||||
## 🔗 Integration mit Monitoring-Tools
|
||||
|
||||
Die JSON-Logs können einfach in Monitoring-Tools integriert werden:
|
||||
|
||||
```bash
|
||||
# Alle Fatal Errors der letzten 24h
|
||||
grep '"level":"FATAL"' public/_cabinet/logs/json_*.log
|
||||
|
||||
# Errors nach IP gruppieren
|
||||
jq -r '.ip' public/_cabinet/logs/json_*.log | sort | uniq -c
|
||||
|
||||
# Häufigste Fehler
|
||||
jq -r '.message' public/_cabinet/logs/json_*.log | sort | uniq -c | sort -rn
|
||||
```
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Bei Fragen oder Problemen:
|
||||
1. Prüfe die Logs in `view-logs.php`
|
||||
2. Schaue dir die Kontext-Informationen an
|
||||
3. Prüfe ob andere Displays gleichen Fehler haben
|
||||
4. Kontaktiere den Support mit Log-Auszug
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.2
|
||||
**Letztes Update:** 2026-01-19
|
||||
292
public/_cabinet/QUICK_START.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Cabinet Digital Signage - Quick Start Guide
|
||||
|
||||
## 🚀 Schnellstart nach Video-Optimierung
|
||||
|
||||
### Was wurde geändert?
|
||||
|
||||
Die Display-Software wurde **massiv optimiert** um das Problem mit schwarzen Bildschirmen nach längerer Laufzeit zu beheben.
|
||||
|
||||
### ✅ Version 1.3 Features:
|
||||
|
||||
1. **Video-Cleanup** - Speicher wird nach jedem Video freigegeben
|
||||
2. **Watchdog** - Überwacht ob Videos laufen und recovered automatisch
|
||||
3. **Start-Timeout** - Videos die nicht starten werden übersprungen
|
||||
4. **Error Recovery** - Automatischer Skip bei defekten Videos
|
||||
5. **Memory-Monitoring** - Speicherüberwachung alle 10 Minuten
|
||||
6. **Präventiver Reload** - Automatischer Neustart alle 6 Stunden
|
||||
7. **Performance-Boost** - CSS Hardware-Beschleunigung aktiviert
|
||||
|
||||
---
|
||||
|
||||
## 📱 Display Setup
|
||||
|
||||
### 1. Display aufrufen
|
||||
```
|
||||
https://cabinet.b2in.eu
|
||||
```
|
||||
|
||||
### 2. Vollbild aktivieren
|
||||
- Klicke auf **"V 1.3"** Button oben links
|
||||
- Display wechselt in Vollbildmodus
|
||||
- Button verschwindet automatisch
|
||||
|
||||
⚠️ **WICHTIG:** Bei einem Page-Reload (nach 6h oder bei Fehlern) wird der Vollbildmodus beendet. Du siehst dann einen **orange pulsierenden Button** mit "⚠️ Vollbild aktivieren!". Einfach erneut klicken.
|
||||
|
||||
💡 **Besser:** Nutze **Fully Kiosk Browser** für permanenten Vollbild ohne manuelles Klicken. Siehe `KIOSK_MODE_SETUP.md`
|
||||
|
||||
### 3. Laufen lassen
|
||||
- Display läuft jetzt automatisch 24/7
|
||||
- Automatischer Reload alle 6 Stunden
|
||||
- Watchdog überwacht Video-Playback
|
||||
- Bei Problemen: Automatische Self-Recovery
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Log-Viewer öffnen:
|
||||
```
|
||||
https://cabinet.b2in.eu/view-logs.php
|
||||
```
|
||||
|
||||
### Was solltest du sehen:
|
||||
|
||||
#### ✅ Normale Logs (alles OK):
|
||||
```
|
||||
[INFO] Video started: video1.mp4
|
||||
[INFO] Video cleanup durchgeführt
|
||||
[INFO] Heartbeat - Display is running
|
||||
[INFO] Memory Status: 45% (230MB / 512MB)
|
||||
[INFO] Playlist-Loop abgeschlossen
|
||||
```
|
||||
|
||||
#### ⚠️ Warnungen (beobachten):
|
||||
```
|
||||
[WARNING] Video stalled (buffering)
|
||||
[WARNING] Hohe Speicherauslastung (85%)
|
||||
[WARNING] Überspringe zum nächsten Video
|
||||
```
|
||||
|
||||
#### ❌ Fehler (Action nötig):
|
||||
```
|
||||
[ERROR] Video start timeout
|
||||
[ERROR] Video Error: MEDIA_ERR_NETWORK
|
||||
[ERROR] Kritischer Zustand erkannt
|
||||
[FATAL] JavaScript Error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Video-Upload Checklist
|
||||
|
||||
Beim Hochladen neuer Videos beachten:
|
||||
|
||||
### ✅ Must-Have:
|
||||
- [ ] Format: **MP4** (H.264 + AAC)
|
||||
- [ ] Auflösung: **Max 1920x1080**
|
||||
- [ ] Bitrate: **5-10 Mbps**
|
||||
- [ ] Dateigröße: **Max 100 MB**
|
||||
- [ ] Länge: **15-60 Sekunden** (optimal)
|
||||
|
||||
### ⚠️ Vermeiden:
|
||||
- ❌ Zu große Dateien (>100MB)
|
||||
- ❌ Zu hohe Bitrate (>10 Mbps)
|
||||
- ❌ Zu lange Videos (>3 Min)
|
||||
- ❌ Exotische Formate (MOV, AVI, WMV)
|
||||
- ❌ 4K Videos (overkill für Display)
|
||||
|
||||
### 🔧 Video optimieren (FFmpeg):
|
||||
```bash
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v libx264 -preset slow -crf 23 \
|
||||
-c:a aac -b:a 128k \
|
||||
-vf scale=1920:1080 \
|
||||
-movflags +faststart \
|
||||
output.mp4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Problem: Schwarzer Bildschirm
|
||||
|
||||
#### Schritt 1: Logs checken
|
||||
```
|
||||
→ Öffne view-logs.php
|
||||
→ Schaue nach ERROR oder FATAL Logs
|
||||
```
|
||||
|
||||
#### Schritt 2: Was sagt das Log?
|
||||
|
||||
**"Video start timeout"**
|
||||
- Video lädt nicht → Check Video-URL
|
||||
- Video zu groß → Komprimieren
|
||||
- Netzwerkproblem → Check Internet
|
||||
|
||||
**"Video Error: MEDIA_ERR_NETWORK"**
|
||||
- Netzwerk-Issue → Check Router/Internet
|
||||
- Server down → Check b2in.eu erreichbar
|
||||
|
||||
**"Video definitiv stuck"**
|
||||
- ✅ Watchdog hat recovered!
|
||||
- Video wurde übersprungen
|
||||
- Nächstes Video sollte laufen
|
||||
|
||||
**"Hohe Speicherauslastung"**
|
||||
- Videos zu groß → Komprimieren
|
||||
- Zu viele Videos → Playlist verkleinern
|
||||
- Warte auf automatischen Reload (alle 6h)
|
||||
|
||||
#### Schritt 3: Manuelle Actions
|
||||
|
||||
**Display neu laden:**
|
||||
```javascript
|
||||
// In Browser-Console (F12):
|
||||
location.reload();
|
||||
```
|
||||
|
||||
**Display komplett neustarten:**
|
||||
```
|
||||
1. Browser schließen
|
||||
2. Warten 10 Sekunden
|
||||
3. Browser neu öffnen
|
||||
4. URL aufrufen
|
||||
5. Vollbild aktivieren
|
||||
```
|
||||
|
||||
### Problem: Video buffert ständig
|
||||
|
||||
#### Ursachen:
|
||||
- Internetverbindung zu langsam
|
||||
- Video-Bitrate zu hoch
|
||||
- Netzwerk überlastet
|
||||
|
||||
#### Lösung:
|
||||
1. **Check Internet:** Speedtest machen
|
||||
2. **Videos optimieren:** Bitrate reduzieren (5 Mbps)
|
||||
3. **Playlist reduzieren:** Weniger Videos = weniger Daten
|
||||
4. **Router prüfen:** Neustart? Kabel OK?
|
||||
|
||||
### Problem: Footer läuft, Video nicht
|
||||
|
||||
#### Das war das Haupt-Problem! Jetzt gefixt durch:
|
||||
- ✅ Video-Cleanup nach jedem Video
|
||||
- ✅ Watchdog erkennt stuck Videos
|
||||
- ✅ Automatischer Skip/Recovery
|
||||
- ✅ Memory-Management
|
||||
- ✅ Präventiver Reload alle 6h
|
||||
|
||||
#### Falls es DOCH noch passiert:
|
||||
```
|
||||
1. Logs checken: view-logs.php
|
||||
2. Memory-Status prüfen
|
||||
3. Watchdog-Logs suchen
|
||||
4. Falls >3 Fehler: Display reload automatisch
|
||||
5. Falls nicht: Manuell reloaden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
### Bei Problemen:
|
||||
|
||||
1. **Logs sichern:**
|
||||
- Öffne view-logs.php
|
||||
- Download/Screenshot der Fehler
|
||||
- Besonders ERROR und FATAL Logs
|
||||
|
||||
2. **Info sammeln:**
|
||||
- Welches Display (IP/Standort)?
|
||||
- Wann trat Problem auf?
|
||||
- Was zeigen die Logs?
|
||||
- Memory-Status?
|
||||
|
||||
3. **Kontakt:**
|
||||
- Mit Log-Auszug
|
||||
- Screenshots
|
||||
- Display-Info
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Monitoring-Routine
|
||||
|
||||
### Täglich (optional):
|
||||
- [ ] Kurzer Blick auf Display (läuft es?)
|
||||
- [ ] Bei Problemen: Logs checken
|
||||
|
||||
### Wöchentlich:
|
||||
- [ ] Logs checken (view-logs.php)
|
||||
- [ ] Statistiken ansehen (Fatal/Error/Warning)
|
||||
- [ ] Memory-Status prüfen
|
||||
- [ ] Watchdog-Interventionen zählen
|
||||
|
||||
### Monatlich:
|
||||
- [ ] Alte Logs aufräumen (>30 Tage automatisch)
|
||||
- [ ] Video-Performance überprüfen
|
||||
- [ ] Playlist aktualisieren
|
||||
- [ ] Display-Uptime checken
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tipps & Tricks
|
||||
|
||||
### Performance optimieren:
|
||||
- Halte Playlist klein (5-10 Videos)
|
||||
- Optimiere Videos vor Upload
|
||||
- Nutze konsistente Video-Auflösung
|
||||
- Vermeide sehr lange Videos (>2 Min)
|
||||
|
||||
### Zuverlässigkeit erhöhen:
|
||||
- Lass präventiven Reload aktiv (6h)
|
||||
- Check Logs wöchentlich
|
||||
- Halte Internet-Verbindung stabil
|
||||
- Nutze kabelgebundenes Netzwerk statt WiFi
|
||||
|
||||
### Memory sparen:
|
||||
- Videos komprimieren (H.264, CRF 23)
|
||||
- Playlist auf 10 Videos limitieren
|
||||
- Preload='metadata' (bereits aktiv)
|
||||
- Automatischer Cleanup (bereits aktiv)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Neue Features nutzen
|
||||
|
||||
### Auto-Recovery:
|
||||
```
|
||||
Display recovered jetzt automatisch von:
|
||||
✅ Stuck Videos (Watchdog)
|
||||
✅ Video-Ladefehlern (Skip)
|
||||
✅ Start-Timeouts (Skip)
|
||||
✅ Memory-Problemen (Reload nach 6h)
|
||||
✅ Kritischen Fehlern (Reload)
|
||||
```
|
||||
|
||||
### Logging:
|
||||
```
|
||||
Alle Events werden geloggt:
|
||||
📊 Video-Start/Ende
|
||||
📊 Memory-Status
|
||||
📊 Fehler und Warnungen
|
||||
📊 Watchdog-Interventionen
|
||||
📊 Heartbeats (Display läuft)
|
||||
```
|
||||
|
||||
### Monitoring:
|
||||
```
|
||||
Live-Überwachung möglich:
|
||||
🔍 view-logs.php
|
||||
🔍 Auto-Refresh alle 10s
|
||||
🔍 Statistiken (Fatal/Error/Warning)
|
||||
🔍 Farbcodierung nach Schwere
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.3
|
||||
**Release:** 2026-01-19
|
||||
**Status:** ✅ Production Ready
|
||||
|
||||
🎉 **Viel Erfolg mit dem optimierten Display!**
|
||||
368
public/_cabinet/VIDEO_OPTIMIZATION_README.md
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
# Cabinet Digital Signage - Video-Optimierung & Robustness
|
||||
|
||||
## 🔧 Problem
|
||||
|
||||
Nach einer gewissen Laufzeit wurden die Videos nicht mehr angezeigt (schwarzer Bildschirm), obwohl der Footer weiterhin funktionierte. Dies deutet auf Memory-Leaks oder einen fehlerhaften Video-Loop hin.
|
||||
|
||||
## ✅ Implementierte Lösungen
|
||||
|
||||
### 1. **Robuster Video-Cleanup** (Memory-Management)
|
||||
|
||||
#### Was wurde gemacht:
|
||||
- **Explizites Video-Cleanup** vor jedem neuen Video:
|
||||
```javascript
|
||||
videoElement.pause();
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.load(); // Triggert Garbage Collection
|
||||
```
|
||||
- **100ms Delay** nach Cleanup, bevor neues Video geladen wird
|
||||
- **Preload auf 'metadata'** gesetzt (statt 'auto') → weniger Memory-Verbrauch
|
||||
|
||||
#### Warum das hilft:
|
||||
- Browser gibt Speicher des alten Videos frei
|
||||
- Verhindert Memory-Leaks bei langen Laufzeiten
|
||||
- Reduziert gleichzeitig geladene Video-Daten
|
||||
|
||||
### 2. **Video Watchdog** (Überwachung)
|
||||
|
||||
#### Was wurde gemacht:
|
||||
- **Watchdog läuft alle 5 Sekunden**
|
||||
- Prüft ob Video noch läuft (vergleicht `currentTime`)
|
||||
- Erkennt wenn Video "stecken bleibt"
|
||||
- Automatischer Skip zum nächsten Video nach 2x "stuck"
|
||||
|
||||
#### Was wird überwacht:
|
||||
```javascript
|
||||
- currentTime (bewegt sich das Video?)
|
||||
- isPaused (ist Video pausiert?)
|
||||
- hasEnded (ist Video zu Ende?)
|
||||
- isStuck (currentTime ändert sich nicht)
|
||||
```
|
||||
|
||||
#### Warum das hilft:
|
||||
- Erkennt frozen Videos automatisch
|
||||
- Verhindert dass Display hängen bleibt
|
||||
- Selbstheilende Funktion ohne manuellen Eingriff
|
||||
|
||||
### 3. **Start-Timeout** (10 Sekunden)
|
||||
|
||||
#### Was wurde gemacht:
|
||||
- Timeout von 10 Sekunden für Video-Start
|
||||
- Falls Video nicht innerhalb von 10s startet → Skip zum nächsten
|
||||
- Timeout wird geclearet wenn Video erfolgreich startet
|
||||
|
||||
#### Warum das hilft:
|
||||
- Verhindert endloses Warten bei defekten Videos
|
||||
- Display bleibt nicht schwarz wenn Video nicht lädt
|
||||
- Automatische Recovery
|
||||
|
||||
### 4. **Error Recovery** (Fehlerbehandlung)
|
||||
|
||||
#### Was wurde gemacht:
|
||||
- **Automatischer Skip** bei Video-Fehlern
|
||||
- **Consecutive Error Tracking** (zählt aufeinanderfolgende Fehler)
|
||||
- **Max 3 aufeinanderfolgende Fehler** → dann Page-Reload nach 30s
|
||||
- **Error-Logging** mit detaillierten Media Error Codes
|
||||
|
||||
#### Error Codes:
|
||||
- `MEDIA_ERR_ABORTED` (1) - Video-Laden abgebrochen
|
||||
- `MEDIA_ERR_NETWORK` (2) - Netzwerkfehler
|
||||
- `MEDIA_ERR_DECODE` (3) - Dekodierungsfehler
|
||||
- `MEDIA_ERR_SRC_NOT_SUPPORTED` (4) - Format nicht unterstützt
|
||||
|
||||
#### Warum das hilft:
|
||||
- Display recovered automatisch von Fehlern
|
||||
- Verhindert dass ein defektes Video alles blockiert
|
||||
- Bei wiederholten Problemen: Komplett-Neustart
|
||||
|
||||
### 5. **Memory-Monitoring** (Performance-Überwachung)
|
||||
|
||||
#### Was wurde gemacht:
|
||||
- **Memory-Check alle 10 Minuten**
|
||||
- Loggt Speicherverbrauch in MB und Prozent
|
||||
- **Warnung bei >80% Speicherauslastung**
|
||||
- Loggt Video-Buffer-Status
|
||||
|
||||
#### Beispiel-Log:
|
||||
```json
|
||||
{
|
||||
"message": "Memory Status",
|
||||
"context": {
|
||||
"usedMB": 245,
|
||||
"limitMB": 512,
|
||||
"percentUsed": 48
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Warum das hilft:
|
||||
- Frühzeitiges Erkennen von Memory-Problemen
|
||||
- Daten für Troubleshooting und Optimierung
|
||||
- Proaktives Monitoring statt reaktives Debugging
|
||||
|
||||
### 6. **Präventive Maßnahmen**
|
||||
|
||||
#### A) Präventiver Page-Reload (alle 6 Stunden)
|
||||
```javascript
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 6 * 60 * 60 * 1000);
|
||||
```
|
||||
- Verhindert Memory-Leaks über sehr lange Laufzeit
|
||||
- Fresh Start alle 6 Stunden
|
||||
- Erfolgt automatisch im Hintergrund
|
||||
|
||||
#### B) Critical Error Check (alle 30 Sekunden)
|
||||
- Überwacht kritische Zustände
|
||||
- Bei 3 kritischen Fehlern → Reload nach 5s
|
||||
- Selbstheilende Funktion
|
||||
|
||||
#### C) CSS Performance-Optimierungen
|
||||
```css
|
||||
#video-player {
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
```
|
||||
- Aktiviert Hardware-Beschleunigung
|
||||
- Reduziert Rendering-Last
|
||||
- Optimiert für Video-Playback
|
||||
|
||||
### 7. **Erweiterte Video-Events**
|
||||
|
||||
#### Neue Events die geloggt werden:
|
||||
- `ended` - Video zu Ende
|
||||
- `error` - Video-Fehler (mit Error Code)
|
||||
- `stalled` - Buffering (Video hängt)
|
||||
- `waiting` - Waiting for data
|
||||
- `playing` - Video spielt ab
|
||||
- `canplay` - Video kann abgespielt werden
|
||||
|
||||
#### Warum das hilft:
|
||||
- Vollständige Transparenz über Video-Status
|
||||
- Erkennen von Buffering-Problemen
|
||||
- Debuggen von Playback-Issues
|
||||
|
||||
## 📊 Monitoring & Debugging
|
||||
|
||||
### Was wird jetzt geloggt:
|
||||
|
||||
#### Erfolgreiche Events:
|
||||
```
|
||||
✅ Video cleanup durchgeführt
|
||||
✅ Video started: video1.mp4
|
||||
✅ Video läuft wieder normal
|
||||
✅ Playlist-Loop abgeschlossen, starte von vorne
|
||||
```
|
||||
|
||||
#### Warnungen:
|
||||
```
|
||||
⚠️ Video scheint stecken geblieben zu sein
|
||||
⚠️ Hohe Speicherauslastung (85%)
|
||||
⚠️ Video stalled (buffering)
|
||||
⚠️ Überspringe zum nächsten Video
|
||||
```
|
||||
|
||||
#### Fehler:
|
||||
```
|
||||
❌ Video start timeout (10s überschritten)
|
||||
❌ Video definitiv stuck - starte nächstes
|
||||
❌ Video Error: MEDIA_ERR_NETWORK
|
||||
❌ Kritischer Zustand erkannt
|
||||
```
|
||||
|
||||
### Log-Analyse:
|
||||
|
||||
#### Beispiel 1: Memory-Problem
|
||||
```
|
||||
[INFO] Memory Status: 420MB / 512MB (82%)
|
||||
[WARNING] Hohe Speicherauslastung
|
||||
→ Action: Beobachten, evtl. Videos optimieren
|
||||
```
|
||||
|
||||
#### Beispiel 2: Stuck Video
|
||||
```
|
||||
[WARNING] Video scheint stecken geblieben (2x)
|
||||
[ERROR] Video definitiv stuck - starte nächstes
|
||||
[WARNING] Überspringe zum nächsten Video: watchdog_stuck
|
||||
→ Action: Watchdog hat Recovery durchgeführt ✓
|
||||
```
|
||||
|
||||
#### Beispiel 3: Netzwerkprobleme
|
||||
```
|
||||
[ERROR] Video Error: MEDIA_ERR_NETWORK
|
||||
[WARNING] Überspringe zum nächsten Video: error_MEDIA_ERR_NETWORK
|
||||
[INFO] Video started: video2.mp4
|
||||
→ Action: Netzwerk kurz unterbrochen, automatisch recovered ✓
|
||||
```
|
||||
|
||||
## 🎯 Best Practices für Videos
|
||||
|
||||
### 1. **Video-Format**
|
||||
- **Container:** MP4 (H.264 + AAC)
|
||||
- **Codec:** H.264 (High Profile, Level 4.0)
|
||||
- **Audio:** AAC, 128-256 kbps
|
||||
- **Auflösung:** Max 1920x1080 (Full HD)
|
||||
- **Framerate:** 25 oder 30 fps
|
||||
- **Bitrate:** 5-10 Mbps (nicht höher!)
|
||||
|
||||
### 2. **Video-Länge**
|
||||
- **Optimal:** 15-60 Sekunden
|
||||
- **Maximum:** 2-3 Minuten
|
||||
- **Warum:** Kürzere Videos = weniger Memory-Verbrauch
|
||||
|
||||
### 3. **Dateigrößen**
|
||||
- **Optimal:** 10-50 MB pro Video
|
||||
- **Maximum:** 100 MB pro Video
|
||||
- **Warum:** Schnelleres Laden, weniger Buffering
|
||||
|
||||
### 4. **Playlist-Größe**
|
||||
- **Optimal:** 5-10 Videos
|
||||
- **Maximum:** 20 Videos
|
||||
- **Warum:** Übersichtlich, nicht zu viel Content im Loop
|
||||
|
||||
### 5. **Video-Optimierung**
|
||||
Nutze Tools wie:
|
||||
- **FFmpeg** für Re-Encoding
|
||||
- **HandBrake** für Kompression
|
||||
- **Adobe Media Encoder** für Profis
|
||||
|
||||
#### FFmpeg Beispiel:
|
||||
```bash
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v libx264 -preset slow -crf 23 \
|
||||
-c:a aac -b:a 128k \
|
||||
-vf scale=1920:1080 \
|
||||
-movflags +faststart \
|
||||
output.mp4
|
||||
```
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Problem: Videos werden nach einiger Zeit schwarz
|
||||
|
||||
#### Mögliche Ursachen:
|
||||
1. **Memory-Leak** → Check Memory-Logs
|
||||
2. **Video zu groß** → Komprimieren
|
||||
3. **Netzwerkprobleme** → Check Network-Logs
|
||||
4. **Browser-Cache voll** → Wird jetzt automatisch gecleart
|
||||
|
||||
#### Lösung:
|
||||
- ✅ Implementiert: Automatischer Cleanup
|
||||
- ✅ Implementiert: Watchdog erkennt Problem
|
||||
- ✅ Implementiert: Automatischer Skip/Recovery
|
||||
- ✅ Implementiert: Präventiver Reload nach 6h
|
||||
|
||||
### Problem: Video startet nicht
|
||||
|
||||
#### Check in Logs:
|
||||
```
|
||||
[ERROR] Video start timeout
|
||||
[ERROR] Video play failed: MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
```
|
||||
|
||||
#### Lösung:
|
||||
1. **Video-Format prüfen** (MP4 H.264?)
|
||||
2. **Video-Pfad prüfen** (erreichbar?)
|
||||
3. **Video neu encodieren**
|
||||
4. **Watchdog springt automatisch zum nächsten**
|
||||
|
||||
### Problem: Video buffert ständig
|
||||
|
||||
#### Check in Logs:
|
||||
```
|
||||
[WARNING] Video stalled (buffering)
|
||||
[WARNING] Video waiting (buffering)
|
||||
```
|
||||
|
||||
#### Lösung:
|
||||
1. **Netzwerkverbindung prüfen**
|
||||
2. **Video-Bitrate reduzieren**
|
||||
3. **Preload auf 'metadata'** (bereits implementiert)
|
||||
4. **Video komprimieren**
|
||||
|
||||
### Problem: Hohe Speicherauslastung
|
||||
|
||||
#### Check in Logs:
|
||||
```
|
||||
[WARNING] Hohe Speicherauslastung (85%)
|
||||
```
|
||||
|
||||
#### Lösung:
|
||||
1. **Videos komprimieren**
|
||||
2. **Playlist verkleinern**
|
||||
3. **Präventiver Reload** (bereits aktiv nach 6h)
|
||||
4. **Browser-Cache leeren** (manuell)
|
||||
|
||||
## 📈 Performance-Metriken
|
||||
|
||||
### Empfohlene Werte:
|
||||
- **Memory Usage:** < 70% des Heap-Limits
|
||||
- **Video Start Time:** < 2 Sekunden
|
||||
- **Buffering Events:** < 1 pro Stunde
|
||||
- **Consecutive Errors:** 0
|
||||
- **Watchdog Interventions:** < 1 pro Tag
|
||||
|
||||
### Critical Werte (Action required):
|
||||
- **Memory Usage:** > 85%
|
||||
- **Video Start Time:** > 10 Sekunden (Timeout!)
|
||||
- **Buffering Events:** > 5 pro Stunde
|
||||
- **Consecutive Errors:** ≥ 3
|
||||
- **Watchdog Interventions:** > 10 pro Tag
|
||||
|
||||
## 🚀 Testing
|
||||
|
||||
### 1. **Test im Browser**
|
||||
```
|
||||
1. Öffne https://cabinet.b2in.eu
|
||||
2. Öffne Developer Tools (F12)
|
||||
3. Console Tab öffnen
|
||||
4. Logs beobachten:
|
||||
- "Video cleanup durchgeführt"
|
||||
- "Video started: ..."
|
||||
- Memory-Status nach 30s
|
||||
```
|
||||
|
||||
### 2. **Stress-Test**
|
||||
```
|
||||
1. Lass Display 24h laufen
|
||||
2. Check Logs auf Probleme
|
||||
3. Memory-Status nach 24h prüfen
|
||||
4. Watchdog-Interventionen zählen
|
||||
```
|
||||
|
||||
### 3. **Network-Test**
|
||||
```
|
||||
1. Simuliere schlechte Verbindung (DevTools → Network → Throttling)
|
||||
2. Beobachte Error-Recovery
|
||||
3. Check ob automatischer Skip funktioniert
|
||||
```
|
||||
|
||||
### 4. **Memory-Test**
|
||||
```
|
||||
1. Lass Display 6h laufen
|
||||
2. Check Memory-Logs alle 10 Min
|
||||
3. Sollte < 70% bleiben
|
||||
4. Nach 6h: automatischer Reload
|
||||
```
|
||||
|
||||
## 📋 Changelog
|
||||
|
||||
### Version 1.3 (2026-01-19)
|
||||
- ✅ Video-Cleanup vor jedem neuen Video
|
||||
- ✅ Video Watchdog (5s Interval)
|
||||
- ✅ Start-Timeout (10s)
|
||||
- ✅ Error Recovery mit Consecutive Error Tracking
|
||||
- ✅ Memory-Monitoring (alle 10 Min)
|
||||
- ✅ Präventiver Reload (alle 6h)
|
||||
- ✅ Critical Error Check (alle 30s)
|
||||
- ✅ CSS Performance-Optimierungen
|
||||
- ✅ Erweiterte Video-Events (playing, canplay, waiting)
|
||||
- ✅ Detaillierte Error-Codes mit Logging
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Ready for Production
|
||||
**Tested on:** Chrome 120+ / Android 11+
|
||||
**Last Update:** 2026-01-19
|
||||
BIN
public/_cabinet/assets/fruehjahr_2024.mp4
Normal file
BIN
public/_cabinet/assets/fruehjahr_2025.mp4
Normal file
BIN
public/_cabinet/assets/herbst_2024.mp4
Normal file
BIN
public/_cabinet/assets/herbst_2025.mp4
Normal file
11
public/_cabinet/clicks.log
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
2025-12-04 10:04:05 - Scanned: pinterest
|
||||
2025-12-18 13:49:51 - Code: pbay2d - Headline: Beratung & Termin - URL: https://cabinet.b2in.eu/go.php?z=t
|
||||
2025-12-18 13:50:12 - Code: ihvb0j - Headline: Beratung vor Ort - URL: https://cabinet.b2in.eu/go.php?z=t1
|
||||
2025-12-18 13:50:12 - Code: ihvb0j - Headline: Beratung vor Ort - URL: https://cabinet.b2in.eu/go.php?z=t1
|
||||
2025-12-18 13:50:12 - Code: ihvb0j - Headline: Beratung vor Ort - URL: https://cabinet.b2in.eu/go.php?z=t1
|
||||
2025-12-18 13:50:12 - Code: ihvb0j - Headline: Beratung vor Ort - URL: https://cabinet.b2in.eu/go.php?z=t1
|
||||
2025-12-18 13:50:12 - Code: ihvb0j - Headline: Beratung vor Ort - URL: https://cabinet.b2in.eu/go.php?z=t1
|
||||
2025-12-18 13:51:01 - Code: k7wrsh - Headline: Instagram - URL: https://cabinet.b2in.eu/go.php?z=i
|
||||
2025-12-18 13:52:57 - Code: wao7uv - Headline: Beratung & Termin - URL: https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393
|
||||
2025-12-18 13:53:33 - Code: wao7uv - Headline: Beratung & Termin - URL: https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393
|
||||
2025-12-18 14:01:26 - Code: c59kjb - Headline: Beratung & Termin - URL: https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393
|
||||
87
public/_cabinet/go.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
/*
|
||||
Redirect-Script mit Datenbanklogging für Display-Footer-Inhalte
|
||||
Funktioniert sowohl in _display-b2in-eu als auch in _cabinet
|
||||
Aufruf via: https://cabinet.b2in.eu/go.php?z=abc123
|
||||
*/
|
||||
|
||||
// Datenbankverbindung direkt (ohne Laravel Bootstrap)
|
||||
// Lade .env Datei
|
||||
$envFile = __DIR__ . '/../../.env';
|
||||
if (file_exists($envFile)) {
|
||||
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
foreach ($lines as $line) {
|
||||
if (strpos(trim($line), '#') === 0) continue;
|
||||
if (strpos($line, '=') === false) continue;
|
||||
list($name, $value) = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
if (!getenv($name)) {
|
||||
putenv("$name=$value");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dbHost = getenv('DB_HOST') ?: 'mysql';
|
||||
$dbName = getenv('DB_DATABASE') ?: 'b2in';
|
||||
$dbUser = getenv('DB_USERNAME') ?: 'sail';
|
||||
$dbPass = getenv('DB_PASSWORD') ?: 'password';
|
||||
|
||||
$shortCode = isset($_GET['z']) ? $_GET['z'] : '';
|
||||
|
||||
if (empty($shortCode)) {
|
||||
http_response_code(404);
|
||||
echo "Kein Ziel angegeben.";
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
// PDO-Verbindung zur Datenbank
|
||||
$pdo = new PDO("mysql:host={$dbHost};dbname={$dbName};charset=utf8mb4", $dbUser, $dbPass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
|
||||
// Suche den Footer-Content mit diesem Short-Code
|
||||
$stmt = $pdo->prepare("SELECT * FROM display_footer_contents WHERE short_code = ? LIMIT 1");
|
||||
$stmt->execute([$shortCode]);
|
||||
$footerContent = $stmt->fetch();
|
||||
|
||||
if ($footerContent) {
|
||||
// Klicks erhöhen
|
||||
$updateStmt = $pdo->prepare("UPDATE display_footer_contents SET clicks = clicks + 1 WHERE id = ?");
|
||||
$updateStmt->execute([$footerContent['id']]);
|
||||
|
||||
// Optional: Logging in Datei
|
||||
$logEntry = date('Y-m-d H:i:s') . " - Code: {$shortCode} - Headline: {$footerContent['headline']} - URL: {$footerContent['url']}\n";
|
||||
@file_put_contents(__DIR__ . '/clicks.log', $logEntry, FILE_APPEND);
|
||||
|
||||
// Redirect zur Original-URL
|
||||
header("Location: " . $footerContent['url']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fallback: Alte Codes für Rückwärtskompatibilität
|
||||
$alteCodes = [
|
||||
't' => 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393',
|
||||
't1' => 'https://www.cabinet.de/bielefeld?utm_source=store_display&utm_medium=qr_code&utm_campaign=bielefeld_pos&utm_content=termin_buchung#c39393',
|
||||
'p' => 'https://de.pinterest.com/cabinet_AG/',
|
||||
'i' => 'https://www.instagram.com/cabinet_schranksysteme/',
|
||||
'f' => 'https://de-de.facebook.com/cabinetschranksysteme/'
|
||||
];
|
||||
|
||||
if (isset($alteCodes[$shortCode])) {
|
||||
$logEntry = date('Y-m-d H:i:s') . " - Legacy Code: {$shortCode}\n";
|
||||
@file_put_contents(__DIR__ . '/clicks.log', $logEntry, FILE_APPEND);
|
||||
|
||||
header("Location: " . $alteCodes[$shortCode]);
|
||||
exit;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
echo "Ziel nicht gefunden.";
|
||||
} catch (PDOException $e) {
|
||||
error_log("Display Go.php Database Error: " . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo "Ein Fehler ist aufgetreten.";
|
||||
}
|
||||
412
public/_cabinet/index copy.html
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Digital Signage Bielefeld</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* --- GRUNDGERÜST --- */
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
/* Seitenverhältnis 9:16 (1080:1920) beibehalten */
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm breiter als 9:16 ist, nach Höhe skalieren */
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm höher als 9:16 ist, nach Breite skalieren */
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- VIDEO BEREICH (Oben) --- */
|
||||
#video-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Video füllt den Bereich randlos */
|
||||
object-position: center 15%; /* Fallback-Position, wird per JavaScript überschrieben */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- FOOTER BEREICH (Unten - ca. 16.67% der Höhe = 320px bei 1920px) --- */
|
||||
#footer {
|
||||
height: 9.67vh;
|
||||
min-height: 100px;
|
||||
background-color: #1a1a1a; /* Dunkelgrau */
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px; /* 60px bei 1080px Breite */
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Progress Bar am oberen Rand des Footers */
|
||||
#progress-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background-color: #009FE3; /* Cabinet Blau */
|
||||
width: 0%;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#progress-bar.animate {
|
||||
animation: progressAnimation 30s linear;
|
||||
}
|
||||
|
||||
@keyframes progressAnimation {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- INHALTE IM FOOTER --- */
|
||||
.cta-text-container {
|
||||
width: 75%;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-size: 2.0em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.cta-subline {
|
||||
font-size: 2.4em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.qr-code-img {
|
||||
width: 8em; /* Relativ zur Footer-Schriftgröße */
|
||||
height: auto;
|
||||
max-width: 100px;
|
||||
aspect-ratio: 1 / 1; /* Erzwingt quadratische Form */
|
||||
object-fit: contain;
|
||||
background-color: white; /* Weißer Hintergrund für Lesbarkeit */
|
||||
padding: 0.4em;
|
||||
border-radius: 0.6em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scan-hint {
|
||||
margin-top: 0.8em;
|
||||
font-size: 1.3em; /* Relativ zur Footer-Schriftgröße */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: #009FE3; /* Akzentfarbe */
|
||||
}
|
||||
|
||||
/* Hilfsklasse für den Überblend-Effekt */
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2em;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="main-container">
|
||||
<!-- VIDEO BEREICH -->
|
||||
<div id="video-wrapper">
|
||||
<!-- Videos werden hier geladen. 'muted' ist oft nötig für Autoplay -->
|
||||
<video id="video-player" autoplay muted playsinline></video>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER BEREICH -->
|
||||
<div id="footer">
|
||||
<div id="progress-bar"></div>
|
||||
<div class="cta-text-container" id="text-area">
|
||||
<div class="cta-headline" id="headline">LADEN...</div>
|
||||
<div class="cta-subline" id="subline">Inhalte werden geladen</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-container" id="qr-area">
|
||||
<img src="" id="qr-image" class="qr-code-img" alt="QR Code">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ==============================================
|
||||
KONFIGURATION WIRD DYNAMISCH GELADEN
|
||||
============================================== */
|
||||
|
||||
let videoPlaylist = [];
|
||||
let footerContent = [];
|
||||
let footerContentLength = 0;
|
||||
|
||||
// Basis-URL für Assets und API (b2in.eu Server)
|
||||
const BASE_URL = 'https://b2in.eu';
|
||||
|
||||
// API-URL für die Konfiguration (CORS ist aktiviert für cabinet.b2in.eu)
|
||||
const API_URL = BASE_URL + '/api/display/config';
|
||||
|
||||
/* ==============================================
|
||||
KONFIGURATION LADEN
|
||||
============================================== */
|
||||
|
||||
async function loadConfiguration() {
|
||||
try {
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Konfiguration');
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
videoPlaylist = config.videoPlaylist || [];
|
||||
footerContent = config.footerContent || [];
|
||||
|
||||
console.log('Konfiguration geladen:', config);
|
||||
|
||||
// Überprüfe, ob Videos vorhanden sind
|
||||
if (videoPlaylist.length === 0) {
|
||||
console.warn('Keine Videos in der Playlist vorhanden');
|
||||
document.getElementById('headline').innerText = 'KEINE VIDEOS';
|
||||
document.getElementById('subline').innerText = 'Bitte fügen Sie Videos im CMS hinzu';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Überprüfe, ob Footer-Inhalte vorhanden sind
|
||||
if (footerContent.length === 0) {
|
||||
console.warn('Keine Footer-Inhalte vorhanden - Footer wird ausgeblendet');
|
||||
footerContentLength = 0;
|
||||
// Footer ausblenden
|
||||
const footer = document.getElementById('footer');
|
||||
if (footer) {
|
||||
footer.style.display = 'none';
|
||||
}
|
||||
// Video-Wrapper auf 100% Höhe setzen
|
||||
const videoWrapper = document.getElementById('video-wrapper');
|
||||
if (videoWrapper) {
|
||||
videoWrapper.style.flexGrow = '1';
|
||||
videoWrapper.style.height = '100%';
|
||||
}
|
||||
} else {
|
||||
// Footer anzeigen, falls er zuvor ausgeblendet wurde
|
||||
const footer = document.getElementById('footer');
|
||||
if (footer) {
|
||||
footer.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konfiguration:', error);
|
||||
document.getElementById('headline').innerText = 'FEHLER';
|
||||
document.getElementById('subline').innerText = 'Konfiguration konnte nicht geladen werden';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
PROGRAMM-LOGIK
|
||||
============================================== */
|
||||
|
||||
// --- VIDEO PLAYER LOGIC ---
|
||||
const videoElement = document.getElementById('video-player');
|
||||
let currentVideoIndex = 0;
|
||||
|
||||
function playNextVideo() {
|
||||
if (videoPlaylist.length === 0) return;
|
||||
|
||||
const video = videoPlaylist[currentVideoIndex];
|
||||
// Videos von b2in.eu laden (absolute URL)
|
||||
videoElement.src = BASE_URL + "/_cabinet/" + video.src;
|
||||
if(footerContentLength !== 0) {
|
||||
videoElement.style.objectPosition = `center ${video.position}%`;
|
||||
}
|
||||
videoElement.play().catch(e => console.log("Autoplay blocked/failed", e));
|
||||
|
||||
currentVideoIndex++;
|
||||
if (currentVideoIndex >= videoPlaylist.length) {
|
||||
currentVideoIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
videoElement.addEventListener('ended', playNextVideo);
|
||||
|
||||
// --- FOOTER ROTATION LOGIC ---
|
||||
let currentFooterIndex = 0;
|
||||
const textArea = document.getElementById('text-area');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const headlineEl = document.getElementById('headline');
|
||||
const sublineEl = document.getElementById('subline');
|
||||
const qrImageEl = document.getElementById('qr-image');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
function restartProgressBar() {
|
||||
// Animation zurücksetzen und neu starten
|
||||
progressBar.classList.remove('animate');
|
||||
void progressBar.offsetWidth; // Force reflow
|
||||
progressBar.classList.add('animate');
|
||||
}
|
||||
|
||||
function updateFooter() {
|
||||
if (footerContent.length === 0) return;
|
||||
|
||||
// 1. Ausblenden
|
||||
textArea.classList.add('fade-out');
|
||||
qrArea.classList.add('fade-out');
|
||||
|
||||
// Progress Bar neu starten
|
||||
restartProgressBar();
|
||||
|
||||
// 2. Warten, Inhalt tauschen, Einblenden
|
||||
setTimeout(() => {
|
||||
const content = footerContent[currentFooterIndex];
|
||||
|
||||
// Text setzen
|
||||
headlineEl.innerText = content.headline;
|
||||
sublineEl.innerText = content.subline;
|
||||
|
||||
// QR Code nur generieren wenn URL vorhanden
|
||||
if (content.url) {
|
||||
// QR Code generieren (API Aufruf)
|
||||
const qrSize = "300x300";
|
||||
const qrColor = "000000"; // Schwarz
|
||||
const qrBg = "ffffff"; // Weiß
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${qrSize}&color=${qrColor}&bgcolor=${qrBg}&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
|
||||
qrImageEl.src = qrUrl;
|
||||
qrArea.style.display = 'flex'; // QR-Bereich anzeigen
|
||||
} else {
|
||||
// Kein QR-Code - QR-Bereich ausblenden
|
||||
qrArea.style.display = 'none';
|
||||
// Text-Container auf volle Breite
|
||||
textArea.style.width = '100%';
|
||||
}
|
||||
|
||||
// Index weiterschalten
|
||||
currentFooterIndex++;
|
||||
if (currentFooterIndex >= footerContent.length) {
|
||||
currentFooterIndex = 0;
|
||||
}
|
||||
|
||||
// 3. Einblenden
|
||||
if (content.url) {
|
||||
qrImageEl.onload = () => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
};
|
||||
}
|
||||
// Fallback falls Bild sofort da ist (Cache) oder kein QR-Code
|
||||
setTimeout(() => {
|
||||
textArea.classList.remove('fade-out');
|
||||
if (content.url) {
|
||||
qrArea.classList.remove('fade-out');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
}, 1000); // 1 Sekunde für Fade-Out Animation
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
INITIALISIERUNG
|
||||
============================================== */
|
||||
|
||||
async function initialize() {
|
||||
const success = await loadConfiguration();
|
||||
|
||||
if (success && videoPlaylist.length > 0) {
|
||||
// Start Video
|
||||
playNextVideo();
|
||||
|
||||
// Start Footer Loop
|
||||
if (footerContent.length > 0) {
|
||||
updateFooter();
|
||||
setInterval(updateFooter, 30000); // Alle 30.000 ms (30 sek) wechseln
|
||||
|
||||
// Progress Bar initial starten
|
||||
restartProgressBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Laden der Seite initialisieren
|
||||
initialize();
|
||||
|
||||
// Optional: Auto-Reload alle 5 Minuten, um neue Inhalte zu laden
|
||||
setInterval(async () => {
|
||||
console.log('Prüfe auf neue Konfiguration...');
|
||||
const oldFooterCount = footerContent.length;
|
||||
await loadConfiguration();
|
||||
|
||||
// Wenn Footer-Inhalte hinzugefügt oder entfernt wurden, Seite neu laden
|
||||
if ((oldFooterCount === 0 && footerContent.length > 0) ||
|
||||
(oldFooterCount > 0 && footerContent.length === 0)) {
|
||||
console.log('Footer-Status hat sich geändert - Seite wird neu geladen');
|
||||
location.reload();
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 Minuten
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
371
public/_cabinet/index-dynamic.html
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Digital Signage Bielefeld</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* --- GRUNDGERÜST --- */
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
/* Seitenverhältnis 9:16 (1080:1920) beibehalten */
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm breiter als 9:16 ist, nach Höhe skalieren */
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm höher als 9:16 ist, nach Breite skalieren */
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- VIDEO BEREICH (Oben) --- */
|
||||
#video-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Video füllt den Bereich randlos */
|
||||
object-position: center 15%; /* Fallback-Position, wird per JavaScript überschrieben */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- FOOTER BEREICH (Unten - ca. 16.67% der Höhe = 320px bei 1920px) --- */
|
||||
#footer {
|
||||
height: 9.67vh;
|
||||
min-height: 100px;
|
||||
background-color: #1a1a1a; /* Dunkelgrau */
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px; /* 60px bei 1080px Breite */
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Progress Bar am oberen Rand des Footers */
|
||||
#progress-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background-color: #009FE3; /* Cabinet Blau */
|
||||
width: 0%;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#progress-bar.animate {
|
||||
animation: progressAnimation 30s linear;
|
||||
}
|
||||
|
||||
@keyframes progressAnimation {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- INHALTE IM FOOTER --- */
|
||||
.cta-text-container {
|
||||
width: 75%;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-size: 2.0em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.cta-subline {
|
||||
font-size: 2.4em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.qr-code-img {
|
||||
width: 8em; /* Relativ zur Footer-Schriftgröße */
|
||||
height: auto;
|
||||
max-width: 100px;
|
||||
aspect-ratio: 1 / 1; /* Erzwingt quadratische Form */
|
||||
object-fit: contain;
|
||||
background-color: white; /* Weißer Hintergrund für Lesbarkeit */
|
||||
padding: 0.4em;
|
||||
border-radius: 0.6em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scan-hint {
|
||||
margin-top: 0.8em;
|
||||
font-size: 1.3em; /* Relativ zur Footer-Schriftgröße */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: #009FE3; /* Akzentfarbe */
|
||||
}
|
||||
|
||||
/* Hilfsklasse für den Überblend-Effekt */
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2em;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="main-container">
|
||||
<!-- VIDEO BEREICH -->
|
||||
<div id="video-wrapper">
|
||||
<!-- Videos werden hier geladen. 'muted' ist oft nötig für Autoplay -->
|
||||
<video id="video-player" autoplay muted playsinline></video>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER BEREICH -->
|
||||
<div id="footer">
|
||||
<div id="progress-bar"></div>
|
||||
<div class="cta-text-container" id="text-area">
|
||||
<div class="cta-headline" id="headline">LADEN...</div>
|
||||
<div class="cta-subline" id="subline">Inhalte werden geladen</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-container" id="qr-area">
|
||||
<img src="" id="qr-image" class="qr-code-img" alt="QR Code">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ==============================================
|
||||
KONFIGURATION WIRD DYNAMISCH GELADEN
|
||||
============================================== */
|
||||
|
||||
let videoPlaylist = [];
|
||||
let footerContent = [];
|
||||
|
||||
// API-URL für die Konfiguration
|
||||
const API_URL = '/api/display/config';
|
||||
|
||||
/* ==============================================
|
||||
KONFIGURATION LADEN
|
||||
============================================== */
|
||||
|
||||
async function loadConfiguration() {
|
||||
try {
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Konfiguration');
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
videoPlaylist = config.videoPlaylist || [];
|
||||
footerContent = config.footerContent || [];
|
||||
|
||||
console.log('Konfiguration geladen:', config);
|
||||
|
||||
// Überprüfe, ob Videos vorhanden sind
|
||||
if (videoPlaylist.length === 0) {
|
||||
console.warn('Keine Videos in der Playlist vorhanden');
|
||||
document.getElementById('headline').innerText = 'KEINE VIDEOS';
|
||||
document.getElementById('subline').innerText = 'Bitte fügen Sie Videos im CMS hinzu';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Überprüfe, ob Footer-Inhalte vorhanden sind
|
||||
if (footerContent.length === 0) {
|
||||
console.warn('Keine Footer-Inhalte vorhanden');
|
||||
footerContent = [{
|
||||
headline: 'WILLKOMMEN',
|
||||
subline: 'Bitte fügen Sie Footer-Inhalte im CMS hinzu',
|
||||
url: 'https://cabinet.b2in.eu'
|
||||
}];
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konfiguration:', error);
|
||||
document.getElementById('headline').innerText = 'FEHLER';
|
||||
document.getElementById('subline').innerText = 'Konfiguration konnte nicht geladen werden';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
PROGRAMM-LOGIK
|
||||
============================================== */
|
||||
|
||||
// --- VIDEO PLAYER LOGIC ---
|
||||
const videoElement = document.getElementById('video-player');
|
||||
let currentVideoIndex = 0;
|
||||
|
||||
function playNextVideo() {
|
||||
if (videoPlaylist.length === 0) return;
|
||||
|
||||
const video = videoPlaylist[currentVideoIndex];
|
||||
videoElement.src = video.src;
|
||||
videoElement.style.objectPosition = `center ${video.position}%`;
|
||||
videoElement.play().catch(e => console.log("Autoplay blocked/failed", e));
|
||||
|
||||
currentVideoIndex++;
|
||||
if (currentVideoIndex >= videoPlaylist.length) {
|
||||
currentVideoIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
videoElement.addEventListener('ended', playNextVideo);
|
||||
|
||||
// --- FOOTER ROTATION LOGIC ---
|
||||
let currentFooterIndex = 0;
|
||||
const textArea = document.getElementById('text-area');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const headlineEl = document.getElementById('headline');
|
||||
const sublineEl = document.getElementById('subline');
|
||||
const qrImageEl = document.getElementById('qr-image');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
function restartProgressBar() {
|
||||
// Animation zurücksetzen und neu starten
|
||||
progressBar.classList.remove('animate');
|
||||
void progressBar.offsetWidth; // Force reflow
|
||||
progressBar.classList.add('animate');
|
||||
}
|
||||
|
||||
function updateFooter() {
|
||||
if (footerContent.length === 0) return;
|
||||
|
||||
// 1. Ausblenden
|
||||
textArea.classList.add('fade-out');
|
||||
qrArea.classList.add('fade-out');
|
||||
|
||||
// Progress Bar neu starten
|
||||
restartProgressBar();
|
||||
|
||||
// 2. Warten, Inhalt tauschen, Einblenden
|
||||
setTimeout(() => {
|
||||
const content = footerContent[currentFooterIndex];
|
||||
|
||||
// Text setzen
|
||||
headlineEl.innerText = content.headline;
|
||||
sublineEl.innerText = content.subline;
|
||||
|
||||
// QR Code generieren (API Aufruf)
|
||||
const qrSize = "300x300";
|
||||
const qrColor = "000000"; // Schwarz
|
||||
const qrBg = "ffffff"; // Weiß
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${qrSize}&color=${qrColor}&bgcolor=${qrBg}&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
|
||||
qrImageEl.src = qrUrl;
|
||||
|
||||
// Index weiterschalten
|
||||
currentFooterIndex++;
|
||||
if (currentFooterIndex >= footerContent.length) {
|
||||
currentFooterIndex = 0;
|
||||
}
|
||||
|
||||
// 3. Einblenden
|
||||
qrImageEl.onload = () => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
};
|
||||
// Fallback falls Bild sofort da ist (Cache)
|
||||
setTimeout(() => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
}, 100);
|
||||
|
||||
}, 1000); // 1 Sekunde für Fade-Out Animation
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
INITIALISIERUNG
|
||||
============================================== */
|
||||
|
||||
async function initialize() {
|
||||
const success = await loadConfiguration();
|
||||
|
||||
if (success && videoPlaylist.length > 0) {
|
||||
// Start Video
|
||||
playNextVideo();
|
||||
|
||||
// Start Footer Loop
|
||||
if (footerContent.length > 0) {
|
||||
updateFooter();
|
||||
setInterval(updateFooter, 30000); // Alle 30.000 ms (30 sek) wechseln
|
||||
|
||||
// Progress Bar initial starten
|
||||
restartProgressBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Laden der Seite initialisieren
|
||||
initialize();
|
||||
|
||||
// Optional: Auto-Reload alle 5 Minuten, um neue Inhalte zu laden
|
||||
setInterval(async () => {
|
||||
console.log('Prüfe auf neue Konfiguration...');
|
||||
await loadConfiguration();
|
||||
}, 5 * 60 * 1000); // 5 Minuten
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
331
public/_cabinet/index-static-backup.html
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Digital Signage Bielefeld</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* --- GRUNDGERÜST --- */
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
/* Seitenverhältnis 9:16 (1080:1920) beibehalten */
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm breiter als 9:16 ist, nach Höhe skalieren */
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm höher als 9:16 ist, nach Breite skalieren */
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- VIDEO BEREICH (Oben) --- */
|
||||
#video-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Video füllt den Bereich randlos */
|
||||
object-position: center 15%; /* Fallback-Position, wird per JavaScript überschrieben */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- FOOTER BEREICH (Unten - ca. 16.67% der Höhe = 320px bei 1920px) --- */
|
||||
#footer {
|
||||
height: 9.67vh;
|
||||
min-height: 100px;
|
||||
background-color: #1a1a1a; /* Dunkelgrau */
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px; /* 60px bei 1080px Breite */
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Progress Bar am oberen Rand des Footers */
|
||||
#progress-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background-color: #009FE3; /* Cabinet Blau */
|
||||
width: 0%;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#progress-bar.animate {
|
||||
animation: progressAnimation 30s linear;
|
||||
}
|
||||
|
||||
@keyframes progressAnimation {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- INHALTE IM FOOTER --- */
|
||||
.cta-text-container {
|
||||
width: 75%;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-size: 2.0em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.cta-subline {
|
||||
font-size: 2.4em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.qr-code-img {
|
||||
width: 8em; /* Relativ zur Footer-Schriftgröße */
|
||||
height: auto;
|
||||
max-width: 100px;
|
||||
aspect-ratio: 1 / 1; /* Erzwingt quadratische Form */
|
||||
object-fit: contain;
|
||||
background-color: white; /* Weißer Hintergrund für Lesbarkeit */
|
||||
padding: 0.4em;
|
||||
border-radius: 0.6em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scan-hint {
|
||||
margin-top: 0.8em;
|
||||
font-size: 1.3em; /* Relativ zur Footer-Schriftgröße */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: #009FE3; /* Akzentfarbe */
|
||||
}
|
||||
|
||||
/* Hilfsklasse für den Überblend-Effekt */
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="main-container">
|
||||
<!-- VIDEO BEREICH -->
|
||||
<div id="video-wrapper">
|
||||
<!-- Videos werden hier geladen. 'muted' ist oft nötig für Autoplay -->
|
||||
<video id="video-player" autoplay muted playsinline></video>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER BEREICH -->
|
||||
<div id="footer">
|
||||
<div id="progress-bar"></div>
|
||||
<div class="cta-text-container" id="text-area">
|
||||
<div class="cta-headline" id="headline">LADEN...</div>
|
||||
<div class="cta-subline" id="subline">Inhalte werden geladen</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-container" id="qr-area">
|
||||
<img src="" id="qr-image" class="qr-code-img" alt="QR Code">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ==============================================
|
||||
KONFIGURATION
|
||||
============================================== */
|
||||
|
||||
// 1. VIDEOS (Dateinamen und Position hier anpassen)
|
||||
// position: Prozentwert von 0% (ganz oben) bis 100% (ganz unten)
|
||||
const videoPlaylist = [
|
||||
{ src: "assets/herbst_2025.mp4", position: 25 },
|
||||
{ src: "assets/fruehjahr_2025.mp4", position: 10 },
|
||||
{ src: "assets/fruehjahr_2024.mp4", position: 25 },
|
||||
{ src: "assets/herbst_2024.mp4", position: 25 },
|
||||
];
|
||||
|
||||
// 2. INHALTE & LINKS
|
||||
// Ich habe deine Links hier eingetragen. Die Texte kannst du anpassen.
|
||||
const footerContent = [
|
||||
{
|
||||
headline: "Beratung & Termin",
|
||||
subline: "Jetzt Termin vereinbaren.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=t"
|
||||
},
|
||||
{
|
||||
headline: "Beratung vor Ort",
|
||||
subline: "Einfach reinkommen.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=t1"
|
||||
},
|
||||
{
|
||||
headline: "Pinterest",
|
||||
subline: "Inspirationen entdecken.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=p"
|
||||
},
|
||||
{
|
||||
headline: "Instagram",
|
||||
subline: "Tägliche Einblicke & Design.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=i"
|
||||
},
|
||||
{
|
||||
headline: "Facebook",
|
||||
subline: "News, Aktionen & Community.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=f"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
/* ==============================================
|
||||
PROGRAMM-LOGIK (Ab hier nichts ändern)
|
||||
============================================== */
|
||||
|
||||
// --- VIDEO PLAYER LOGIC ---
|
||||
const videoElement = document.getElementById('video-player');
|
||||
let currentVideoIndex = 0;
|
||||
|
||||
function playNextVideo() {
|
||||
const video = videoPlaylist[currentVideoIndex];
|
||||
videoElement.src = video.src;
|
||||
videoElement.style.objectPosition = `center ${video.position}%`;
|
||||
videoElement.play().catch(e => console.log("Autoplay blocked/failed", e));
|
||||
|
||||
currentVideoIndex++;
|
||||
if (currentVideoIndex >= videoPlaylist.length) {
|
||||
currentVideoIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
videoElement.addEventListener('ended', playNextVideo);
|
||||
|
||||
// Start Video
|
||||
if(videoPlaylist.length > 0) playNextVideo();
|
||||
|
||||
// --- FOOTER ROTATION LOGIC ---
|
||||
let currentFooterIndex = 0;
|
||||
const textArea = document.getElementById('text-area');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const headlineEl = document.getElementById('headline');
|
||||
const sublineEl = document.getElementById('subline');
|
||||
const qrImageEl = document.getElementById('qr-image');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
function restartProgressBar() {
|
||||
// Animation zurücksetzen und neu starten
|
||||
progressBar.classList.remove('animate');
|
||||
void progressBar.offsetWidth; // Force reflow
|
||||
progressBar.classList.add('animate');
|
||||
}
|
||||
|
||||
function updateFooter() {
|
||||
// 1. Ausblenden
|
||||
textArea.classList.add('fade-out');
|
||||
qrArea.classList.add('fade-out');
|
||||
|
||||
// Progress Bar neu starten
|
||||
restartProgressBar();
|
||||
|
||||
// 2. Warten, Inhalt tauschen, Einblenden
|
||||
setTimeout(() => {
|
||||
const content = footerContent[currentFooterIndex];
|
||||
|
||||
// Text setzen
|
||||
headlineEl.innerText = content.headline;
|
||||
sublineEl.innerText = content.subline;
|
||||
|
||||
// QR Code generieren (API Aufruf)
|
||||
// Wir nutzen 'qrserver.com', eine schnelle und kostenlose API
|
||||
const qrSize = "300x300";
|
||||
const qrColor = "000000"; // Schwarz
|
||||
const qrBg = "ffffff"; // Weiß
|
||||
// encodeURIComponent sorgt dafür, dass Sonderzeichen im Link funktionieren
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${qrSize}&color=${qrColor}&bgcolor=${qrBg}&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
|
||||
qrImageEl.src = qrUrl;
|
||||
|
||||
// Index weiterschalten
|
||||
currentFooterIndex++;
|
||||
if (currentFooterIndex >= footerContent.length) {
|
||||
currentFooterIndex = 0;
|
||||
}
|
||||
|
||||
// 3. Einblenden
|
||||
// Kurze Verzögerung damit das Bild Zeit hat zu laden (optisch schöner)
|
||||
qrImageEl.onload = () => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
};
|
||||
// Fallback falls Bild sofort da ist (Cache)
|
||||
setTimeout(() => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
}, 100);
|
||||
|
||||
}, 1000); // 1 Sekunde für Fade-Out Animation
|
||||
}
|
||||
|
||||
// Start Footer Loop
|
||||
updateFooter();
|
||||
setInterval(updateFooter, 30000); // Alle 30.000 ms (30 sek) wechseln
|
||||
|
||||
// Progress Bar initial starten
|
||||
restartProgressBar();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
991
public/_cabinet/index.html
Normal file
|
|
@ -0,0 +1,991 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Digital Signage Bielefeld</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
(function() {
|
||||
const LOG_URL = 'https://cabinet.b2in.eu/logger.php';
|
||||
|
||||
// Kontext-Informationen für besseres Debugging
|
||||
let appContext = {
|
||||
currentVideo: null,
|
||||
currentFooter: null,
|
||||
videoPlaylistLength: 0,
|
||||
footerContentLength: 0,
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
|
||||
// Logging-Funktion mit Kontext
|
||||
function sendLog(level, message, additionalData = {}) {
|
||||
try {
|
||||
const logData = {
|
||||
level: level,
|
||||
message: String(message),
|
||||
timestamp: new Date().toISOString(),
|
||||
context: {
|
||||
...appContext,
|
||||
...additionalData
|
||||
},
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
connection: navigator.onLine ? 'online' : 'offline'
|
||||
};
|
||||
|
||||
fetch(LOG_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(logData),
|
||||
keepalive: true // Wichtig für Logs beim Verlassen der Seite
|
||||
}).catch(() => {}); // Fehler beim Loggen ignorieren
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Globale Fehler abfangen
|
||||
window.onerror = function(msg, url, line, col, error) {
|
||||
sendLog('FATAL', `JavaScript Error: ${msg}`, {
|
||||
file: url,
|
||||
line: line,
|
||||
column: col,
|
||||
stack: error?.stack
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
// Unhandled Promise Rejections (sehr wichtig für async/await!)
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
sendLog('ERROR', `Unhandled Promise Rejection: ${event.reason}`, {
|
||||
promise: event.promise?.toString()
|
||||
});
|
||||
});
|
||||
|
||||
// Console.error überschreiben
|
||||
const originalError = console.error;
|
||||
console.error = function(...args) {
|
||||
sendLog('ERROR', `Console Error: ${args.join(' ')}`);
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
// Console.warn überschreiben (für Warnungen)
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
sendLog('WARNING', `Console Warning: ${args.join(' ')}`);
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Resource Loading Errors (z.B. Videos, Bilder)
|
||||
window.addEventListener('error', function(event) {
|
||||
if (event.target !== window) {
|
||||
const element = event.target;
|
||||
const tagName = element.tagName;
|
||||
const src = element.src || element.href;
|
||||
|
||||
sendLog('ERROR', `Resource Failed to Load: ${tagName}`, {
|
||||
src: src,
|
||||
type: tagName
|
||||
});
|
||||
}
|
||||
}, true); // useCapture = true, um alle Events zu fangen
|
||||
|
||||
// Online/Offline Status überwachen
|
||||
window.addEventListener('online', () => {
|
||||
sendLog('INFO', 'Connection restored');
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
sendLog('WARNING', 'Connection lost');
|
||||
});
|
||||
|
||||
// Heartbeat: Alle 5 Minuten ein "alive" Signal senden
|
||||
setInterval(() => {
|
||||
sendLog('INFO', 'Heartbeat - Display is running', {
|
||||
uptime: Math.floor((Date.now() - appContext.lastActivity) / 1000) + 's'
|
||||
});
|
||||
}, 5 * 60 * 1000); // Alle 5 Minuten
|
||||
|
||||
// Initial Log beim Start
|
||||
sendLog('INFO', 'Display started', {
|
||||
userAgent: navigator.userAgent,
|
||||
screen: `${screen.width}x${screen.height}`,
|
||||
url: window.location.href
|
||||
});
|
||||
|
||||
// Export für andere Scripts
|
||||
window.displayLogger = {
|
||||
log: (msg, data) => sendLog('INFO', msg, data),
|
||||
warn: (msg, data) => sendLog('WARNING', msg, data),
|
||||
error: (msg, data) => sendLog('ERROR', msg, data),
|
||||
setContext: (key, value) => { appContext[key] = value; }
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* --- GRUNDGERÜST --- */
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
/* Seitenverhältnis 9:16 (1080:1920) beibehalten */
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm breiter als 9:16 ist, nach Höhe skalieren */
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm höher als 9:16 ist, nach Breite skalieren */
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- VIDEO BEREICH (Oben) --- */
|
||||
#video-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Video füllt den Bereich randlos */
|
||||
object-position: center 15%; /* Fallback-Position, wird per JavaScript überschrieben */
|
||||
display: block;
|
||||
|
||||
/* Performance-Optimierungen für Video */
|
||||
will-change: transform; /* Hint für Browser-Optimierung */
|
||||
transform: translateZ(0); /* Hardware-Beschleunigung aktivieren */
|
||||
backface-visibility: hidden; /* Reduziert Rendering-Last */
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* --- FOOTER BEREICH (Unten - ca. 16.67% der Höhe = 320px bei 1920px) --- */
|
||||
#footer {
|
||||
height: 9.67vh;
|
||||
min-height: 100px;
|
||||
background-color: #1a1a1a; /* Dunkelgrau */
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px; /* 60px bei 1080px Breite */
|
||||
box-sizing: border-box;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Progress Bar am oberen Rand des Footers */
|
||||
#progress-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background-color: #009FE3; /* Cabinet Blau */
|
||||
width: 0%;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#progress-bar.animate {
|
||||
animation: progressAnimation 30s linear;
|
||||
}
|
||||
|
||||
@keyframes progressAnimation {
|
||||
from {
|
||||
width: 0%;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- INHALTE IM FOOTER --- */
|
||||
.cta-text-container {
|
||||
width: 75%;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-size: 2.0em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.cta-subline {
|
||||
font-size: 2.4em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.qr-code-img {
|
||||
width: 8em; /* Relativ zur Footer-Schriftgröße */
|
||||
height: auto;
|
||||
max-width: 100px;
|
||||
aspect-ratio: 1 / 1; /* Erzwingt quadratische Form */
|
||||
object-fit: contain;
|
||||
background-color: white; /* Weißer Hintergrund für Lesbarkeit */
|
||||
padding: 0.4em;
|
||||
border-radius: 0.6em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scan-hint {
|
||||
margin-top: 0.8em;
|
||||
font-size: 1.3em; /* Relativ zur Footer-Schriftgröße */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: #009FE3; /* Akzentfarbe */
|
||||
}
|
||||
|
||||
/* Hilfsklasse für den Überblend-Effekt */
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Fullscreen Button */
|
||||
#fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 1000;
|
||||
background-color: rgba(0, 159, 227, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
#fullscreen-btn:hover {
|
||||
background-color: rgba(0, 159, 227, 1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
#fullscreen-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Button ausblenden wenn bereits im Fullscreen */
|
||||
#fullscreen-btn.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Fullscreen Reminder (nach Reload) */
|
||||
#fullscreen-btn.reminder {
|
||||
background-color: rgba(255, 152, 0, 0.95);
|
||||
animation: pulse 2s infinite;
|
||||
padding: 12px 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 12px rgba(255, 152, 0, 0.6);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- FULLSCREEN BUTTON -->
|
||||
<button id="fullscreen-btn" title="Vollbildmodus aktivieren">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle;">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
<span style="vertical-align: middle;">V 1.3</span>
|
||||
</button>
|
||||
|
||||
<div id="main-container">
|
||||
<!-- VIDEO BEREICH -->
|
||||
<div id="video-wrapper">
|
||||
<!-- Videos werden hier geladen. 'muted' ist oft nötig für Autoplay -->
|
||||
<video id="video-player" autoplay muted playsinline></video>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER BEREICH -->
|
||||
<div id="footer">
|
||||
<div id="progress-bar"></div>
|
||||
<div class="cta-text-container" id="text-area">
|
||||
<div class="cta-headline" id="headline">LADEN...</div>
|
||||
<div class="cta-subline" id="subline">Inhalte werden geladen</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-container" id="qr-area">
|
||||
<img src="" id="qr-image" class="qr-code-img" alt="QR Code">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ==============================================
|
||||
FULLSCREEN BUTTON LOGIC MIT AUTO-REMINDER
|
||||
============================================== */
|
||||
|
||||
const fullscreenBtn = document.getElementById('fullscreen-btn');
|
||||
const FULLSCREEN_STATE_KEY = 'cabinet_fullscreen_was_active';
|
||||
|
||||
// Fullscreen aktivieren
|
||||
function enterFullscreen() {
|
||||
const elem = document.documentElement;
|
||||
|
||||
// Merken dass Fullscreen aktiviert wurde (für nach Reload)
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
|
||||
if (elem.requestFullscreen) {
|
||||
elem.requestFullscreen();
|
||||
} else if (elem.webkitRequestFullscreen) { // Safari/Chrome
|
||||
elem.webkitRequestFullscreen();
|
||||
} else if (elem.mozRequestFullScreen) { // Firefox
|
||||
elem.mozRequestFullScreen();
|
||||
} else if (elem.msRequestFullscreen) { // IE/Edge
|
||||
elem.msRequestFullscreen();
|
||||
}
|
||||
|
||||
window.displayLogger?.log('Fullscreen aktiviert');
|
||||
}
|
||||
|
||||
// Button Event Listener
|
||||
fullscreenBtn.addEventListener('click', () => {
|
||||
enterFullscreen();
|
||||
// Reminder-Klasse entfernen falls vorhanden
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
});
|
||||
|
||||
// Prüfen ob Fullscreen vorher aktiv war (nach Reload)
|
||||
function checkFullscreenRestore() {
|
||||
const wasFullscreen = localStorage.getItem(FULLSCREEN_STATE_KEY);
|
||||
|
||||
if (wasFullscreen === 'true') {
|
||||
// Fullscreen war vorher aktiv → Auffälliger Reminder
|
||||
fullscreenBtn.classList.add('reminder');
|
||||
fullscreenBtn.innerHTML = `
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle;">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
<span style="vertical-align: middle;">⚠️ Vollbild aktivieren!</span>
|
||||
`;
|
||||
|
||||
window.displayLogger?.warn('Fullscreen-Reminder angezeigt (war vorher aktiv)', {
|
||||
reason: 'Page reload',
|
||||
previousState: 'fullscreen'
|
||||
});
|
||||
|
||||
// Nach 30 Sekunden automatisch versuchen (falls Kiosk-Mode)
|
||||
setTimeout(() => {
|
||||
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
|
||||
window.displayLogger?.log('Versuche Auto-Fullscreen (Kiosk-Mode?)');
|
||||
enterFullscreen();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
// Button ausblenden wenn bereits im Fullscreen
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (document.fullscreenElement) {
|
||||
fullscreenBtn.classList.add('hidden');
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
} else {
|
||||
fullscreenBtn.classList.remove('hidden');
|
||||
// Fullscreen wurde verlassen → State clearen
|
||||
localStorage.removeItem(FULLSCREEN_STATE_KEY);
|
||||
window.displayLogger?.log('Fullscreen verlassen');
|
||||
}
|
||||
});
|
||||
|
||||
// Webkit Fullscreen Change (Chrome/Safari)
|
||||
document.addEventListener('webkitfullscreenchange', () => {
|
||||
if (document.webkitFullscreenElement) {
|
||||
fullscreenBtn.classList.add('hidden');
|
||||
fullscreenBtn.classList.remove('reminder');
|
||||
localStorage.setItem(FULLSCREEN_STATE_KEY, 'true');
|
||||
} else {
|
||||
fullscreenBtn.classList.remove('hidden');
|
||||
localStorage.removeItem(FULLSCREEN_STATE_KEY);
|
||||
window.displayLogger?.log('Fullscreen verlassen (webkit)');
|
||||
}
|
||||
});
|
||||
|
||||
// Check beim Laden der Seite
|
||||
checkFullscreenRestore();
|
||||
|
||||
/* ==============================================
|
||||
KONFIGURATION WIRD DYNAMISCH GELADEN
|
||||
============================================== */
|
||||
|
||||
let videoPlaylist = [];
|
||||
let footerContent = [];
|
||||
let footerContentLength = 0;
|
||||
|
||||
// Basis-URL für Assets und API (b2in.eu Server)
|
||||
const BASE_URL = 'https://b2in.eu';
|
||||
|
||||
// API-URL für die Konfiguration (CORS ist aktiviert für cabinet.b2in.eu)
|
||||
const API_URL = BASE_URL + '/api/display/config';
|
||||
|
||||
/* ==============================================
|
||||
KONFIGURATION LADEN
|
||||
============================================== */
|
||||
|
||||
async function loadConfiguration() {
|
||||
try {
|
||||
window.displayLogger?.log('Lade Konfiguration...', { url: API_URL });
|
||||
|
||||
const response = await fetch(API_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
videoPlaylist = config.videoPlaylist || [];
|
||||
footerContent = config.footerContent || [];
|
||||
|
||||
window.displayLogger?.setContext('videoPlaylistLength', videoPlaylist.length);
|
||||
window.displayLogger?.setContext('footerContentLength', footerContent.length);
|
||||
window.displayLogger?.log('Konfiguration erfolgreich geladen', {
|
||||
videos: videoPlaylist.length,
|
||||
footerItems: footerContent.length
|
||||
});
|
||||
|
||||
console.log('Konfiguration geladen:', config);
|
||||
|
||||
// Überprüfe, ob Videos vorhanden sind
|
||||
if (videoPlaylist.length === 0) {
|
||||
console.warn('Keine Videos in der Playlist vorhanden');
|
||||
window.displayLogger?.warn('Keine Videos in Playlist');
|
||||
document.getElementById('headline').innerText = 'KEINE VIDEOS';
|
||||
document.getElementById('subline').innerText = 'Bitte fügen Sie Videos im CMS hinzu';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Überprüfe, ob Footer-Inhalte vorhanden sind
|
||||
if (footerContent.length === 0) {
|
||||
console.warn('Keine Footer-Inhalte vorhanden - Footer wird ausgeblendet');
|
||||
footerContentLength = 0;
|
||||
// Footer ausblenden
|
||||
const footer = document.getElementById('footer');
|
||||
if (footer) {
|
||||
footer.style.display = 'none';
|
||||
}
|
||||
// Video-Wrapper auf 100% Höhe setzen
|
||||
const videoWrapper = document.getElementById('video-wrapper');
|
||||
if (videoWrapper) {
|
||||
videoWrapper.style.flexGrow = '1';
|
||||
videoWrapper.style.height = '100%';
|
||||
}
|
||||
} else {
|
||||
// Footer anzeigen, falls er zuvor ausgeblendet wurde
|
||||
const footer = document.getElementById('footer');
|
||||
if (footer) {
|
||||
footer.style.display = 'flex';
|
||||
}
|
||||
footerContentLength = 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Konfiguration:', error);
|
||||
window.displayLogger?.error('Konfiguration konnte nicht geladen werden', {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
document.getElementById('headline').innerText = 'FEHLER';
|
||||
document.getElementById('subline').innerText = 'Konfiguration konnte nicht geladen werden';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
PROGRAMM-LOGIK
|
||||
============================================== */
|
||||
|
||||
// --- ROBUSTER VIDEO PLAYER MIT MEMORY-MANAGEMENT ---
|
||||
const videoElement = document.getElementById('video-player');
|
||||
let currentVideoIndex = 0;
|
||||
let videoStartTimeout = null;
|
||||
let videoWatchdogInterval = null;
|
||||
let lastVideoTime = 0;
|
||||
let videoStuckCount = 0;
|
||||
let consecutiveErrors = 0;
|
||||
const MAX_CONSECUTIVE_ERRORS = 3;
|
||||
const VIDEO_START_TIMEOUT = 10000; // 10 Sekunden
|
||||
const VIDEO_WATCHDOG_INTERVAL = 5000; // Alle 5 Sekunden prüfen
|
||||
|
||||
// Video-Element optimieren für Memory-Management
|
||||
videoElement.setAttribute('preload', 'metadata'); // Nur Metadaten vorladen, nicht ganzes Video
|
||||
|
||||
function cleanupVideo() {
|
||||
// Wichtig: Stoppt Video und gibt Speicher frei
|
||||
try {
|
||||
videoElement.pause();
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.load(); // Triggert Garbage Collection des alten Videos
|
||||
|
||||
// Timeouts clearen
|
||||
if (videoStartTimeout) {
|
||||
clearTimeout(videoStartTimeout);
|
||||
videoStartTimeout = null;
|
||||
}
|
||||
|
||||
window.displayLogger?.log('Video cleanup durchgeführt');
|
||||
} catch (e) {
|
||||
window.displayLogger?.error('Video cleanup error', { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function playNextVideo() {
|
||||
if (videoPlaylist.length === 0) return;
|
||||
|
||||
// Watchdog zurücksetzen
|
||||
lastVideoTime = 0;
|
||||
videoStuckCount = 0;
|
||||
|
||||
const video = videoPlaylist[currentVideoIndex];
|
||||
const videoSrc = BASE_URL + "/_cabinet/" + video.src;
|
||||
|
||||
// Kontext aktualisieren
|
||||
window.displayLogger?.setContext('currentVideo', video.src);
|
||||
window.displayLogger?.setContext('currentVideoIndex', currentVideoIndex);
|
||||
|
||||
// WICHTIG: Altes Video cleanup BEVOR neues geladen wird
|
||||
cleanupVideo();
|
||||
|
||||
// Kleiner Delay um Cleanup abzuschließen
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Neues Video laden
|
||||
videoElement.src = videoSrc;
|
||||
|
||||
if(footerContentLength !== 0 && video.position !== undefined) {
|
||||
videoElement.style.objectPosition = `center ${video.position}%`;
|
||||
}
|
||||
|
||||
// Timeout für Video-Start
|
||||
videoStartTimeout = setTimeout(() => {
|
||||
window.displayLogger?.error('Video start timeout', {
|
||||
video: video.src,
|
||||
timeout: VIDEO_START_TIMEOUT
|
||||
});
|
||||
// Nächstes Video probieren
|
||||
skipToNextVideo('timeout');
|
||||
}, VIDEO_START_TIMEOUT);
|
||||
|
||||
// Video abspielen
|
||||
videoElement.play()
|
||||
.then(() => {
|
||||
window.displayLogger?.log(`Video started: ${video.src}`);
|
||||
consecutiveErrors = 0; // Erfolg → Error-Counter zurücksetzen
|
||||
|
||||
// Start-Timeout clearen
|
||||
if (videoStartTimeout) {
|
||||
clearTimeout(videoStartTimeout);
|
||||
videoStartTimeout = null;
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
console.log("Autoplay blocked/failed", e);
|
||||
window.displayLogger?.error(`Video play failed: ${video.src}`, {
|
||||
error: e.message
|
||||
});
|
||||
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
window.displayLogger?.error('Zu viele aufeinanderfolgende Fehler', {
|
||||
count: consecutiveErrors
|
||||
});
|
||||
// Seite nach 30 Sekunden neu laden
|
||||
setTimeout(() => location.reload(), 30000);
|
||||
} else {
|
||||
// Nächstes Video probieren
|
||||
skipToNextVideo('play_failed');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
window.displayLogger?.error('Exception beim Video-Laden', {
|
||||
error: e.message,
|
||||
stack: e.stack
|
||||
});
|
||||
skipToNextVideo('exception');
|
||||
}
|
||||
}, 100); // 100ms Delay für Cleanup
|
||||
|
||||
// Index weiterschalten
|
||||
currentVideoIndex++;
|
||||
if (currentVideoIndex >= videoPlaylist.length) {
|
||||
currentVideoIndex = 0;
|
||||
// Playlist-Loop abgeschlossen → Log für Monitoring
|
||||
window.displayLogger?.log('Playlist-Loop abgeschlossen, starte von vorne');
|
||||
}
|
||||
}
|
||||
|
||||
function skipToNextVideo(reason) {
|
||||
window.displayLogger?.warn('Überspringe zum nächsten Video', { reason: reason });
|
||||
playNextVideo();
|
||||
}
|
||||
|
||||
// Video Watchdog: Prüft ob Video wirklich läuft
|
||||
function startVideoWatchdog() {
|
||||
if (videoWatchdogInterval) {
|
||||
clearInterval(videoWatchdogInterval);
|
||||
}
|
||||
|
||||
videoWatchdogInterval = setInterval(() => {
|
||||
if (videoPlaylist.length === 0) return;
|
||||
|
||||
const currentTime = videoElement.currentTime;
|
||||
const isPaused = videoElement.paused;
|
||||
const hasEnded = videoElement.ended;
|
||||
const isStuck = (currentTime === lastVideoTime && !isPaused && !hasEnded);
|
||||
|
||||
// Debug-Log
|
||||
if (isStuck) {
|
||||
videoStuckCount++;
|
||||
window.displayLogger?.warn('Video scheint stecken geblieben zu sein', {
|
||||
currentTime: currentTime,
|
||||
isPaused: isPaused,
|
||||
hasEnded: hasEnded,
|
||||
stuckCount: videoStuckCount,
|
||||
src: videoElement.src
|
||||
});
|
||||
|
||||
// Wenn 2x hintereinander stuck → Recovery
|
||||
if (videoStuckCount >= 2) {
|
||||
window.displayLogger?.error('Video definitiv stuck - starte nächstes', {
|
||||
currentTime: currentTime,
|
||||
src: videoElement.src
|
||||
});
|
||||
skipToNextVideo('watchdog_stuck');
|
||||
}
|
||||
} else {
|
||||
// Video läuft normal → Counter zurücksetzen
|
||||
if (videoStuckCount > 0) {
|
||||
window.displayLogger?.log('Video läuft wieder normal');
|
||||
}
|
||||
videoStuckCount = 0;
|
||||
}
|
||||
|
||||
lastVideoTime = currentTime;
|
||||
}, VIDEO_WATCHDOG_INTERVAL);
|
||||
}
|
||||
|
||||
// Video Events
|
||||
videoElement.addEventListener('ended', () => {
|
||||
window.displayLogger?.log('Video ended', {
|
||||
src: videoElement.src
|
||||
});
|
||||
playNextVideo();
|
||||
});
|
||||
|
||||
videoElement.addEventListener('error', (e) => {
|
||||
const error = videoElement.error;
|
||||
const errorCode = error?.code;
|
||||
const errorMessage = {
|
||||
1: 'MEDIA_ERR_ABORTED',
|
||||
2: 'MEDIA_ERR_NETWORK',
|
||||
3: 'MEDIA_ERR_DECODE',
|
||||
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED'
|
||||
}[errorCode] || 'UNKNOWN';
|
||||
|
||||
window.displayLogger?.error('Video Error Event', {
|
||||
code: errorCode,
|
||||
message: error?.message,
|
||||
src: videoElement.src,
|
||||
mediaError: errorMessage
|
||||
});
|
||||
|
||||
// Bei Fehler → Nächstes Video
|
||||
consecutiveErrors++;
|
||||
skipToNextVideo(`error_${errorMessage}`);
|
||||
});
|
||||
|
||||
videoElement.addEventListener('stalled', () => {
|
||||
window.displayLogger?.warn('Video stalled (buffering)', {
|
||||
src: videoElement.src,
|
||||
currentTime: videoElement.currentTime
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener('waiting', () => {
|
||||
window.displayLogger?.warn('Video waiting (buffering)', {
|
||||
src: videoElement.src,
|
||||
currentTime: videoElement.currentTime
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener('playing', () => {
|
||||
window.displayLogger?.log('Video playing event', {
|
||||
src: videoElement.src,
|
||||
currentTime: videoElement.currentTime
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener('canplay', () => {
|
||||
window.displayLogger?.log('Video canplay event', {
|
||||
src: videoElement.src
|
||||
});
|
||||
});
|
||||
|
||||
// --- FOOTER ROTATION LOGIC ---
|
||||
let currentFooterIndex = 0;
|
||||
const textArea = document.getElementById('text-area');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const headlineEl = document.getElementById('headline');
|
||||
const sublineEl = document.getElementById('subline');
|
||||
const qrImageEl = document.getElementById('qr-image');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
function restartProgressBar() {
|
||||
// Animation zurücksetzen und neu starten
|
||||
progressBar.classList.remove('animate');
|
||||
void progressBar.offsetWidth; // Force reflow
|
||||
progressBar.classList.add('animate');
|
||||
}
|
||||
|
||||
function updateFooter() {
|
||||
if (footerContent.length === 0) return;
|
||||
|
||||
// 1. Ausblenden
|
||||
textArea.classList.add('fade-out');
|
||||
qrArea.classList.add('fade-out');
|
||||
|
||||
// Progress Bar neu starten
|
||||
restartProgressBar();
|
||||
|
||||
// 2. Warten, Inhalt tauschen, Einblenden
|
||||
setTimeout(() => {
|
||||
const content = footerContent[currentFooterIndex];
|
||||
|
||||
// Kontext aktualisieren
|
||||
window.displayLogger?.setContext('currentFooter', currentFooterIndex);
|
||||
|
||||
// Text setzen
|
||||
headlineEl.innerText = content.headline;
|
||||
sublineEl.innerText = content.subline;
|
||||
|
||||
// QR Code nur generieren wenn URL vorhanden
|
||||
if (content.url) {
|
||||
// QR Code generieren (API Aufruf)
|
||||
const qrSize = "300x300";
|
||||
const qrColor = "000000"; // Schwarz
|
||||
const qrBg = "ffffff"; // Weiß
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${qrSize}&color=${qrColor}&bgcolor=${qrBg}&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
|
||||
qrImageEl.src = qrUrl;
|
||||
qrArea.style.display = 'flex'; // QR-Bereich anzeigen
|
||||
} else {
|
||||
// Kein QR-Code - QR-Bereich ausblenden
|
||||
qrArea.style.display = 'none';
|
||||
// Text-Container auf volle Breite
|
||||
textArea.style.width = '100%';
|
||||
}
|
||||
|
||||
// Index weiterschalten
|
||||
currentFooterIndex++;
|
||||
if (currentFooterIndex >= footerContent.length) {
|
||||
currentFooterIndex = 0;
|
||||
}
|
||||
|
||||
// 3. Einblenden
|
||||
if (content.url) {
|
||||
qrImageEl.onload = () => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
};
|
||||
}
|
||||
// Fallback falls Bild sofort da ist (Cache) oder kein QR-Code
|
||||
setTimeout(() => {
|
||||
textArea.classList.remove('fade-out');
|
||||
if (content.url) {
|
||||
qrArea.classList.remove('fade-out');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
}, 1000); // 1 Sekunde für Fade-Out Animation
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
MEMORY MANAGEMENT & PERFORMANCE
|
||||
============================================== */
|
||||
|
||||
// Memory-Optimierung: Regelmäßig Browser aufräumen
|
||||
function performMemoryOptimization() {
|
||||
try {
|
||||
// Performance-Metriken loggen falls verfügbar
|
||||
if (performance.memory) {
|
||||
const memUsed = Math.round(performance.memory.usedJSHeapSize / 1048576);
|
||||
const memLimit = Math.round(performance.memory.jsHeapSizeLimit / 1048576);
|
||||
const memPercent = Math.round((memUsed / memLimit) * 100);
|
||||
|
||||
window.displayLogger?.log('Memory Status', {
|
||||
usedMB: memUsed,
|
||||
limitMB: memLimit,
|
||||
percentUsed: memPercent
|
||||
});
|
||||
|
||||
// Warnung wenn Speicher über 80%
|
||||
if (memPercent > 80) {
|
||||
window.displayLogger?.warn('Hohe Speicherauslastung', {
|
||||
percentUsed: memPercent,
|
||||
usedMB: memUsed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cache-Infos loggen
|
||||
const cacheInfo = {
|
||||
videoBuffered: videoElement.buffered.length,
|
||||
videoDuration: videoElement.duration,
|
||||
videoReadyState: videoElement.readyState
|
||||
};
|
||||
|
||||
window.displayLogger?.log('Video Cache Status', cacheInfo);
|
||||
|
||||
} catch (e) {
|
||||
window.displayLogger?.error('Memory optimization error', {
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Automatischer Page-Reload bei kritischen Problemen (Failsafe)
|
||||
let criticalErrorCount = 0;
|
||||
function checkCriticalErrors() {
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
criticalErrorCount++;
|
||||
window.displayLogger?.error('Kritischer Zustand erkannt', {
|
||||
consecutiveErrors: consecutiveErrors,
|
||||
criticalErrorCount: criticalErrorCount
|
||||
});
|
||||
|
||||
if (criticalErrorCount >= 3) {
|
||||
window.displayLogger?.error('Zu viele kritische Fehler - Seite wird neu geladen');
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
}
|
||||
} else {
|
||||
criticalErrorCount = 0; // Zurücksetzen wenn alles normal läuft
|
||||
}
|
||||
}
|
||||
|
||||
/* ==============================================
|
||||
INITIALISIERUNG
|
||||
============================================== */
|
||||
|
||||
async function initialize() {
|
||||
const success = await loadConfiguration();
|
||||
|
||||
if (success && videoPlaylist.length > 0) {
|
||||
// Start Video
|
||||
playNextVideo();
|
||||
|
||||
// Start Video Watchdog (überwacht ob Videos laufen)
|
||||
startVideoWatchdog();
|
||||
window.displayLogger?.log('Video Watchdog gestartet');
|
||||
|
||||
// Start Footer Loop
|
||||
if (footerContent.length > 0) {
|
||||
updateFooter();
|
||||
setInterval(updateFooter, 30000); // Alle 30.000 ms (30 sek) wechseln
|
||||
|
||||
// Progress Bar initial starten
|
||||
restartProgressBar();
|
||||
}
|
||||
|
||||
// Memory-Optimierung alle 10 Minuten
|
||||
setInterval(performMemoryOptimization, 10 * 60 * 1000);
|
||||
window.displayLogger?.log('Memory Optimizer gestartet (alle 10 Min)');
|
||||
|
||||
// Critical Error Check alle 30 Sekunden
|
||||
setInterval(checkCriticalErrors, 30 * 1000);
|
||||
|
||||
// Initial Memory Check nach 30 Sekunden
|
||||
setTimeout(performMemoryOptimization, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Laden der Seite initialisieren
|
||||
initialize();
|
||||
|
||||
// Auto-Reload alle 5 Minuten, um neue Inhalte zu laden
|
||||
setInterval(async () => {
|
||||
console.log('Prüfe auf neue Konfiguration...');
|
||||
const oldFooterCount = footerContent.length;
|
||||
await loadConfiguration();
|
||||
|
||||
// Wenn Footer-Inhalte hinzugefügt oder entfernt wurden, Seite neu laden
|
||||
if ((oldFooterCount === 0 && footerContent.length > 0) ||
|
||||
(oldFooterCount > 0 && footerContent.length === 0)) {
|
||||
console.log('Footer-Status hat sich geändert - Seite wird neu geladen');
|
||||
location.reload();
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 Minuten
|
||||
|
||||
// Präventiver Page-Reload alle 6 Stunden (verhindert Memory-Leaks über lange Zeit)
|
||||
setTimeout(() => {
|
||||
window.displayLogger?.log('Präventiver Reload nach 6 Stunden');
|
||||
location.reload();
|
||||
}, 6 * 60 * 60 * 1000); // 6 Stunden
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
292
public/_cabinet/index_1.html
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Digital Signage Bielefeld</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* --- GRUNDGERÜST --- */
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
/* Seitenverhältnis 9:16 (1080:1920) beibehalten */
|
||||
aspect-ratio: 9 / 16;
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm breiter als 9:16 ist, nach Höhe skalieren */
|
||||
@media (min-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Wenn Bildschirm höher als 9:16 ist, nach Breite skalieren */
|
||||
@media (max-aspect-ratio: 9/16) {
|
||||
#main-container {
|
||||
width: 100vw;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- VIDEO BEREICH (Oben) --- */
|
||||
#video-wrapper {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* Video füllt den Bereich randlos */
|
||||
object-position: center 15%; /* Fallback-Position, wird per JavaScript überschrieben */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- FOOTER BEREICH (Unten - ca. 16.67% der Höhe = 320px bei 1920px) --- */
|
||||
#footer {
|
||||
height: 16.67vh;
|
||||
min-height: 200px;
|
||||
background-color: #1a1a1a; /* Dunkelgrau */
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px; /* 60px bei 1080px Breite */
|
||||
box-sizing: border-box;
|
||||
border-top: 0.26vh solid #009FE3; /* Cabinet Blau - 5px bei 1920px */
|
||||
font-size: 10px
|
||||
}
|
||||
|
||||
/* --- INHALTE IM FOOTER --- */
|
||||
.cta-text-container {
|
||||
width: 65%;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-size: 2.2em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 300;
|
||||
margin-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.cta-subline {
|
||||
font-size: 2.8em; /* Relativ zur Footer-Schriftgröße */
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
width: 30%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.qr-code-img {
|
||||
width: 12em; /* Relativ zur Footer-Schriftgröße */
|
||||
height: auto;
|
||||
max-width: 200px;
|
||||
aspect-ratio: 1 / 1; /* Erzwingt quadratische Form */
|
||||
object-fit: contain;
|
||||
background-color: white; /* Weißer Hintergrund für Lesbarkeit */
|
||||
padding: 0.8em;
|
||||
border-radius: 0.6em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scan-hint {
|
||||
margin-top: 0.8em;
|
||||
font-size: 1.3em; /* Relativ zur Footer-Schriftgröße */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
color: #009FE3; /* Akzentfarbe */
|
||||
}
|
||||
|
||||
/* Hilfsklasse für den Überblend-Effekt */
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="main-container">
|
||||
<!-- VIDEO BEREICH -->
|
||||
<div id="video-wrapper">
|
||||
<!-- Videos werden hier geladen. 'muted' ist oft nötig für Autoplay -->
|
||||
<video id="video-player" autoplay muted playsinline></video>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER BEREICH -->
|
||||
<div id="footer">
|
||||
<div class="cta-text-container" id="text-area">
|
||||
<div class="cta-headline" id="headline">LADEN...</div>
|
||||
<div class="cta-subline" id="subline">Inhalte werden geladen</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-container" id="qr-area">
|
||||
<img src="" id="qr-image" class="qr-code-img" alt="QR Code">
|
||||
<span class="scan-hint">Scan Me</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ==============================================
|
||||
KONFIGURATION
|
||||
============================================== */
|
||||
|
||||
// 1. VIDEOS (Dateinamen und Position hier anpassen)
|
||||
// position: Prozentwert von 0% (ganz oben) bis 100% (ganz unten)
|
||||
const videoPlaylist = [
|
||||
{ src: "assets/video5.mp4", position: 10 },
|
||||
{ src: "assets/video5.mp4", position: 10 },
|
||||
{ src: "assets/video3.mp4", position: 15 },
|
||||
{ src: "assets/video4.mp4", position: 25 },
|
||||
|
||||
|
||||
|
||||
];
|
||||
|
||||
// 2. INHALTE & LINKS
|
||||
// Ich habe deine Links hier eingetragen. Die Texte kannst du anpassen.
|
||||
const footerContent = [
|
||||
{
|
||||
headline: "Beratung & Termin",
|
||||
subline: "Planen Sie Ihren Traumschrank.\nJetzt Termin vereinbaren.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=t"
|
||||
},
|
||||
{
|
||||
headline: "Inspiration",
|
||||
subline: "Entdecken Sie unsere Ideen-Pinnwände auf Pinterest.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=p"
|
||||
},
|
||||
{
|
||||
headline: "Instagram",
|
||||
subline: "Folgen Sie uns für tägliche Einblicke & Design.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=i"
|
||||
},
|
||||
{
|
||||
headline: "Facebook",
|
||||
subline: "News, Aktionen & Community.\nWerden Sie Fan.",
|
||||
url: "https://cabinet.b2in.eu/go.php?z=f"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
/* ==============================================
|
||||
PROGRAMM-LOGIK (Ab hier nichts ändern)
|
||||
============================================== */
|
||||
|
||||
// --- VIDEO PLAYER LOGIC ---
|
||||
const videoElement = document.getElementById('video-player');
|
||||
let currentVideoIndex = 0;
|
||||
|
||||
function playNextVideo() {
|
||||
const video = videoPlaylist[currentVideoIndex];
|
||||
videoElement.src = video.src;
|
||||
videoElement.style.objectPosition = `center ${video.position}%`;
|
||||
videoElement.play().catch(e => console.log("Autoplay blocked/failed", e));
|
||||
|
||||
currentVideoIndex++;
|
||||
if (currentVideoIndex >= videoPlaylist.length) {
|
||||
currentVideoIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
videoElement.addEventListener('ended', playNextVideo);
|
||||
|
||||
// Start Video
|
||||
if(videoPlaylist.length > 0) playNextVideo();
|
||||
|
||||
|
||||
// --- FOOTER ROTATION LOGIC ---
|
||||
let currentFooterIndex = 0;
|
||||
const textArea = document.getElementById('text-area');
|
||||
const qrArea = document.getElementById('qr-area');
|
||||
const headlineEl = document.getElementById('headline');
|
||||
const sublineEl = document.getElementById('subline');
|
||||
const qrImageEl = document.getElementById('qr-image');
|
||||
|
||||
function updateFooter() {
|
||||
// 1. Ausblenden
|
||||
textArea.classList.add('fade-out');
|
||||
qrArea.classList.add('fade-out');
|
||||
|
||||
// 2. Warten, Inhalt tauschen, Einblenden
|
||||
setTimeout(() => {
|
||||
const content = footerContent[currentFooterIndex];
|
||||
|
||||
// Text setzen
|
||||
headlineEl.innerText = content.headline;
|
||||
sublineEl.innerText = content.subline;
|
||||
|
||||
// QR Code generieren (API Aufruf)
|
||||
// Wir nutzen 'qrserver.com', eine schnelle und kostenlose API
|
||||
const qrSize = "300x300";
|
||||
const qrColor = "000000"; // Schwarz
|
||||
const qrBg = "ffffff"; // Weiß
|
||||
// encodeURIComponent sorgt dafür, dass Sonderzeichen im Link funktionieren
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${qrSize}&color=${qrColor}&bgcolor=${qrBg}&margin=10&data=${encodeURIComponent(content.url)}`;
|
||||
|
||||
qrImageEl.src = qrUrl;
|
||||
|
||||
// Index weiterschalten
|
||||
currentFooterIndex++;
|
||||
if (currentFooterIndex >= footerContent.length) {
|
||||
currentFooterIndex = 0;
|
||||
}
|
||||
|
||||
// 3. Einblenden
|
||||
// Kurze Verzögerung damit das Bild Zeit hat zu laden (optisch schöner)
|
||||
qrImageEl.onload = () => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
};
|
||||
// Fallback falls Bild sofort da ist (Cache)
|
||||
setTimeout(() => {
|
||||
textArea.classList.remove('fade-out');
|
||||
qrArea.classList.remove('fade-out');
|
||||
}, 100);
|
||||
|
||||
}, 1000); // 1 Sekunde für Fade-Out Animation
|
||||
}
|
||||
|
||||
// Start Footer Loop
|
||||
updateFooter();
|
||||
setInterval(updateFooter, 30000); // Alle 30.000 ms (30 sek) wechseln
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
94
public/_cabinet/logger.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
// logger.php - Optimiertes Logging für Cabinet Digital Signage
|
||||
// Erlaubt CORS
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
header("Access-Control-Allow-Headers: Content-Type");
|
||||
header("Access-Control-Allow-Methods: POST, OPTIONS");
|
||||
header("Content-Type: application/json");
|
||||
|
||||
// Handle OPTIONS preflight request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Nur POST akzeptieren
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$input = file_get_contents('php://input');
|
||||
$data = json_decode($input, true);
|
||||
|
||||
if ($data) {
|
||||
$level = strtoupper($data['level'] ?? 'INFO');
|
||||
$message = $data['message'] ?? 'n/a';
|
||||
$timestamp = $data['timestamp'] ?? date('c');
|
||||
$context = $data['context'] ?? [];
|
||||
$viewport = $data['viewport'] ?? 'unknown';
|
||||
$connection = $data['connection'] ?? 'unknown';
|
||||
|
||||
// Log-Eintrag mit strukturierten Daten
|
||||
$logEntry = [
|
||||
'timestamp' => date('Y-m-d H:i:s'),
|
||||
'iso_timestamp' => $timestamp,
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'viewport' => $viewport,
|
||||
'connection' => $connection,
|
||||
'context' => $context
|
||||
];
|
||||
|
||||
// Log-Datei basierend auf Level
|
||||
$logFile = __DIR__ . '/logs/' . strtolower($level) . '_' . date('Y-m-d') . '.log';
|
||||
$allLogsFile = __DIR__ . '/logs/all_' . date('Y-m-d') . '.log';
|
||||
|
||||
// Logs-Verzeichnis erstellen falls nicht vorhanden
|
||||
$logsDir = __DIR__ . '/logs';
|
||||
if (!is_dir($logsDir)) {
|
||||
mkdir($logsDir, 0755, true);
|
||||
}
|
||||
|
||||
// Formatierte Log-Zeile (human-readable)
|
||||
$logLine = sprintf(
|
||||
"[%s] [%s] %s\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
str_pad($level, 8),
|
||||
$message
|
||||
);
|
||||
|
||||
// Zusätzliche Kontext-Informationen wenn vorhanden
|
||||
if (!empty($context)) {
|
||||
$logLine .= " Context: " . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
}
|
||||
|
||||
$logLine .= " IP: {$logEntry['ip']} | Viewport: {$viewport} | Connection: {$connection}\n";
|
||||
$logLine .= str_repeat('-', 80) . "\n";
|
||||
|
||||
// JSON Log für maschinelle Verarbeitung
|
||||
$jsonLogLine = json_encode($logEntry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||
$jsonLogFile = __DIR__ . '/logs/json_' . date('Y-m-d') . '.log';
|
||||
|
||||
// In Dateien schreiben
|
||||
file_put_contents($logFile, $logLine, FILE_APPEND | LOCK_EX);
|
||||
file_put_contents($allLogsFile, $logLine, FILE_APPEND | LOCK_EX);
|
||||
file_put_contents($jsonLogFile, $jsonLogLine, FILE_APPEND | LOCK_EX);
|
||||
|
||||
// Log-Rotation: Dateien älter als 30 Tage löschen
|
||||
$files = glob($logsDir . '/*.log');
|
||||
$now = time();
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file) && $now - filemtime($file) >= 30 * 24 * 3600) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
// Erfolgsantwort
|
||||
echo json_encode(['status' => 'success', 'message' => 'Log received']);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid JSON']);
|
||||
}
|
||||
} else {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
|
||||
}
|
||||
1
public/_cabinet/logs/test_2026-01-19.log
Normal file
|
|
@ -0,0 +1 @@
|
|||
[2026-01-19 08:45:35] [INFO] Logging-System wurde eingerichtet
|
||||
118
public/_cabinet/setup-logging.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/bin/bash
|
||||
# Setup-Script für Cabinet Digital Signage Logging System
|
||||
# Führe dieses Script einmalig aus um das Logging zu aktivieren
|
||||
|
||||
echo "================================================"
|
||||
echo "Cabinet Digital Signage - Logging Setup"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Farben für Output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Arbeitsverzeichnis
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "📁 Arbeitsverzeichnis: $SCRIPT_DIR"
|
||||
echo ""
|
||||
|
||||
# 1. Logs-Verzeichnis erstellen
|
||||
echo "1️⃣ Erstelle logs/ Verzeichnis..."
|
||||
if [ -d "logs" ]; then
|
||||
echo -e "${YELLOW} ⚠️ Verzeichnis existiert bereits${NC}"
|
||||
else
|
||||
mkdir -p logs
|
||||
echo -e "${GREEN} ✅ logs/ erstellt${NC}"
|
||||
fi
|
||||
|
||||
# 2. Berechtigungen setzen
|
||||
echo ""
|
||||
echo "2️⃣ Setze Berechtigungen..."
|
||||
|
||||
# Prüfe ob als root ausgeführt
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${YELLOW} ⚠️ Nicht als Root - verwende chmod 777 (nicht für Produktion!)${NC}"
|
||||
chmod 777 logs
|
||||
else
|
||||
echo -e "${GREEN} ✅ Als Root - setze www-data owner${NC}"
|
||||
chown -R www-data:www-data logs
|
||||
chmod 755 logs
|
||||
fi
|
||||
|
||||
# 3. Test-Log erstellen
|
||||
echo ""
|
||||
echo "3️⃣ Erstelle Test-Log..."
|
||||
TEST_LOG="logs/test_$(date +%Y-%m-%d).log"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] Logging-System wurde eingerichtet" > "$TEST_LOG"
|
||||
|
||||
if [ -f "$TEST_LOG" ]; then
|
||||
echo -e "${GREEN} ✅ Test-Log erfolgreich erstellt: $TEST_LOG${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ Test-Log konnte nicht erstellt werden${NC}"
|
||||
echo -e "${YELLOW} Prüfe die Schreibrechte!${NC}"
|
||||
fi
|
||||
|
||||
# 4. .htaccess vorbereiten
|
||||
echo ""
|
||||
echo "4️⃣ Prüfe .htaccess..."
|
||||
if [ -f ".htaccess" ]; then
|
||||
echo -e "${YELLOW} ⚠️ .htaccess existiert bereits${NC}"
|
||||
echo -e " Möchtest du die Beispiel-.htaccess ansehen? → .htaccess.example"
|
||||
else
|
||||
echo -e "${GREEN} ℹ️ Keine .htaccess gefunden${NC}"
|
||||
echo -e " Für Passwortschutz: cp .htaccess.example .htaccess"
|
||||
fi
|
||||
|
||||
# 5. PHP-Konfiguration prüfen
|
||||
echo ""
|
||||
echo "5️⃣ Prüfe PHP-Konfiguration..."
|
||||
|
||||
if command -v php &> /dev/null; then
|
||||
PHP_VERSION=$(php -v | head -n 1)
|
||||
echo -e "${GREEN} ✅ PHP gefunden: $PHP_VERSION${NC}"
|
||||
|
||||
# Prüfe wichtige PHP-Settings
|
||||
FILE_UPLOADS=$(php -r "echo ini_get('file_uploads');")
|
||||
MAX_POST=$(php -r "echo ini_get('post_max_size');")
|
||||
|
||||
echo " 📋 file_uploads: $FILE_UPLOADS"
|
||||
echo " 📋 post_max_size: $MAX_POST"
|
||||
else
|
||||
echo -e "${RED} ❌ PHP nicht gefunden!${NC}"
|
||||
fi
|
||||
|
||||
# 6. Zusammenfassung
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo "✅ Setup abgeschlossen!"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
echo "📝 Nächste Schritte:"
|
||||
echo ""
|
||||
echo "1. Teste das Logging:"
|
||||
echo " → Öffne: https://cabinet.b2in.eu/test-logging.html"
|
||||
echo ""
|
||||
echo "2. Schaue die Logs an:"
|
||||
echo " → Öffne: https://cabinet.b2in.eu/view-logs.php"
|
||||
echo ""
|
||||
echo "3. Für Produktion (empfohlen):"
|
||||
echo " → Aktiviere Passwortschutz:"
|
||||
echo " cp .htaccess.example .htaccess"
|
||||
echo " htpasswd -c .htpasswd admin"
|
||||
echo ""
|
||||
echo "4. Dokumentation lesen:"
|
||||
echo " → cat LOGGING_README.md"
|
||||
echo ""
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Verzeichnisstruktur anzeigen
|
||||
echo "📂 Aktuelle Struktur:"
|
||||
tree -L 2 -a . 2>/dev/null || ls -lah
|
||||
|
||||
echo ""
|
||||
echo "🎉 Fertig! Das Logging-System ist bereit."
|
||||
330
public/_cabinet/test-logging.html
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Logging Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
h1 {
|
||||
color: #009FE3;
|
||||
border-bottom: 3px solid #009FE3;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.test-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
button {
|
||||
background-color: #009FE3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0082bd;
|
||||
}
|
||||
button.danger {
|
||||
background-color: #f44336;
|
||||
}
|
||||
button.danger:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
button.warning {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
button.warning:hover {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
.log-output {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.log-entry {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
border-left: 3px solid #009FE3;
|
||||
}
|
||||
.success {
|
||||
color: #4caf50;
|
||||
}
|
||||
.error {
|
||||
color: #f44336;
|
||||
}
|
||||
.info {
|
||||
color: #009FE3;
|
||||
}
|
||||
code {
|
||||
background-color: #f0f0f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🧪 Cabinet Logging System - Test Interface</h1>
|
||||
|
||||
<p><strong>Zweck:</strong> Diese Seite testet das Logging-System, bevor es auf den Displays eingesetzt wird.</p>
|
||||
<p><strong>Logs ansehen:</strong> <a href="view-logs.php" target="_blank">Log-Viewer öffnen →</a></p>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>📡 Verbindungstest</h2>
|
||||
<p>Teste ob der Logger-Endpoint erreichbar ist:</p>
|
||||
<button onclick="testConnection()">Verbindung testen</button>
|
||||
<div id="connection-output" class="log-output" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>📝 Log-Level Tests</h2>
|
||||
<p>Sende verschiedene Log-Levels:</p>
|
||||
<button onclick="sendTestLog('INFO', 'Dies ist eine Test-Info-Nachricht')">INFO senden</button>
|
||||
<button class="warning" onclick="sendTestLog('WARNING', 'Dies ist eine Test-Warnung')">WARNING senden</button>
|
||||
<button class="danger" onclick="sendTestLog('ERROR', 'Dies ist ein Test-Fehler')">ERROR senden</button>
|
||||
<button class="danger" onclick="sendTestLog('FATAL', 'Dies ist ein FATAL-Test-Fehler')">FATAL senden</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>💥 Fehler-Simulation</h2>
|
||||
<p>Simuliere verschiedene Fehlertypen (erscheinen automatisch in Logs):</p>
|
||||
<button class="danger" onclick="triggerRuntimeError()">Runtime Error auslösen</button>
|
||||
<button class="danger" onclick="triggerPromiseRejection()">Promise Rejection auslösen</button>
|
||||
<button class="warning" onclick="triggerConsoleError()">Console.error auslösen</button>
|
||||
<button class="warning" onclick="triggerConsoleWarn()">Console.warn auslösen</button>
|
||||
<button class="danger" onclick="triggerResourceError()">Resource Loading Error</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>🎯 Kontext-Test</h2>
|
||||
<p>Teste Logging mit Kontext-Informationen:</p>
|
||||
<button onclick="testWithContext()">Log mit Kontext senden</button>
|
||||
<button onclick="testWithComplexContext()">Log mit komplexem Kontext</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>🔄 Performance-Test</h2>
|
||||
<p>Teste mehrere Logs gleichzeitig:</p>
|
||||
<button onclick="sendMultipleLogs(10)">10 Logs senden</button>
|
||||
<button onclick="sendMultipleLogs(50)">50 Logs senden</button>
|
||||
<button class="warning" onclick="sendMultipleLogs(100)">100 Logs senden (langsam!)</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>📊 Console Output</h2>
|
||||
<p>Lokale Log-Ausgabe (wird auch gesendet):</p>
|
||||
<div id="console-output" class="log-output"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Logger einbinden (vereinfachte Version für Tests)
|
||||
const LOG_URL = 'https://cabinet.b2in.eu/logger.php';
|
||||
const consoleOutput = document.getElementById('console-output');
|
||||
|
||||
function logToConsole(message, type = 'info') {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${type}`;
|
||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||
consoleOutput.appendChild(entry);
|
||||
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendLog(level, message, context = {}) {
|
||||
try {
|
||||
const response = await fetch(LOG_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
level: level,
|
||||
message: message,
|
||||
timestamp: new Date().toISOString(),
|
||||
context: context,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
connection: navigator.onLine ? 'online' : 'offline'
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logToConsole(`✅ ${level}: ${message}`, 'success');
|
||||
return true;
|
||||
} else {
|
||||
logToConsole(`❌ Fehler beim Senden: ${response.status}`, 'error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logToConsole(`❌ Network Error: ${error.message}`, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
async function testConnection() {
|
||||
const output = document.getElementById('connection-output');
|
||||
output.style.display = 'block';
|
||||
output.innerHTML = '<div class="log-entry info">Teste Verbindung...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(LOG_URL, {
|
||||
method: 'OPTIONS'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
output.innerHTML = `
|
||||
<div class="log-entry success">✅ Verbindung erfolgreich!</div>
|
||||
<div class="log-entry info">Status: ${response.status}</div>
|
||||
<div class="log-entry info">CORS: Aktiviert</div>
|
||||
`;
|
||||
} else {
|
||||
output.innerHTML = `
|
||||
<div class="log-entry error">❌ Verbindung fehlgeschlagen</div>
|
||||
<div class="log-entry error">Status: ${response.status}</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
output.innerHTML = `
|
||||
<div class="log-entry error">❌ Netzwerkfehler: ${error.message}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function sendTestLog(level, message) {
|
||||
sendLog(level, message, {
|
||||
test: true,
|
||||
source: 'test-interface'
|
||||
});
|
||||
}
|
||||
|
||||
function triggerRuntimeError() {
|
||||
logToConsole('⚠️ Löse Runtime Error aus...', 'warning');
|
||||
setTimeout(() => {
|
||||
throw new Error('Test Runtime Error - Dies ist beabsichtigt!');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function triggerPromiseRejection() {
|
||||
logToConsole('⚠️ Löse Promise Rejection aus...', 'warning');
|
||||
Promise.reject('Test Promise Rejection - Dies ist beabsichtigt!');
|
||||
}
|
||||
|
||||
function triggerConsoleError() {
|
||||
console.error('Test Console Error - Dies ist beabsichtigt!', {
|
||||
errorCode: 123,
|
||||
test: true
|
||||
});
|
||||
}
|
||||
|
||||
function triggerConsoleWarn() {
|
||||
console.warn('Test Console Warning - Dies ist beabsichtigt!', {
|
||||
warningCode: 456,
|
||||
test: true
|
||||
});
|
||||
}
|
||||
|
||||
function triggerResourceError() {
|
||||
logToConsole('⚠️ Löse Resource Loading Error aus...', 'warning');
|
||||
const img = document.createElement('img');
|
||||
img.src = 'https://example.com/nonexistent-image-12345.jpg';
|
||||
document.body.appendChild(img);
|
||||
setTimeout(() => img.remove(), 1000);
|
||||
}
|
||||
|
||||
function testWithContext() {
|
||||
sendLog('INFO', 'Test mit Kontext-Informationen', {
|
||||
testId: 'CTX-001',
|
||||
user: 'Tester',
|
||||
timestamp: Date.now(),
|
||||
browser: navigator.userAgent
|
||||
});
|
||||
}
|
||||
|
||||
function testWithComplexContext() {
|
||||
sendLog('INFO', 'Test mit komplexem Kontext', {
|
||||
testId: 'CTX-002',
|
||||
nested: {
|
||||
level1: {
|
||||
level2: {
|
||||
value: 'Tief verschachtelt'
|
||||
}
|
||||
}
|
||||
},
|
||||
array: [1, 2, 3, 4, 5],
|
||||
metadata: {
|
||||
display: 'Test-Display',
|
||||
location: 'Test-Raum',
|
||||
version: '1.2'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMultipleLogs(count) {
|
||||
logToConsole(`🚀 Sende ${count} Logs...`, 'info');
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
await sendLog('INFO', `Performance Test Log ${i + 1}/${count}`, {
|
||||
test: 'performance',
|
||||
index: i,
|
||||
total: count
|
||||
});
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logToConsole(`✅ ${count} Logs in ${duration}ms gesendet (${(duration/count).toFixed(2)}ms pro Log)`, 'success');
|
||||
}
|
||||
|
||||
// Global Error Handler für Tests
|
||||
window.onerror = function(msg, url, line, col, error) {
|
||||
sendLog('FATAL', `JavaScript Error: ${msg}`, {
|
||||
file: url,
|
||||
line: line,
|
||||
column: col,
|
||||
stack: error?.stack
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
sendLog('ERROR', `Unhandled Promise Rejection: ${event.reason}`, {
|
||||
promise: event.promise?.toString()
|
||||
});
|
||||
});
|
||||
|
||||
const originalError = console.error;
|
||||
console.error = function(...args) {
|
||||
sendLog('ERROR', `Console Error: ${args.join(' ')}`);
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function(...args) {
|
||||
sendLog('WARNING', `Console Warning: ${args.join(' ')}`);
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Initial Log
|
||||
logToConsole('🚀 Test-Interface geladen', 'success');
|
||||
sendLog('INFO', 'Test-Interface geöffnet', {
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
319
public/_cabinet/view-logs.php
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
<?php
|
||||
// view-logs.php - Einfacher Log-Viewer für Cabinet Digital Signage
|
||||
// ACHTUNG: In Produktion mit Passwortschutz versehen!
|
||||
|
||||
$logsDir = __DIR__ . '/logs';
|
||||
$selectedFile = $_GET['file'] ?? null;
|
||||
$logLevel = $_GET['level'] ?? 'all';
|
||||
$lines = $_GET['lines'] ?? 100;
|
||||
|
||||
// Verfügbare Log-Dateien
|
||||
$logFiles = glob($logsDir . '/*.log');
|
||||
rsort($logFiles); // Neueste zuerst
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cabinet Logs Viewer</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #252526;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #009FE3;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
select,
|
||||
button,
|
||||
input {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #3e3e42;
|
||||
background-color: #2d2d30;
|
||||
color: #d4d4d4;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background-color: #009FE3;
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0082bd;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background-color: #252526;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
margin-bottom: 10px;
|
||||
padding: 5px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.log-line.FATAL {
|
||||
border-left-color: #f44336;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
|
||||
.log-line.ERROR {
|
||||
border-left-color: #ff9800;
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
}
|
||||
|
||||
.log-line.WARNING {
|
||||
border-left-color: #ffeb3b;
|
||||
background-color: rgba(255, 235, 59, 0.1);
|
||||
}
|
||||
|
||||
.log-line.INFO {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.level {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.level.FATAL {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.level.ERROR {
|
||||
background-color: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.level.WARNING {
|
||||
background-color: #ffeb3b;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.level.INFO {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #858585;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.context {
|
||||
margin-left: 30px;
|
||||
color: #9cdcfe;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: #252526;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #009FE3;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #858585;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📊 Cabinet Digital Signage - Log Viewer</h1>
|
||||
<div class="controls">
|
||||
<select name="file" id="file-select" onchange="location.href='?file=' + this.value + '&lines=<?= $lines ?>'">
|
||||
<option value="">-- Wähle Log-Datei --</option>
|
||||
<?php foreach ($logFiles as $file):
|
||||
$filename = basename($file);
|
||||
$selected = ($selectedFile === $file) ? 'selected' : '';
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($file) ?>" <?= $selected ?>>
|
||||
<?= htmlspecialchars($filename) ?> (<?= formatBytes(filesize($file)) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<select name="lines" id="lines-select" onchange="location.href='?file=<?= urlencode($selectedFile) ?>&lines=' + this.value">
|
||||
<option value="50" <?= $lines == 50 ? 'selected' : '' ?>>50 Zeilen</option>
|
||||
<option value="100" <?= $lines == 100 ? 'selected' : '' ?>>100 Zeilen</option>
|
||||
<option value="500" <?= $lines == 500 ? 'selected' : '' ?>>500 Zeilen</option>
|
||||
<option value="1000" <?= $lines == 1000 ? 'selected' : '' ?>>1000 Zeilen</option>
|
||||
<option value="-1" <?= $lines == -1 ? 'selected' : '' ?>>Alle</option>
|
||||
</select>
|
||||
|
||||
<button onclick="location.reload()">🔄 Aktualisieren</button>
|
||||
<button onclick="autoRefresh()">⏱️ Auto-Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
if ($selectedFile && file_exists($selectedFile)) {
|
||||
// Statistiken
|
||||
$allContent = file_get_contents($selectedFile);
|
||||
$stats = [
|
||||
'FATAL' => substr_count($allContent, '[FATAL]'),
|
||||
'ERROR' => substr_count($allContent, '[ERROR]'),
|
||||
'WARNING' => substr_count($allContent, '[WARNING]'),
|
||||
'INFO' => substr_count($allContent, '[INFO]')
|
||||
];
|
||||
?>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #f44336;"><?= $stats['FATAL'] ?></div>
|
||||
<div class="stat-label">FATAL Errors</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #ff9800;"><?= $stats['ERROR'] ?></div>
|
||||
<div class="stat-label">Errors</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #ffeb3b;"><?= $stats['WARNING'] ?></div>
|
||||
<div class="stat-label">Warnings</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="color: #4caf50;"><?= $stats['INFO'] ?></div>
|
||||
<div class="stat-label">Info Messages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log-container">
|
||||
<?php
|
||||
// Log-Datei einlesen
|
||||
$logLines = file($selectedFile, FILE_IGNORE_NEW_LINES);
|
||||
|
||||
// Nur die letzten X Zeilen anzeigen
|
||||
if ($lines > 0) {
|
||||
$logLines = array_slice($logLines, -$lines);
|
||||
}
|
||||
|
||||
$currentEntry = '';
|
||||
foreach ($logLines as $line) {
|
||||
// Erkennen von Log-Level
|
||||
if (preg_match('/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\s+\[([A-Z]+)\s*\]\s+(.+)/', $line, $matches)) {
|
||||
if ($currentEntry) {
|
||||
echo "</div>";
|
||||
}
|
||||
$timestamp = $matches[1];
|
||||
$level = trim($matches[2]);
|
||||
$message = htmlspecialchars($matches[3]);
|
||||
|
||||
echo "<div class='log-line {$level}'>";
|
||||
echo "<span class='timestamp'>{$timestamp}</span>";
|
||||
echo "<span class='level {$level}'>{$level}</span>";
|
||||
echo "<span class='message'>{$message}</span>";
|
||||
$currentEntry = $level;
|
||||
} else {
|
||||
// Kontext-Zeilen
|
||||
echo "<div class='context'>" . htmlspecialchars($line) . "</div>";
|
||||
}
|
||||
}
|
||||
if ($currentEntry) {
|
||||
echo "</div>";
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<?php } else { ?>
|
||||
<div class="log-container">
|
||||
<p>Bitte wähle eine Log-Datei aus dem Dropdown-Menü.</p>
|
||||
<?php if (empty($logFiles)): ?>
|
||||
<p style="color: #ff9800;">⚠️ Keine Log-Dateien gefunden. Stelle sicher, dass das Logging aktiv ist und das Verzeichnis 'logs/' existiert.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
|
||||
<script>
|
||||
let autoRefreshInterval = null;
|
||||
|
||||
function autoRefresh() {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
alert('Auto-Refresh deaktiviert');
|
||||
} else {
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
location.reload();
|
||||
}, 10000); // Alle 10 Sekunden
|
||||
alert('Auto-Refresh aktiviert (alle 10 Sekunden)');
|
||||
}
|
||||
}
|
||||
|
||||
// Automatisch zum Ende scrollen
|
||||
const logContainer = document.querySelector('.log-container');
|
||||
if (logContainer) {
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
<?php
|
||||
function formatBytes($bytes, $precision = 2)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
?>
|
||||
13171
public/flux/flux.js
Normal file
BIN
public/img/logos/b2a-logo-negativ.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/img/logos/b2a-logo-positiv.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/img/logos/b2in-logo-negative.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/img/logos/b2in-logo-positive.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/img/logos/stileigentum-logo-negativ.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/img/logos/stileigentum-logo-positiv.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/img/logos/style2own-logo-negativ.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
public/img/logos/style2own-logo-positiv.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -63,13 +63,53 @@ select:focus[data-flux-control] {
|
|||
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground;
|
||||
}
|
||||
|
||||
/* Verbesserte Kontraste für Input-Werte */
|
||||
input[data-flux-control],
|
||||
textarea[data-flux-control],
|
||||
select[data-flux-control] {
|
||||
@apply text-zinc-900 dark:text-zinc-50;
|
||||
}
|
||||
|
||||
/* Placeholder sollte deutlich heller sein */
|
||||
input[data-flux-control]::placeholder,
|
||||
textarea[data-flux-control]::placeholder {
|
||||
@apply text-zinc-400 dark:text-zinc-700;
|
||||
}
|
||||
|
||||
.shadow-elegant {
|
||||
box-shadow: 0 4px 12px -8px rgba(0, 136, 204, 0.4);
|
||||
}
|
||||
.bg-background {
|
||||
background-color: hsl(var(--background));
|
||||
|
||||
& button,
|
||||
& .btn,
|
||||
& .btn-secondary,
|
||||
& a[class*="btn"],
|
||||
& [role="button"] {
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
filter 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* \[:where(&)\]:size-4 {
|
||||
@apply size-4;
|
||||
} */
|
||||
& button:hover,
|
||||
& .btn:hover,
|
||||
& .btn-secondary:hover,
|
||||
& a[class*="btn"]:hover,
|
||||
& [role="button"]:hover {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.06),
|
||||
0 2px 4px rgba(0, 0, 0, 0.08),
|
||||
0 4px 8px rgba(0, 0, 0, 0.1),
|
||||
0 8px 16px rgba(0, 0, 0, 0.08),
|
||||
0 0 10px hsla(199, 74%, 49%, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Scroll-Optimierung für sanftere Animationen */
|
||||
& {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Theme-specific button styles for Backend Portal */
|
||||
|
||||
|
|
|
|||
|
|
@ -83,16 +83,16 @@ h1, h2, h3, h4, h5, h6 {
|
|||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: hsl(var(--secondary));
|
||||
background-color: hsl(var(--primary-dark));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
hsl(var(--primary-light)) 0%,
|
||||
hsl(var(--primary)) 100%
|
||||
) !important;
|
||||
color: hsl(var(--secondary-lighter));
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +112,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary-accent:hover {
|
||||
|
|
@ -121,7 +122,6 @@ h1, h2, h3, h4, h5, h6 {
|
|||
hsl(var(--primary-light)) 0%,
|
||||
hsl(var(--primary)) 100%
|
||||
) !important;
|
||||
color: hsl(var(--secondary-lighter));
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
|
|
@ -141,12 +141,18 @@ h1, h2, h3, h4, h5, h6 {
|
|||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-lighter));
|
||||
}
|
||||
background-color: hsl(var(--secondary-dark));
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
hsl(var(--secondary-dark)) 0%,
|
||||
hsl(var(--secondary)) 100%
|
||||
) !important;
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary-accent {
|
||||
background-color: hsl(var(--secondary));
|
||||
|
|
@ -163,6 +169,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary-accent:hover {
|
||||
|
|
@ -172,9 +179,14 @@ h1, h2, h3, h4, h5, h6 {
|
|||
hsl(var(--secondary-dark)) 0%,
|
||||
hsl(var(--secondary)) 100%
|
||||
) !important;
|
||||
color: hsl(var(--primary-lighter));
|
||||
}
|
||||
|
||||
.btn-secondary-accent.small {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
|
||||
.btn-accent {
|
||||
background-color: hsl(var(--accent));
|
||||
background: linear-gradient(
|
||||
|
|
@ -190,6 +202,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
|
|
@ -648,3 +661,191 @@ h1, h2, h3, h4, h5, h6 {
|
|||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shadow Effects - Updated for new primary color */
|
||||
.shadow-elegant {
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 136, 204, 0.2);
|
||||
}
|
||||
|
||||
.shadow-elegant-white {
|
||||
box-shadow: 0 10px 30px -10px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.shadow-white {
|
||||
box-shadow: 0 10px 30px -10px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.elegant-shadow-card {
|
||||
box-shadow:
|
||||
0 2px 8px -2px rgba(0, 0, 0, 0.08),
|
||||
0 4px 10px 0px rgba(0, 136, 204,1);
|
||||
}
|
||||
|
||||
.shadow-premium {
|
||||
box-shadow: 0 20px 60px -15px rgba(0, 136, 204, 0.3);
|
||||
}
|
||||
|
||||
/* Glow Effects - Inspired by Möbius band's luminous edge */
|
||||
.glow-soft {
|
||||
box-shadow:
|
||||
0 0 10px rgba(0, 155, 221, 0.15),
|
||||
0 0 20px rgba(0, 155, 221, 0.1),
|
||||
0 4px 10px -4px rgba(0, 136, 204, 0.2);
|
||||
}
|
||||
|
||||
.glow-medium {
|
||||
box-shadow:
|
||||
0 0 15px rgba(0, 155, 221, 0.25),
|
||||
0 0 30px rgba(0, 155, 221, 0.15),
|
||||
0 5px 10px -5px rgba(0, 136, 204, 0.3);
|
||||
}
|
||||
|
||||
.glow-strong {
|
||||
box-shadow:
|
||||
0 0 30px rgba(0, 155, 221, 0.35),
|
||||
0 0 60px rgba(0, 155, 221, 0.2),
|
||||
0 0 90px rgba(0, 113, 168, 0.1),
|
||||
0 10px 10px -10px rgba(0, 136, 204, 0.4);
|
||||
}
|
||||
.icon-secondary-linear {
|
||||
background-color: hsl(var(--secondary) / 0.1);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--secondary)) 0%,
|
||||
hsl(var(--secondary-dark)) 100%
|
||||
);
|
||||
color: hsl(var(--secondary-foreground)) !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-section-title {
|
||||
line-height: 0.95em;
|
||||
}
|
||||
.variante-glass-flow {
|
||||
|
||||
& * {
|
||||
will-change: auto;
|
||||
} section,
|
||||
& .section-container {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsl(var(--background)/90%) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
& section.bg-accent {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--accent) /0.5) 0%,
|
||||
hsl(var(--accent-dark) / 0.5) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
& section.bg-secondary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--secondary)) 0%,
|
||||
hsl(var(--secondary-dark)) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
& div.bg-hero-container {
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--hero-container)) 0%,
|
||||
hsl(var(--hero-container-dark)) 100%
|
||||
);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.1);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
/* Cards - Multi-Layer-Schatten für Tiefe (Optimiert) */
|
||||
& .card,
|
||||
& [class*="card"],
|
||||
& .bg-card {
|
||||
background:
|
||||
linear-gradient(
|
||||
145deg,
|
||||
hsl(0 0% 100%) 0%,
|
||||
hsl(0 0% 99.5%) 40%,
|
||||
hsl(0 0% 99%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03),
|
||||
0 1px 1px rgba(0, 0, 0, 0.02),
|
||||
0 2px 3px rgba(0, 0, 0, 0.025),
|
||||
0 4px 6px rgba(0, 0, 0, 0.03),
|
||||
0 8px 12px rgba(0, 0, 0, 0.04),
|
||||
0 12px 20px rgba(0, 0, 0, 0.045),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.8),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
filter 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
& .card:hover,
|
||||
& [class*="card"]:hover {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04),
|
||||
0 2px 4px rgba(0, 0, 0, 0.03),
|
||||
0 4px 8px rgba(0, 0, 0, 0.04),
|
||||
0 8px 16px rgba(0, 0, 0, 0.05),
|
||||
0 16px 24px rgba(0, 0, 0, 0.07),
|
||||
0 24px 40px rgba(0, 0, 0, 0.08),
|
||||
0 32px 64px rgba(0, 0, 0, 0.06),
|
||||
inset 0 1px 3px rgba(255, 255, 255, 0.9),
|
||||
inset 0 -1px 2px rgba(0, 0, 0, 0.03);
|
||||
transform: translateY(-2px) scale(1.005);
|
||||
filter: brightness(1.01);
|
||||
}
|
||||
|
||||
& button,
|
||||
& .btn,
|
||||
& .btn-secondary,
|
||||
& a[class*="btn"],
|
||||
& [role="button"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.05),
|
||||
0 2px 4px rgba(0, 0, 0, 0.06),
|
||||
0 4px 8px rgba(0, 0, 0, 0.07),
|
||||
0 8px 16px rgba(0, 0, 0, 0.05);
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
filter 0.2s ease;
|
||||
}
|
||||
|
||||
& button:hover,
|
||||
& .btn:hover,
|
||||
& .btn-secondary:hover,
|
||||
& a[class*="btn"]:hover,
|
||||
& [role="button"]:hover {
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.06),
|
||||
0 4px 8px rgba(0, 0, 0, 0.08),
|
||||
0 8px 16px rgba(0, 0, 0, 0.1),
|
||||
0 16px 32px rgba(0, 0, 0, 0.08),
|
||||
0 0 40px hsla(199, 74%, 49%, 0.3);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Scroll-Optimierung für sanftere Animationen */
|
||||
& {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,14 @@
|
|||
|
||||
/* B2A Theme Colors */
|
||||
--primary: 207 70% 26%; /* #123f6d - Azur Blue */
|
||||
--primary-light: 207 70% 36%; /* #123f6d - Azur Blue */
|
||||
--primary-lighter: 207 70% 86%; /* #123f6d - Azur Blue */
|
||||
--primary-dark: 207 70% 20%; /* #123f6d - Azur Blue */
|
||||
--primary-foreground: 30 25% 98%; /* #faf9f7 - Off White */
|
||||
--secondary: 352 76% 48%; /* #ce1d2e - Liberty Red */
|
||||
--secondary-light: 352 76% 56%; /* #ce1d2e - Liberty Red */
|
||||
--secondary-lighter: 352 76% 75%; /* #ce1d2e - Liberty Red */
|
||||
--secondary-dark: 352 76% 42%; /* #ce1d2e - Liberty Red */
|
||||
--secondary-foreground: 0 25% 96%; /* #hsl(0 25% 96%) - Off White */
|
||||
|
||||
/* Neutral colors */
|
||||
|
|
@ -39,13 +45,18 @@
|
|||
|
||||
/* Hero container background */
|
||||
--hero-container: 0 0% 91%; /* #e8e8e8 - Light Gray */
|
||||
|
||||
--hero-container-dark: 0 0% 83%; /* #e8e8e8 - Light Gray */
|
||||
--hero-container-light: 0 0% 93%; /* #e8e8e8 - Light Gray */
|
||||
/* Shadows */
|
||||
--shadow-warm: 0 10px 30px -15px hsl(var(--foreground) / 0.1);
|
||||
--shadow-card: 0 4px 20px -8px hsl(var(--foreground) / 0.08);
|
||||
--shadow-elevated: 0 20px 40px -20px hsl(var(--foreground) / 0.15);
|
||||
--shadow-accent-glow: 0 0 30px hsl(var(--secondary) / 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
/* Font families */
|
||||
--font-primary: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-secondary: 'Merriweather', Georgia, serif;
|
||||
|
|
|
|||
|
|
@ -72,192 +72,5 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Shadow Effects - Updated for new primary color */
|
||||
.shadow-elegant {
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 136, 204, 0.2);
|
||||
}
|
||||
|
||||
.shadow-elegant-white {
|
||||
box-shadow: 0 10px 30px -10px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.shadow-white {
|
||||
box-shadow: 0 10px 30px -10px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.elegant-shadow-card {
|
||||
box-shadow:
|
||||
0 2px 8px -2px rgba(0, 0, 0, 0.08),
|
||||
0 4px 10px 0px rgba(0, 136, 204,1);
|
||||
}
|
||||
|
||||
.shadow-premium {
|
||||
box-shadow: 0 20px 60px -15px rgba(0, 136, 204, 0.3);
|
||||
}
|
||||
|
||||
/* Glow Effects - Inspired by Möbius band's luminous edge */
|
||||
.glow-soft {
|
||||
box-shadow:
|
||||
0 0 10px rgba(0, 155, 221, 0.15),
|
||||
0 0 20px rgba(0, 155, 221, 0.1),
|
||||
0 4px 10px -4px rgba(0, 136, 204, 0.2);
|
||||
}
|
||||
|
||||
.glow-medium {
|
||||
box-shadow:
|
||||
0 0 15px rgba(0, 155, 221, 0.25),
|
||||
0 0 30px rgba(0, 155, 221, 0.15),
|
||||
0 5px 10px -5px rgba(0, 136, 204, 0.3);
|
||||
}
|
||||
|
||||
.glow-strong {
|
||||
box-shadow:
|
||||
0 0 30px rgba(0, 155, 221, 0.35),
|
||||
0 0 60px rgba(0, 155, 221, 0.2),
|
||||
0 0 90px rgba(0, 113, 168, 0.1),
|
||||
0 10px 10px -10px rgba(0, 136, 204, 0.4);
|
||||
}
|
||||
.icon-secondary-linear {
|
||||
background-color: hsl(var(--secondary) / 0.1);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--secondary)) 0%,
|
||||
hsl(var(--secondary-dark)) 100%
|
||||
);
|
||||
color: hsl(var(--secondary-foreground)) !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-section-title {
|
||||
line-height: 0.95em;
|
||||
}
|
||||
.variante-glass-flow {
|
||||
|
||||
& * {
|
||||
will-change: auto;
|
||||
} section,
|
||||
& .section-container {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--background)) 0%,
|
||||
hsl(var(--background)/90%) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
& section.bg-accent {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--accent) /0.5) 0%,
|
||||
hsl(var(--accent-dark) / 0.5) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
& section.bg-secondary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--secondary)) 0%,
|
||||
hsl(var(--secondary-dark)) 100%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
& div.bg-hero-container {
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--hero-container)) 0%,
|
||||
hsl(var(--hero-container-dark)) 100%
|
||||
);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.1);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
/* Cards - Multi-Layer-Schatten für Tiefe (Optimiert) */
|
||||
& .card,
|
||||
& [class*="card"],
|
||||
& .bg-card {
|
||||
background:
|
||||
linear-gradient(
|
||||
145deg,
|
||||
hsl(0 0% 100%) 0%,
|
||||
hsl(0 0% 99.5%) 40%,
|
||||
hsl(0 0% 99%) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03),
|
||||
0 1px 1px rgba(0, 0, 0, 0.02),
|
||||
0 2px 3px rgba(0, 0, 0, 0.025),
|
||||
0 4px 6px rgba(0, 0, 0, 0.03),
|
||||
0 8px 12px rgba(0, 0, 0, 0.04),
|
||||
0 12px 20px rgba(0, 0, 0, 0.045),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.8),
|
||||
inset 0 -1px 1px rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
filter 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
& .card:hover,
|
||||
& [class*="card"]:hover {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04),
|
||||
0 2px 4px rgba(0, 0, 0, 0.03),
|
||||
0 4px 8px rgba(0, 0, 0, 0.04),
|
||||
0 8px 16px rgba(0, 0, 0, 0.05),
|
||||
0 16px 24px rgba(0, 0, 0, 0.07),
|
||||
0 24px 40px rgba(0, 0, 0, 0.08),
|
||||
0 32px 64px rgba(0, 0, 0, 0.06),
|
||||
inset 0 1px 3px rgba(255, 255, 255, 0.9),
|
||||
inset 0 -1px 2px rgba(0, 0, 0, 0.03);
|
||||
transform: translateY(-2px) scale(1.005);
|
||||
filter: brightness(1.01);
|
||||
}
|
||||
|
||||
& button,
|
||||
& .btn,
|
||||
& .btn-secondary,
|
||||
& a[class*="btn"],
|
||||
& [role="button"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.05),
|
||||
0 2px 4px rgba(0, 0, 0, 0.06),
|
||||
0 4px 8px rgba(0, 0, 0, 0.07),
|
||||
0 8px 16px rgba(0, 0, 0, 0.05);
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
filter 0.2s ease;
|
||||
}
|
||||
|
||||
& button:hover,
|
||||
& .btn:hover,
|
||||
& .btn-secondary:hover,
|
||||
& a[class*="btn"]:hover,
|
||||
& [role="button"]:hover {
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.06),
|
||||
0 4px 8px rgba(0, 0, 0, 0.08),
|
||||
0 8px 16px rgba(0, 0, 0, 0.1),
|
||||
0 16px 32px rgba(0, 0, 0, 0.08),
|
||||
0 0 40px hsla(199, 74%, 49%, 0.3);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Scroll-Optimierung für sanftere Animationen */
|
||||
& {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
@import "./shared-styles.css";
|
||||
|
|
|
|||
|
|
@ -14,8 +14,14 @@
|
|||
|
||||
/* Stileigentum Theme Colors */
|
||||
--primary: 209 65% 20%; /* #123453 - Imperial Blue */
|
||||
--primary-light: 209 65% 30%; /* #123453 - Imperial Blue */
|
||||
--primary-lighter: 209 65% 80%; /* #123453 - Imperial Blue */
|
||||
--primary-dark: 209 65% 15%; /* #123453 - Imperial Blue */
|
||||
--primary-foreground: 0 25% 96%; /* #hsl(0 25% 96%) - Off White */
|
||||
--secondary: 38 40% 66%; /* #c9ac84 - Sand Gold */
|
||||
--secondary-light: 38 40% 76%; /* #c9ac84 - Sand Gold */
|
||||
--secondary-lighter: 38 40% 85%; /* #c9ac84 - Sand Gold */
|
||||
--secondary-dark: 38 40% 55%; /* #c9ac84 - Sand Gold */
|
||||
--secondary-foreground: 20 14% 16%; /* #2a2a2a - Dark Gray */
|
||||
|
||||
/* Neutral colors */
|
||||
|
|
@ -38,13 +44,18 @@
|
|||
|
||||
/* Hero container background */
|
||||
--hero-container: 0 0% 91%; /* #e8e8e8 - Light Gray */
|
||||
|
||||
--hero-container-dark: 0 0% 83%; /* #e8e8e8 - Light Gray */
|
||||
--hero-container-light: 0 0% 93%; /* #e8e8e8 - Light Gray */
|
||||
/* Shadows */
|
||||
--shadow-warm: 0 10px 30px -15px hsl(var(--foreground) / 0.1);
|
||||
--shadow-card: 0 4px 20px -8px hsl(var(--foreground) / 0.08);
|
||||
--shadow-elevated: 0 20px 40px -20px hsl(var(--foreground) / 0.15);
|
||||
--shadow-accent-glow: 0 0 30px hsl(var(--secondary) / 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
/* Font families */
|
||||
--font-primary: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-secondary: 'EB Garamond', Georgia, serif;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,14 @@
|
|||
|
||||
/* Style2own Theme Colors */
|
||||
--primary: 195 100% 34%; /* #007aab - Style Blue */
|
||||
--primary-light: 195 100% 44%; /* #007aab - Style Blue */
|
||||
--primary-lighter: 195 100% 84%; /* #007aab - Style Blue */
|
||||
--primary-dark: 195 100% 24%; /* #007aab - Style Blue */
|
||||
--primary-foreground: 30 25% 98%; /* #faf9f7 - Off White */
|
||||
--secondary: 46 95% 56%; /* #fbaf22 - Style Sun */
|
||||
--secondary-light: 46 95% 66%; /* #fbaf22 - Style Sun */
|
||||
--secondary-lighter: 46 95% 86%; /* #fbaf22 - Style Sun */
|
||||
--secondary-dark: 46 95% 46%; /* #fbaf22 - Style Sun */
|
||||
--secondary-foreground: 20 14% 16%; /* #2a2a2a - Dark Gray */
|
||||
|
||||
/* Neutral colors */
|
||||
|
|
@ -39,6 +45,12 @@
|
|||
|
||||
/* Hero container background */
|
||||
--hero-container: 0 0% 91%; /* #e8e8e8 - Light Gray */
|
||||
--hero-container-dark: 0 0% 83%; /* #e8e8e8 - Light Gray */
|
||||
--hero-container-light: 0 0% 93%; /* #e8e8e8 - Light Gray */
|
||||
|
||||
/* Transitions */
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-warm: 0 10px 30px -15px hsl(var(--foreground) / 0.1);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,41 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Globaler Observer, der wiederverwendet wird
|
||||
let globalObserver = null;
|
||||
|
||||
// Warte bis DOM vollständig geladen ist
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAnimations);
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
function init() {
|
||||
initAnimations();
|
||||
|
||||
// Scroll Progress Indicator
|
||||
initScrollProgress();
|
||||
|
||||
// Premium Sticky Header
|
||||
initStickyHeader();
|
||||
|
||||
// Livewire Event Listener für dynamisch geladene Komponenten
|
||||
document.addEventListener('livewire:navigated', function() {
|
||||
initAnimations();
|
||||
});
|
||||
|
||||
// Fallback für ältere Livewire-Versionen
|
||||
document.addEventListener('livewire:load', function() {
|
||||
initAnimations();
|
||||
});
|
||||
|
||||
// Nach Livewire-Updates
|
||||
window.addEventListener('livewire:update', function() {
|
||||
setTimeout(() => {
|
||||
initAnimations();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
function initAnimations() {
|
||||
|
|
@ -17,8 +47,9 @@
|
|||
rootMargin: '0px 0px -80px 0px'
|
||||
};
|
||||
|
||||
// Erstelle Observer
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
// Erstelle Observer wenn noch nicht vorhanden
|
||||
if (!globalObserver) {
|
||||
globalObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
// Füge is-visible Klasse mit kleinem Delay hinzu für sanfteren Effekt
|
||||
|
|
@ -27,22 +58,25 @@
|
|||
}, 50);
|
||||
|
||||
// Observer beenden nach Animation für bessere Performance
|
||||
observer.unobserve(entry.target);
|
||||
globalObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, observerOptions);
|
||||
}
|
||||
|
||||
// Finde alle Elemente mit Animation-Klassen
|
||||
// Finde alle Elemente mit Animation-Klassen, die noch nicht beobachtet werden
|
||||
const animatedElements = document.querySelectorAll(
|
||||
'.scroll-animate, .fade-in, .slide-up, .slide-right, .slide-left, .scale-in'
|
||||
'.scroll-animate:not(.is-visible):not(.observed), .fade-in:not(.is-visible):not(.observed), .slide-up:not(.is-visible):not(.observed), .slide-right:not(.is-visible):not(.observed), .slide-left:not(.is-visible):not(.observed), .scale-in:not(.is-visible):not(.observed), .slide-down:not(.is-visible):not(.observed)'
|
||||
);
|
||||
|
||||
// Beobachte jedes Element
|
||||
animatedElements.forEach(el => {
|
||||
observer.observe(el);
|
||||
el.classList.add('observed'); // Markiere als beobachtet
|
||||
globalObserver.observe(el);
|
||||
});
|
||||
|
||||
// Smooth Scroll für Anchor-Links
|
||||
// Smooth Scroll für Anchor-Links (nur einmal registrieren)
|
||||
if (!window.smoothScrollInitialized) {
|
||||
document.addEventListener('click', function(e) {
|
||||
const target = e.target.closest('a[href^="#"]');
|
||||
if (target && target.hash) {
|
||||
|
|
@ -61,12 +95,8 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll Progress Indicator
|
||||
initScrollProgress();
|
||||
|
||||
// Premium Sticky Header
|
||||
initStickyHeader();
|
||||
window.smoothScrollInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
function initStickyHeader() {
|
||||
|
|
|
|||
85
resources/lang/de/registration.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'roles' => [
|
||||
'customer' => [
|
||||
'label' => 'Kunde',
|
||||
'description' => 'Zugang als Kunde mit Ihrer persönlichen Kundennummer.',
|
||||
],
|
||||
'broker' => [
|
||||
'label' => 'Makler',
|
||||
'description' => 'Registrierung für Makler mit eigener Makler-Nummer.',
|
||||
],
|
||||
'manufacturer' => [
|
||||
'label' => 'Hersteller',
|
||||
'description' => 'Zugang für Hersteller, um Produkte zu pflegen.',
|
||||
],
|
||||
'retailer' => [
|
||||
'label' => 'Händler',
|
||||
'description' => 'Zugang für Händler, um Sortiment und Kunden zu verwalten.',
|
||||
],
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'code_entry' => [
|
||||
'title' => 'Code eingeben',
|
||||
'description' => 'Scannen Sie den QR-Code, geben Sie Ihren Registrierungscode ein (z.B. M00100001 oder K01102513) und lassen Sie ihn prüfen.',
|
||||
],
|
||||
'create_account' => [
|
||||
'title' => 'Konto erstellen',
|
||||
'description' => 'Nach erfolgreicher Prüfung legen Sie Ihr Konto an. Rolle: :role – damit wird Ihr Zugang korrekt zugeordnet.',
|
||||
],
|
||||
'complete_onboarding' => [
|
||||
'title' => 'Onboarding abschließen',
|
||||
'description' => 'Sie werden direkt in den passenden Setup-Prozess geleitet und schließen Ihr Profil in wenigen Minuten ab.',
|
||||
],
|
||||
],
|
||||
|
||||
'messages' => [
|
||||
'code_unique' => 'Jeder Code ist einzigartig und kann nur einmalig eingelöst werden.',
|
||||
'code_format' => 'Format: Buchstabe + 4×2 Ziffern, z.B. M 99 11 22 44',
|
||||
'code_invalid' => 'Dieser Registrierungscode ist ungültig oder wurde bereits verwendet.',
|
||||
'code_accepted' => 'Code akzeptiert. Bitte registrieren Sie sich jetzt.',
|
||||
'code_problems' => 'Probleme mit dem Code? Wenden Sie sich an Ihren Ansprechpartner oder den Support.',
|
||||
],
|
||||
|
||||
'titles' => [
|
||||
'registration' => 'Registrierung',
|
||||
'access_for_role' => 'Zugang für :role',
|
||||
'enter_code' => 'Geben Sie Ihren persönlichen Code ein',
|
||||
'how_it_works' => 'So läuft Ihre Registrierung ab',
|
||||
'how_it_works_description' => 'Drei Schritte bis zum Zugang – mit Ihrem persönlichen Registrierungscode und dem passenden Onboarding für :role.',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'check_code' => 'Code prüfen & weiter',
|
||||
'how_it_works' => 'so gehts',
|
||||
'start_now' => 'Jetzt starten',
|
||||
],
|
||||
|
||||
'thank_you' => [
|
||||
'subtitle' => 'Registrierung erfolgreich',
|
||||
'title' => 'Vielen Dank für Ihre Registrierung!',
|
||||
'description' => 'Ihr Konto wurde erfolgreich erstellt. Um die Registrierung abzuschließen, bestätigen Sie bitte Ihre E-Mail-Adresse.',
|
||||
|
||||
'email_sent_title' => 'Bestätigungs-E-Mail wurde versendet',
|
||||
'email_sent_description' => 'Wir haben eine E-Mail mit einem Bestätigungslink an :email gesendet. Bitte überprüfen Sie Ihr Postfach.',
|
||||
|
||||
'next_steps_title' => 'So geht es weiter:',
|
||||
|
||||
'step1_title' => 'E-Mail-Postfach öffnen',
|
||||
'step1_description' => 'Öffnen Sie Ihr E-Mail-Postfach und suchen Sie nach unserer Bestätigungsmail.',
|
||||
|
||||
'step2_title' => 'Bestätigungslink klicken',
|
||||
'step2_description' => 'Klicken Sie auf den Bestätigungslink in der E-Mail, um Ihre E-Mail-Adresse zu verifizieren.',
|
||||
|
||||
'step3_title' => 'Anmelden und Setup abschließen',
|
||||
'step3_description' => 'Nach der Bestätigung können Sie sich anmelden und Ihr Profil im Setup-Wizard vervollständigen.',
|
||||
|
||||
'spam_check_title' => 'Wichtiger Hinweis',
|
||||
'spam_check_description' => 'Falls Sie keine E-Mail erhalten haben, überprüfen Sie bitte auch Ihren Spam-Ordner.',
|
||||
|
||||
'already_verified' => 'E-Mail bereits bestätigt?',
|
||||
'login_button' => 'Zum Login',
|
||||
],
|
||||
];
|
||||
10
resources/lang/de/roles.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'broker' => 'Makler',
|
||||
'retailer' => 'Händler',
|
||||
'manufacturer' => 'Hersteller',
|
||||
'customer' => 'Kunde',
|
||||
'Sprache' => 'Sprache',
|
||||
'role' => 'Rolle',
|
||||
];
|
||||
85
resources/lang/en/registration.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'roles' => [
|
||||
'customer' => [
|
||||
'label' => 'Customer',
|
||||
'description' => 'Access as a customer with your personal customer number.',
|
||||
],
|
||||
'broker' => [
|
||||
'label' => 'Estate Agent',
|
||||
'description' => 'Registration for estate agents with their own agent number.',
|
||||
],
|
||||
'manufacturer' => [
|
||||
'label' => 'Manufacturer',
|
||||
'description' => 'Access for manufacturers to manage products.',
|
||||
],
|
||||
'retailer' => [
|
||||
'label' => 'Retailer',
|
||||
'description' => 'Access for retailers to manage inventory and customers.',
|
||||
],
|
||||
],
|
||||
|
||||
'steps' => [
|
||||
'code_entry' => [
|
||||
'title' => 'Enter Code',
|
||||
'description' => 'Scan the QR code, enter your registration code (e.g. M00100001 or K01102513) and verify it.',
|
||||
],
|
||||
'create_account' => [
|
||||
'title' => 'Create Account',
|
||||
'description' => 'After successful verification, create your account. Role: :role – this ensures your access is correctly assigned.',
|
||||
],
|
||||
'complete_onboarding' => [
|
||||
'title' => 'Complete Onboarding',
|
||||
'description' => 'You will be guided directly to the appropriate setup process and complete your profile in just a few minutes.',
|
||||
],
|
||||
],
|
||||
|
||||
'messages' => [
|
||||
'code_unique' => 'Each code is unique and can only be redeemed once.',
|
||||
'code_format' => 'Format: Letter + 4×2 digits, e.g. M 99 11 22 44',
|
||||
'code_invalid' => 'This registration code is invalid or has already been used.',
|
||||
'code_accepted' => 'Code accepted. Please register now.',
|
||||
'code_problems' => 'Problems with the code? Contact your representative or support.',
|
||||
],
|
||||
|
||||
'titles' => [
|
||||
'registration' => 'Registration',
|
||||
'access_for_role' => 'Access for :role',
|
||||
'enter_code' => 'Enter Your Personal Code',
|
||||
'how_it_works' => 'How Your Registration Works',
|
||||
'how_it_works_description' => 'Three steps to access – with your personal registration code and the appropriate onboarding for :role.',
|
||||
],
|
||||
|
||||
'actions' => [
|
||||
'check_code' => 'Check Code & Continue',
|
||||
'how_it_works' => 'how it works',
|
||||
'start_now' => 'Start Now',
|
||||
],
|
||||
|
||||
'thank_you' => [
|
||||
'subtitle' => 'Registration Successful',
|
||||
'title' => 'Thank You for Your Registration!',
|
||||
'description' => 'Your account has been successfully created. To complete the registration, please verify your email address.',
|
||||
|
||||
'email_sent_title' => 'Verification Email Sent',
|
||||
'email_sent_description' => 'We have sent an email with a verification link to :email. Please check your inbox.',
|
||||
|
||||
'next_steps_title' => 'Next Steps:',
|
||||
|
||||
'step1_title' => 'Open Your Email',
|
||||
'step1_description' => 'Open your email inbox and look for our verification email.',
|
||||
|
||||
'step2_title' => 'Click Verification Link',
|
||||
'step2_description' => 'Click on the verification link in the email to verify your email address.',
|
||||
|
||||
'step3_title' => 'Login and Complete Setup',
|
||||
'step3_description' => 'After verification, you can log in and complete your profile in the setup wizard.',
|
||||
|
||||
'spam_check_title' => 'Important Note',
|
||||
'spam_check_description' => 'If you did not receive an email, please check your spam folder.',
|
||||
|
||||
'already_verified' => 'Email already verified?',
|
||||
'login_button' => 'Go to Login',
|
||||
],
|
||||
];
|
||||
|
|
@ -1,57 +1,268 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Partner;
|
||||
use App\Models\User;
|
||||
use App\Models\RegistrationCode;
|
||||
use App\Helpers\ThemeHelper;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public $userRole = '';
|
||||
public $roleIcon = 'shield-check';
|
||||
public $roleName = '';
|
||||
|
||||
// Admin KPIs
|
||||
public $activeHubs = 0;
|
||||
public $plannedHubs = 0;
|
||||
public $totalPartners = 0;
|
||||
public $partnersThisMonth = 0;
|
||||
public $platformRevenue = 0;
|
||||
public $systemStatus = 'green';
|
||||
public $pendingInvitations = 0;
|
||||
public $totalCustomers = 0;
|
||||
public array $data = [];
|
||||
|
||||
// Retailer KPIs
|
||||
public $openOrders = 0;
|
||||
public $monthlyRevenue = 0;
|
||||
public $productViews = 0;
|
||||
public $stockWarnings = 0;
|
||||
public $myCustomers = 0;
|
||||
|
||||
// Manufacturer KPIs
|
||||
public $brandReach = 0;
|
||||
public $activeProducts = 0;
|
||||
public $draftProducts = 0;
|
||||
public $totalViews = 0;
|
||||
|
||||
// Broker KPIs
|
||||
public $totalCommission = 0;
|
||||
public $pendingPayout = 0;
|
||||
public $generatedLeads = 0;
|
||||
public $referralLink = '';
|
||||
public $brokerCustomers = 0;
|
||||
|
||||
// Customer Brand Data
|
||||
public $customerBrand = 'b2in';
|
||||
public $customerBrandName = 'B2IN';
|
||||
public $customerBrandLogo = '';
|
||||
public $customerBrandColors = [];
|
||||
public $customerBrokerName = '';
|
||||
public $topOffers = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
$role = $user->roles->first();
|
||||
|
||||
if ($role) {
|
||||
$this->userRole = strtolower(str_replace('-', '', $role->name));
|
||||
$this->roleIcon = $role->icon ?? 'shield-check';
|
||||
$this->roleName = $role->display_name ?? $role->name;
|
||||
}
|
||||
|
||||
// Lade rollenspezifische Daten
|
||||
match ($this->userRole) {
|
||||
'admin', 'superadmin' => $this->loadAdminData(),
|
||||
'retailer' => $this->loadRetailerData($user),
|
||||
'manufacturer' => $this->loadManufacturerData($user),
|
||||
'broker', 'estateagent' => $this->loadBrokerData($user),
|
||||
'customer' => $this->loadCustomerData($user),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function loadAdminData(): void
|
||||
{
|
||||
// Platzhalter: Aktive Hubs (später aus Hub-Tabelle)
|
||||
$this->activeHubs = 3;
|
||||
$this->plannedHubs = 2;
|
||||
|
||||
// Partner-Wachstum
|
||||
$this->totalPartners = Partner::whereIn('type', ['Retailer', 'Manufacturer', 'Broker', 'Estate-Agent'])->count();
|
||||
$this->partnersThisMonth = Partner::whereIn('type', ['Retailer', 'Manufacturer', 'Broker', 'Estate-Agent'])
|
||||
->whereMonth('created_at', now()->month)
|
||||
->count();
|
||||
|
||||
// Platzhalter: Plattform-Umsatz
|
||||
$this->platformRevenue = 125000; // Später aus Order-Tabelle
|
||||
|
||||
// System-Status (Platzhalter)
|
||||
$this->systemStatus = 'green';
|
||||
|
||||
// Onboarding-Pipeline: Codes ohne registrierte User
|
||||
$this->pendingInvitations = RegistrationCode::whereNull('used_at')->count();
|
||||
|
||||
// Kunden gesamt
|
||||
$this->totalCustomers = Partner::where('type', 'Customer')->count();
|
||||
|
||||
$this->data = [
|
||||
['date' => '2025-12-11', 'visitors' => 50],
|
||||
['date' => '2025-12-12', 'visitors' => 70],
|
||||
['date' => '2025-12-13', 'visitors' => 100],
|
||||
['date' => '2025-12-14', 'visitors' => 210],
|
||||
['date' => '2025-12-15', 'visitors' => 198],
|
||||
['date' => '2025-12-16', 'visitors' => 269],
|
||||
['date' => '2025-12-17', 'visitors' => 259],
|
||||
['date' => '2025-12-18', 'visitors' => 267],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadRetailerData(User $user): void
|
||||
{
|
||||
// Platzhalter: Offene Bestellungen
|
||||
$this->openOrders = 5; // Später aus Order-Tabelle
|
||||
|
||||
// Platzhalter: Umsatz diesen Monat
|
||||
$this->monthlyRevenue = 15500; // Später aus Order-Tabelle
|
||||
|
||||
// Platzhalter: Produkt-Views
|
||||
$this->productViews = 1250; // Später aus Analytics
|
||||
|
||||
// Platzhalter: Lager-Warnungen
|
||||
$this->stockWarnings = 3; // Später aus Product-Tabelle (stock < min_stock)
|
||||
|
||||
// Meine Kunden (Kunden die diesem Händler zugeordnet sind)
|
||||
if ($user->partner_id) {
|
||||
$this->myCustomers = Partner::where('type', 'Customer')
|
||||
->where('parent_partner_id', $user->partner_id)
|
||||
->count();
|
||||
}
|
||||
}
|
||||
|
||||
private function loadManufacturerData(User $user): void
|
||||
{
|
||||
// Platzhalter: Marken-Reichweite (Händler die meine Produkte führen)
|
||||
$this->brandReach = 12; // Später aus Produkt-Zuordnungen
|
||||
|
||||
// Platzhalter: Katalog-Status
|
||||
$this->activeProducts = 45; // Später aus Product-Tabelle (is_active = true)
|
||||
$this->draftProducts = 7; // Später aus Product-Tabelle (is_active = false)
|
||||
|
||||
// Platzhalter: Gesamt-Views
|
||||
$this->totalViews = 8900; // Später aus Analytics
|
||||
}
|
||||
|
||||
private function loadBrokerData(User $user): void
|
||||
{
|
||||
// Platzhalter: Verdiente Provision
|
||||
$this->totalCommission = 4250.50; // Später aus Commission-Tabelle
|
||||
|
||||
// Platzhalter: Offene Auszahlung
|
||||
$this->pendingPayout = 850.00; // Später aus Payout-Tabelle
|
||||
|
||||
// Generierte Leads (Kunden über diesen Makler)
|
||||
if ($user->partner_id) {
|
||||
$this->generatedLeads = Partner::where('type', 'Customer')
|
||||
->where('parent_partner_id', $user->partner_id)
|
||||
->count();
|
||||
|
||||
$this->brokerCustomers = $this->generatedLeads;
|
||||
}
|
||||
|
||||
// Empfehlungs-Link (Platzhalter)
|
||||
$partner = Partner::find($user->partner_id);
|
||||
if ($partner) {
|
||||
$this->referralLink = url('/register?ref=' . $partner->partner_number);
|
||||
}
|
||||
}
|
||||
|
||||
private function loadCustomerData(User $user): void
|
||||
{
|
||||
// Kunde: Lade Brand-Daten vom Partner
|
||||
if ($user->partner_id) {
|
||||
$partner = Partner::find($user->partner_id);
|
||||
|
||||
if ($partner) {
|
||||
// Brand aus Partner-Datensatz
|
||||
$this->customerBrand = $partner->brand ?? 'b2in';
|
||||
$this->customerBrandName = ThemeHelper::getBrandName($this->customerBrand);
|
||||
$this->customerBrandLogo = ThemeHelper::getLogoPathForBrand($this->customerBrand, 'positive');
|
||||
$this->customerBrandColors = ThemeHelper::getBrandColors($this->customerBrand);
|
||||
|
||||
// Makler/Händler-Namen laden, falls vorhanden
|
||||
if ($partner->parent_partner_id) {
|
||||
$parentPartner = Partner::find($partner->parent_partner_id);
|
||||
if ($parentPartner) {
|
||||
$this->customerBrokerName = $parentPartner->display_name ?? $parentPartner->company_name ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
// Top-Angebote laden (Platzhalter - später aus Product-Tabelle)
|
||||
$this->topOffers = [
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Designer Sofa "Luna"',
|
||||
'description' => 'Modernes 3-Sitzer Sofa mit Samtbezug',
|
||||
'price' => 1899.00,
|
||||
'original_price' => 2499.00,
|
||||
'discount' => 24,
|
||||
'image' => 'https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=400&h=300&fit=crop',
|
||||
'category' => 'Wohnzimmer',
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'name' => 'Esstisch "Nordic"',
|
||||
'description' => 'Massivholz Esstisch für 6-8 Personen',
|
||||
'price' => 899.00,
|
||||
'original_price' => 1299.00,
|
||||
'discount' => 31,
|
||||
'image' => 'https://images.unsplash.com/photo-1617806118233-18e1de247200?w=400&h=300&fit=crop',
|
||||
'category' => 'Esszimmer',
|
||||
],
|
||||
[
|
||||
'id' => 3,
|
||||
'name' => 'Boxspringbett "Royal"',
|
||||
'description' => 'Premium Boxspringbett 180x200 cm',
|
||||
'price' => 1599.00,
|
||||
'original_price' => 2199.00,
|
||||
'discount' => 27,
|
||||
'image' => 'https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=400&h=300&fit=crop',
|
||||
'category' => 'Schlafzimmer',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<x-layouts.app title="Dashboard">
|
||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700 p-4">
|
||||
<livewire:notifications />
|
||||
@volt('dashboard')
|
||||
<div class="space-y-6">
|
||||
{{-- Header mit Rollenbadge --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ __('Dashboard') }} (Beispiel)</flux:heading>
|
||||
<flux:subheading>{{ __('Willkommen zurück') }}, {{ Auth::user()->name }}!</flux:subheading>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-accent-50 dark:bg-accent-900/20 rounded-lg">
|
||||
@svg('heroicon-o-'.$roleIcon, 'w-5 h-5 text-accent-600 dark:text-accent-400')
|
||||
<span class="text-sm font-medium text-accent-700 dark:text-accent-300">
|
||||
{{ $roleName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>Customer</flux:table.column>
|
||||
<flux:table.column>Date</flux:table.column>
|
||||
<flux:table.column>Status</flux:table.column>
|
||||
<flux:table.column>Amount</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
<flux:table.rows>
|
||||
<flux:table.row>
|
||||
<flux:table.cell>Lindsey Aminoff</flux:table.cell>
|
||||
<flux:table.cell>Jul 29, 10:45 AM</flux:table.cell>
|
||||
<flux:table.cell><flux:badge color="green" size="sm" inset="top bottom">Paid</flux:badge></flux:table.cell>
|
||||
<flux:table.cell variant="strong">$49.00</flux:table.cell>
|
||||
</flux:table.row>
|
||||
|
||||
<flux:table.row>
|
||||
<flux:table.cell>Hanna Lubin</flux:table.cell>
|
||||
<flux:table.cell>Jul 28, 2:15 PM</flux:table.cell>
|
||||
<flux:table.cell><flux:badge color="green" size="sm" inset="top bottom">Paid</flux:badge></flux:table.cell>
|
||||
<flux:table.cell variant="strong">$312.00</flux:table.cell>
|
||||
</flux:table.row>
|
||||
|
||||
<flux:table.row>
|
||||
<flux:table.cell>Kianna Bushevi</flux:table.cell>
|
||||
<flux:table.cell>Jul 30, 4:05 PM</flux:table.cell>
|
||||
<flux:table.cell><flux:badge color="zinc" size="sm" inset="top bottom">Refunded</flux:badge></flux:table.cell>
|
||||
<flux:table.cell variant="strong">$132.00</flux:table.cell>
|
||||
</flux:table.row>
|
||||
|
||||
<flux:table.row>
|
||||
<flux:table.cell>Gustavo Geidt</flux:table.cell>
|
||||
<flux:table.cell>Jul 27, 9:30 AM</flux:table.cell>
|
||||
<flux:table.cell><flux:badge color="green" size="sm" inset="top bottom">Paid</flux:badge></flux:table.cell>
|
||||
<flux:table.cell variant="strong">$31.00</flux:table.cell>
|
||||
</flux:table.row>
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</div>
|
||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||
{{-- Rollenspezifisches Dashboard laden --}}
|
||||
@if(in_array($userRole, ['admin', 'superadmin']))
|
||||
@include('admin.dashboards.admin')
|
||||
@elseif($userRole === 'retailer')
|
||||
@include('admin.dashboards.retailer')
|
||||
@elseif($userRole === 'manufacturer')
|
||||
@include('admin.dashboards.manufacturer')
|
||||
@elseif(in_array($userRole, ['broker', 'estateagent']))
|
||||
@include('admin.dashboards.broker')
|
||||
@elseif($userRole === 'customer')
|
||||
@include('admin.dashboards.customer')
|
||||
@else
|
||||
<flux:card>
|
||||
<div class="text-center py-8">
|
||||
<div class="text-zinc-500">{{ __('Dashboard für Ihre Rolle wird noch entwickelt.') }}</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
@endvolt
|
||||
</x-layouts.app>
|
||||
|
||||
|
||||
|
|
|
|||
148
resources/views/admin/dashboards/admin.blade.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<div class="space-y-6">
|
||||
{{-- KPI-Karten --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{{-- Aktive Hubs --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300 font-medium">{{ __('Aktive Hubs') }}</div>
|
||||
<div class="text-3xl font-bold text-blue-900 dark:text-blue-100 mt-2">{{ $activeHubs }}</div>
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400 mt-1">{{ $plannedHubs }} {{ __('geplant') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-map-pin', 'w-10 h-10 text-blue-400 dark:text-blue-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Partner-Wachstum --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-green-700 dark:text-green-300 font-medium">{{ __('Partner gesamt') }}</div>
|
||||
<div class="text-3xl font-bold text-green-900 dark:text-green-100 mt-2">{{ $totalPartners }}</div>
|
||||
<div class="text-xs text-green-600 dark:text-green-400 mt-1">+{{ $partnersThisMonth }} {{ __('diesen Monat') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-user-group', 'w-10 h-10 text-green-400 dark:text-green-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Plattform-Umsatz --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-purple-700 dark:text-purple-400 font-medium">{{ __('Plattform-Umsatz') }}</div>
|
||||
<div class="text-3xl font-bold text-purple-900 dark:text-purple-100 mt-2">{{ number_format($platformRevenue, 0, ',', '.') }} €</div>
|
||||
<div class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ __('Platzhalter') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-currency-euro', 'w-10 h-10 text-purple-400 dark:text-purple-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- System-Status --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-emerald-700 dark:text-emerald-300 font-medium">{{ __('System-Status') }}</div>
|
||||
<div class="text-3xl font-bold text-emerald-900 dark:text-emerald-100 mt-2">{{ __('Optimal') }}</div>
|
||||
<div class="flex items-center gap-1 mt-1">
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
<div class="text-xs text-emerald-600 dark:text-emerald-400">{{ __('Alle Systeme laufen') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@svg('heroicon-o-server', 'w-10 h-10 text-emerald-400 dark:text-emerald-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Widgets --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Onboarding-Pipeline --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Onboarding-Pipeline') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Offene Einladungen') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 flex items-center justify-center py-8 bg-orange-50 dark:bg-zinc-800 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="text-5xl font-bold text-sky-600 dark:text-sky-400">{{ $pendingInvitations }}</div>
|
||||
<div class="text-sm text-sky-700 dark:text-sky-300 mt-2">{{ __('Einladungen ohne Registrierung') }}</div>
|
||||
<flux:button href="{{ route('admin.partners.registration-codes') }}" variant="primary" size="sm" class="mt-4">
|
||||
{{ __('Einladungen verwalten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Partner-Übersicht --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Partner & Kunden') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Übersicht der Plattform-Nutzer') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@svg('heroicon-o-user', 'w-6 h-6 text-blue-500')
|
||||
<span class="font-medium">{{ __('Kunden') }}</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold">{{ $totalCustomers }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@svg('heroicon-o-building-storefront', 'w-6 h-6 text-green-500')
|
||||
<span class="font-medium">{{ __('Händler') }}</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold">{{ \App\Models\Partner::where('type', 'Retailer')->count() }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@svg('heroicon-o-building-office', 'w-6 h-6 text-purple-500')
|
||||
<span class="font-medium">{{ __('Hersteller') }}</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold">{{ \App\Models\Partner::where('type', 'Manufacturer')->count() }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@svg('heroicon-o-briefcase', 'w-6 h-6 text-orange-500')
|
||||
<span class="font-medium">{{ __('Makler') }}</span>
|
||||
</div>
|
||||
<span class="text-2xl font-bold">{{ \App\Models\Partner::whereIn('type', ['Broker', 'Estate-Agent'])->count() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
{{-- Besucher-Statistik Chart --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Besucher-Statistik') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Tägliche Besucherzahlen der letzten Tage') }}</flux:subheading>
|
||||
|
||||
<div class="mt-6">
|
||||
@if(!empty($data))
|
||||
<flux:chart :value="$data" class="aspect-[3/1]">
|
||||
<flux:chart.svg>
|
||||
<flux:chart.line field="visitors" class="text-pink-500 dark:text-pink-400" />
|
||||
|
||||
<flux:chart.axis axis="x" field="date">
|
||||
<flux:chart.axis.line />
|
||||
<flux:chart.axis.tick />
|
||||
</flux:chart.axis>
|
||||
|
||||
<flux:chart.axis axis="y">
|
||||
<flux:chart.axis.grid />
|
||||
<flux:chart.axis.tick />
|
||||
</flux:chart.axis>
|
||||
|
||||
<flux:chart.cursor />
|
||||
</flux:chart.svg>
|
||||
|
||||
<flux:chart.tooltip>
|
||||
<flux:chart.tooltip.heading field="date" :format="['year' => 'numeric', 'month' => 'numeric', 'day' => 'numeric']" />
|
||||
<flux:chart.tooltip.value field="visitors" label="Besucher" />
|
||||
</flux:chart.tooltip>
|
||||
</flux:chart>
|
||||
@else
|
||||
<div class="text-center py-8 text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('Keine Daten verfügbar') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
104
resources/views/admin/dashboards/broker.blade.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<div class="space-y-6">
|
||||
{{-- KPI-Karten --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{{-- Verdiente Provision --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-green-700 dark:text-green-300 font-medium">{{ __('Verdiente Provision') }}</div>
|
||||
<div class="text-3xl font-bold text-green-900 dark:text-green-100 mt-2">{{ number_format($totalCommission, 2, ',', '.') }} €</div>
|
||||
<div class="text-xs text-green-600 dark:text-green-400 mt-1">{{ __('Lifetime Earnings (Platzhalter)') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-currency-euro', 'w-10 h-10 text-green-400 dark:text-green-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Offene Auszahlung --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300 font-medium">{{ __('Offene Auszahlung') }}</div>
|
||||
<div class="text-3xl font-bold text-blue-900 dark:text-blue-100 mt-2">{{ number_format($pendingPayout, 2, ',', '.') }} €</div>
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400 mt-1">{{ __('Platzhalter') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-banknotes', 'w-10 h-10 text-blue-400 dark:text-blue-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Generierte Leads --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-purple-700 dark:text-purple-300 font-medium">{{ __('Generierte Leads') }}</div>
|
||||
<div class="text-3xl font-bold text-purple-900 dark:text-purple-100 mt-2">{{ $generatedLeads }}</div>
|
||||
<div class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ __('Registrierte Kunden') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-user-group', 'w-10 h-10 text-purple-400 dark:text-purple-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Widgets --}}
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
{{-- Empfehlungs-Link --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Mein Empfehlungs-Link') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Teilen Sie diesen Link mit Ihren Kunden') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border-2 border-dashed border-blue-300 dark:border-blue-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 bg-white dark:bg-zinc-800 px-4 py-3 rounded font-mono text-sm break-all">
|
||||
{{ $referralLink }}
|
||||
</div>
|
||||
<flux:button
|
||||
variant="primary"
|
||||
icon="clipboard"
|
||||
x-data
|
||||
@click="navigator.clipboard.writeText('{{ $referralLink }}'); $tooltip('{{ __('Link kopiert!') }}', { timeout: 2000 })"
|
||||
>
|
||||
{{ __('Kopieren') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="ghost" size="sm" icon="chat-bubble-left">{{ __('Per WhatsApp teilen') }}</flux:button>
|
||||
<flux:button variant="ghost" size="sm" icon="envelope">{{ __('Per E-Mail teilen') }}</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Meine Kunden --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Meine Kunden') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Über Sie registrierte Kunden') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 flex items-center justify-center py-8 bg-purple-50 dark:bg-purple-900/10 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="text-5xl font-bold text-purple-600 dark:text-purple-400">{{ $brokerCustomers }}</div>
|
||||
<div class="text-sm text-purple-700 dark:text-purple-300 mt-2">{{ __('Registrierte Kunden') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Letzte Aktivitäten --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Letzte Aktivitäten') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Was ist neu?') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg text-sm">
|
||||
<div class="font-medium">{{ __('Kunde Max M. hat sich registriert') }}</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">{{ __('vor 2 Stunden (Platzhalter)') }}</div>
|
||||
</div>
|
||||
<div class="p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg text-sm">
|
||||
<div class="font-medium">{{ __('Provision gutgeschrieben: 125,00 €') }}</div>
|
||||
<div class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">{{ __('gestern (Platzhalter)') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
184
resources/views/admin/dashboards/customer.blade.php
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<div class="space-y-6">
|
||||
{{-- Marken-Header mit Logo --}}
|
||||
<flux:card>
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-6 p-6 rounded-lg"
|
||||
style="background: linear-gradient(135deg, {{ $customerBrandColors['primary'] ?? '#2b3f51' }}15 0%, {{ $customerBrandColors['secondary'] ?? '#20a0da' }}15 100%);">
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<div class="mb-4">
|
||||
<img src="{{ asset($customerBrandLogo) }}" alt="{{ $customerBrandName }}" class="h-12 mx-auto md:mx-0 dark:hidden">
|
||||
<img src="{{ asset(\App\Helpers\ThemeHelper::getLogoPathForBrand($customerBrand, 'negative')) }}" alt="{{ $customerBrandName }}" class="h-12 mx-auto md:mx-0 hidden dark:block">
|
||||
</div>
|
||||
<flux:heading size="xl">{{ __('Willkommen bei') }} {{ $customerBrandName }}!</flux:heading>
|
||||
<flux:subheading class="mt-2">{{ __('Ihr persönliches Kundenportal') }}</flux:subheading>
|
||||
@if($customerBrokerName)
|
||||
<div class="mt-3 flex items-center gap-2 justify-center md:justify-start">
|
||||
@svg('heroicon-o-user-circle', 'w-5 h-5 text-zinc-500 dark:text-zinc-400')
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ __('Ihr Ansprechpartner:') }} <strong>{{ $customerBrokerName }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-32 h-32 rounded-full flex items-center justify-center"
|
||||
style="background-color: {{ $customerBrandColors['primary'] ?? '#2b3f51' }}20;">
|
||||
@svg('heroicon-o-home', 'w-16 h-16', ['style' => 'color: ' . ($customerBrandColors['primary'] ?? '#2b3f51')])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Info-Box --}}
|
||||
<flux:card>
|
||||
<div class="p-4 rounded-lg" style="background-color: {{ $customerBrandColors['secondary'] ?? '#20a0da' }}10;">
|
||||
<div class="flex items-start gap-3">
|
||||
@svg('heroicon-o-information-circle', 'w-6 h-6 flex-shrink-0', ['style' => 'color: ' . ($customerBrandColors['secondary'] ?? '#20a0da')])
|
||||
<div>
|
||||
<div class="font-medium" style="color: {{ $customerBrandColors['primary'] ?? '#2b3f51' }};">
|
||||
{{ __('Ihr Dashboard wird bald erweitert') }}
|
||||
</div>
|
||||
<div class="text-sm text-zinc-700 dark:text-zinc-300 mt-1">
|
||||
{{ __('Hier sehen Sie bald Ihre Bestellungen, Wunschliste und Empfehlungen.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Top-Angebote --}}
|
||||
@if(!empty($topOffers))
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Top-Angebote für Sie') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Exklusive Möbel zu besonderen Preisen') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button variant="ghost" size="sm" icon="arrow-right">
|
||||
{{ __('Alle Angebote') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
||||
@foreach($topOffers as $offer)
|
||||
<div class="group relative overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700 hover:shadow-lg transition-shadow duration-300">
|
||||
{{-- Produktbild --}}
|
||||
<div class="relative aspect-[4/3] overflow-hidden bg-zinc-100 dark:bg-zinc-800">
|
||||
<img src="{{ $offer['image'] }}"
|
||||
alt="{{ $offer['name'] }}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
|
||||
|
||||
{{-- Rabatt-Badge --}}
|
||||
@if($offer['discount'] > 0)
|
||||
<div class="absolute top-3 right-3 px-2 py-1 rounded-full text-xs font-bold text-white"
|
||||
style="background-color: {{ $customerBrandColors['secondary'] ?? '#20a0da' }};">
|
||||
-{{ $offer['discount'] }}%
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Kategorie-Badge --}}
|
||||
<div class="absolute top-3 left-3 px-2 py-1 rounded-full text-xs font-medium bg-white/90 dark:bg-zinc-900/90 text-zinc-700 dark:text-zinc-300">
|
||||
{{ $offer['category'] }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Produktinfo --}}
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg mb-2 text-zinc-900 dark:text-zinc-100">
|
||||
{{ $offer['name'] }}
|
||||
</h3>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-3">
|
||||
{{ $offer['description'] }}
|
||||
</p>
|
||||
|
||||
{{-- Preis --}}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl font-bold" style="color: {{ $customerBrandColors['primary'] ?? '#2b3f51' }};">
|
||||
{{ number_format($offer['price'], 2, ',', '.') }} €
|
||||
</span>
|
||||
@if($offer['original_price'] > $offer['price'])
|
||||
<span class="text-sm text-zinc-500 dark:text-zinc-400 line-through">
|
||||
{{ number_format($offer['original_price'], 2, ',', '.') }} €
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Aktions-Buttons --}}
|
||||
<div class="flex gap-2">
|
||||
<flux:button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon="eye"
|
||||
class="flex-1"
|
||||
style="background-color: {{ $customerBrandColors['primary'] ?? '#2b3f51' }}; border-color: {{ $customerBrandColors['primary'] ?? '#2b3f51' }};">
|
||||
{{ __('Details') }}
|
||||
</flux:button>
|
||||
<flux:button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="heart"
|
||||
style="color: {{ $customerBrandColors['secondary'] ?? '#20a0da' }};">
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
{{-- Feature-Vorschau --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<flux:card>
|
||||
<div class="p-4 text-center">
|
||||
<div class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center"
|
||||
style="background-color: {{ $customerBrandColors['primary'] ?? '#2b3f51' }}20;">
|
||||
@svg('heroicon-o-shopping-bag', 'w-8 h-8', ['style' => 'color: ' . ($customerBrandColors['primary'] ?? '#2b3f51')])
|
||||
</div>
|
||||
<div class="font-medium text-lg">{{ __('Meine Bestellungen') }}</div>
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{{ __('Bald verfügbar') }}</div>
|
||||
<div class="mt-3">
|
||||
<flux:badge color="zinc" size="sm">{{ __('In Entwicklung') }}</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<div class="p-4 text-center">
|
||||
<div class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center"
|
||||
style="background-color: {{ $customerBrandColors['secondary'] ?? '#20a0da' }}20;">
|
||||
@svg('heroicon-o-heart', 'w-8 h-8', ['style' => 'color: ' . ($customerBrandColors['secondary'] ?? '#20a0da')])
|
||||
</div>
|
||||
<div class="font-medium text-lg">{{ __('Meine Wunschliste') }}</div>
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{{ __('Bald verfügbar') }}</div>
|
||||
<div class="mt-3">
|
||||
<flux:badge color="zinc" size="sm">{{ __('In Entwicklung') }}</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<div class="p-4 text-center">
|
||||
<div class="w-16 h-16 rounded-full mx-auto mb-3 flex items-center justify-center"
|
||||
style="background-color: {{ $customerBrandColors['accent'] ?? ($customerBrandColors['secondary'] ?? '#20a0da') }}20;">
|
||||
@svg('heroicon-o-star', 'w-8 h-8', ['style' => 'color: ' . ($customerBrandColors['accent'] ?? ($customerBrandColors['secondary'] ?? '#20a0da'))])
|
||||
</div>
|
||||
<div class="font-medium text-lg">{{ __('Empfehlungen') }}</div>
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{{ __('Bald verfügbar') }}</div>
|
||||
<div class="mt-3">
|
||||
<flux:badge color="zinc" size="sm">{{ __('In Entwicklung') }}</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Marken-Info Footer --}}
|
||||
<flux:card>
|
||||
<div class="text-center py-6">
|
||||
<div class="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ __('Powered by') }}
|
||||
</div>
|
||||
<img src="{{ asset($customerBrandLogo) }}" alt="{{ $customerBrandName }}" class="h-8 mx-auto mt-2 opacity-60 dark:hidden">
|
||||
<img src="{{ asset(\App\Helpers\ThemeHelper::getLogoPathForBrand($customerBrand, 'negative')) }}" alt="{{ $customerBrandName }}" class="h-8 mx-auto mt-2 opacity-60 hidden dark:block">
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
95
resources/views/admin/dashboards/manufacturer.blade.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<div class="space-y-6">
|
||||
{{-- KPI-Karten --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{{-- Marken-Reichweite --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-purple-700 dark:text-purple-300 font-medium">{{ __('Marken-Reichweite') }}</div>
|
||||
<div class="text-3xl font-bold text-purple-900 dark:text-purple-100 mt-2">{{ $brandReach }}</div>
|
||||
<div class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ __('Händler führen meine Produkte') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-globe-alt', 'w-10 h-10 text-purple-400 dark:text-purple-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Katalog-Status --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300 font-medium">{{ __('Katalog-Status') }}</div>
|
||||
<div class="text-3xl font-bold text-blue-900 dark:text-blue-100 mt-2">{{ $activeProducts }}</div>
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400 mt-1">{{ $draftProducts }} {{ __('Entwürfe') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-cube', 'w-10 h-10 text-blue-400 dark:text-blue-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Gesamt-Views --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-green-700 dark:text-green-300 font-medium">{{ __('Gesamt-Views') }}</div>
|
||||
<div class="text-3xl font-bold text-green-900 dark:text-green-100 mt-2">{{ number_format($totalViews, 0, ',', '.') }}</div>
|
||||
<div class="text-xs text-green-600 dark:text-green-400 mt-1">{{ __('Platzhalter') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-eye', 'w-10 h-10 text-green-400 dark:text-green-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Widgets --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Katalog-Pflege --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Katalog-Pflege') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Verwalten Sie Ihre Produkte') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-green-50 dark:bg-green-900/10 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@svg('heroicon-o-check-circle', 'w-5 h-5 text-green-500')
|
||||
<span>{{ __('Aktive Produkte') }}</span>
|
||||
</div>
|
||||
<span class="font-bold text-lg">{{ $activeProducts }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-orange-50 dark:bg-orange-900/10 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
@svg('heroicon-o-pencil', 'w-5 h-5 text-orange-500')
|
||||
<span>{{ __('Entwürfe') }}</span>
|
||||
</div>
|
||||
<span class="font-bold text-lg">{{ $draftProducts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<flux:button variant="primary" size="sm" icon="plus">{{ __('Neues Produkt (Master)') }}</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Schnellzugriff --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Schnellzugriff') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Häufig benötigte Funktionen') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<flux:button variant="ghost" icon="photo" class="w-full justify-start">
|
||||
{{ __('Marketing-Material hochladen') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" icon="chart-bar" class="w-full justify-start">
|
||||
{{ __('Händler-Performance') }} ({{ __('Platzhalter') }})
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" icon="tag" class="w-full justify-start">
|
||||
{{ __('Meine Marke bearbeiten') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
95
resources/views/admin/dashboards/retailer.blade.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<div class="space-y-6">
|
||||
{{-- KPI-Karten --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{{-- Offene Bestellungen --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-red-700 dark:text-red-300 font-medium">{{ __('Offene Bestellungen') }}</div>
|
||||
<div class="text-3xl font-bold text-red-900 dark:text-red-100 mt-2">{{ $openOrders }}</div>
|
||||
<div class="text-xs text-red-600 dark:text-red-400 mt-1">{{ __('Platzhalter') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-shopping-cart', 'w-10 h-10 text-red-400 dark:text-red-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Umsatz --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-green-700 dark:text-green-300 font-medium">{{ __('Umsatz diesen Monat') }}</div>
|
||||
<div class="text-3xl font-bold text-green-900 dark:text-green-100 mt-2">{{ number_format($monthlyRevenue, 0, ',', '.') }} €</div>
|
||||
<div class="text-xs text-green-600 dark:text-green-400 mt-1">{{ __('Platzhalter') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-currency-euro', 'w-10 h-10 text-green-400 dark:text-green-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Produkt-Views --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300 font-medium">{{ __('Produkt-Views') }}</div>
|
||||
<div class="text-3xl font-bold text-blue-900 dark:text-blue-100 mt-2">{{ number_format($productViews, 0, ',', '.') }}</div>
|
||||
<div class="text-xs text-blue-600 dark:text-blue-400 mt-1">{{ __('Platzhalter') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-eye', 'w-10 h-10 text-blue-400 dark:text-blue-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Lager-Warnungen --}}
|
||||
<flux:card>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-orange-700 dark:text-orange-300 font-medium">{{ __('Lager-Warnungen') }}</div>
|
||||
<div class="text-3xl font-bold text-orange-900 dark:text-orange-100 mt-2">{{ $stockWarnings }}</div>
|
||||
<div class="text-xs text-orange-600 dark:text-orange-400 mt-1">{{ __('Platzhalter') }}</div>
|
||||
</div>
|
||||
@svg('heroicon-o-exclamation-triangle', 'w-10 h-10 text-orange-400 dark:text-orange-600')
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Widgets --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- To-Do Liste --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('To-Do Liste') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Ihre anstehenden Aufgaben') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-center gap-3 p-3 bg-red-50 dark:bg-red-900/10 rounded-lg">
|
||||
@svg('heroicon-o-shopping-cart', 'w-5 h-5 text-red-500')
|
||||
<span class="font-medium">{{ $openOrders }} {{ __('neue Bestellungen warten') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/10 rounded-lg">
|
||||
@svg('heroicon-o-chat-bubble-left-right', 'w-5 h-5 text-blue-500')
|
||||
<span class="font-medium">{{ __('2 Kundenfragen offen') }} ({{ __('Platzhalter') }})</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/10 rounded-lg">
|
||||
@svg('heroicon-o-cube', 'w-5 h-5 text-orange-500')
|
||||
<span class="font-medium">{{ $stockWarnings }} {{ __('Lager-Warnungen') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" size="sm" icon="plus">{{ __('Neues Produkt') }}</flux:button>
|
||||
<flux:button variant="ghost" size="sm" icon="arrow-path">{{ __('Bestände aktualisieren') }}</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Meine Kunden --}}
|
||||
<flux:card>
|
||||
<flux:heading size="lg">{{ __('Meine Kunden') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Kunden in Ihrem Einzugsgebiet') }}</flux:subheading>
|
||||
|
||||
<div class="mt-4 flex items-center justify-center py-8 bg-blue-50 dark:bg-blue-900/10 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="text-5xl font-bold text-blue-600 dark:text-blue-400">{{ $myCustomers }}</div>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300 mt-2">{{ __('zugeordnete Kunden') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
27
resources/views/components/error-alert-static.blade.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
@props(['title' => null, 'light' => false])
|
||||
|
||||
@php
|
||||
$bg = $light ? 'bg-red-50 border border-red-200' : 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800';
|
||||
$icon = $light ? 'text-red-600' : 'text-red-600 dark:text-red-400';
|
||||
$titleColor = $light ? 'text-red-800' : 'text-red-800 dark:text-red-200';
|
||||
$textColor = $light ? 'text-red-700' : 'text-red-700 dark:text-red-300';
|
||||
@endphp
|
||||
|
||||
@if ($errors->any())
|
||||
<div {{ $attributes->merge(['class' => "rounded-lg p-4 $bg"]) }}>
|
||||
<div class="flex items-start gap-3">
|
||||
<flux:icon.exclamation-circle class="h-5 w-5 {{ $icon }} mt-0.5 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold {{ $titleColor }} mb-2">
|
||||
{{ $title ?? __('Bitte korrigieren Sie folgende Fehler:') }}
|
||||
</h3>
|
||||
<ul class="text-sm {{ $textColor }} space-y-1 list-disc list-inside">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
<form method="POST" action="{{ route('auth.logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
|
|
|
|||
|
|
@ -10,48 +10,114 @@
|
|||
<x-app-logo />
|
||||
</a>
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
@if(session('impersonate_from'))
|
||||
<div class="mb-4 rounded-lg bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 p-3">
|
||||
<div class="flex items-start gap-3 mb-2">
|
||||
<flux:icon.exclamation-triangle class="h-5 w-5 text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-orange-900 dark:text-orange-100">
|
||||
{{ __('Als :name eingeloggt', ['name' => Auth::user()->name]) }}
|
||||
</div>
|
||||
<div class="text-xs text-orange-700 dark:text-orange-300 mt-0.5">
|
||||
{{ __('Sie sind temporär als dieser User angemeldet') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.impersonate.leave') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:button type="submit" variant="primary" size="sm" icon="arrow-left-start-on-rectangle" class="w-full">
|
||||
{{ __('Zurück zum Admin') }}
|
||||
</flux:button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
@hasrole('Customer')
|
||||
<flux:navlist.group :heading="__('Customer')" class="grid mb-4">
|
||||
<flux:navlist.item icon="user" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Retailer')
|
||||
<flux:navlist.group :heading="__('Retailer')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Produkte')" class="grid mb-4">
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')" :current="request()->routeIs('products.index')" wire:navigate>{{ __('Produktliste') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create')" :current="request()->routeIs('products.create')" wire:navigate>{{ __('Neues Produkt') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Manufacturer')
|
||||
<flux:navlist.group :heading="__('Manufacturer')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Estate-Agent')" class="grid mb-4">
|
||||
<flux:navlist.group :heading="__('Produkte')" class="grid mb-4">
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')" :current="request()->routeIs('products.index')" wire:navigate>{{ __('Produktliste') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="plus-circle" :href="route('products.create')" :current="request()->routeIs('products.create')" wire:navigate>{{ __('Neues Produkt') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Broker')
|
||||
<flux:navlist.group :heading="__('Broker')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group :heading="__('Daten')" class="grid mb-4">
|
||||
<flux:navlist.item icon="building-office" :href="route('partner.my-data')" :current="request()->routeIs('partner.my-data')" wire:navigate>{{ __('Meine Daten') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Admin|Super-Admin')
|
||||
<flux:navlist.group :heading="__('Info')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
|
||||
|
||||
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.group :heading="__('Admin')" class="grid mb-4">
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.users')" :current="request()->routeIs('admin.users')" wire:navigate>{{ __('Users') }}</flux:navlist.item>
|
||||
|
||||
<flux:navlist.group expandable expanded="false" heading="Users" class="hidden lg:grid">
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.users')" :current="request()->routeIs('admin.users')" wire:navigate>{{ __('Users') }}</flux:navlist.item>
|
||||
<flux:navlist.group expandable :expanded="request()->routeIs(['admin.users', 'admin.users.permissions', 'admin.partners.invite', 'admin.partners.registration-codes'])" heading="Partner" class="grid">
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.users')" :current="request()->routeIs('admin.users')" wire:navigate>{{ __('Benutzer') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.partners.invite')" :current="request()->routeIs('admin.partners.invite')" wire:navigate>{{ __('Partner einladen') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="key" :href="route('admin.partners.registration-codes')" :current="request()->routeIs('admin.partners.registration-codes')" wire:navigate>{{ __('Registrierungscodes') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.users.permissions')" :current="request()->routeIs('admin.users.permissions')" wire:navigate>{{ __('Permissions') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group expandable :expanded="request()->routeIs('testing')" heading="Testing" class="hidden lg:grid mt-2">
|
||||
<flux:navlist.item icon="user-group" :href="route('testing.landing')" :current="request()->routeIs('testing.landing')" wire:navigate>{{ __('Register') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.partners.invite')" :current="request()->routeIs('admin.partners.invite')" wire:navigate>{{ __('Partner einladen') }}</flux:navlist.item>
|
||||
<flux:navlist.group :heading="__('Regionen')" class="grid mb-4">
|
||||
<flux:navlist.item icon="map" :href="route('admin.hubs.index')" :current="request()->routeIs('admin.hubs.*')" wire:navigate>{{ __('Hub-Verwaltung') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('CMS')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="home" :href="route('admin.cms.cabinet')" :current="request()->routeIs('admin.cms.cabinet')" wire:navigate>{{ __('Cabinet') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
|
||||
@endhasrole
|
||||
|
||||
@hasrole('Super-Admin')
|
||||
<flux:navlist.group :heading="__('Superadmin')" class="grid mb-4">
|
||||
<flux:navlist.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
</flux:navlist>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.group :heading="__('Dokumentation')" class="grid mb-4">
|
||||
<flux:navlist.item icon="document-text" :href="route('admin.documentation')" :current="request()->routeIs('admin.documentation')" wire:navigate>{{ __('Projekt-Dokumentation') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
@hasrole('Super-Admin')
|
||||
<flux:navlist variant="outline">
|
||||
<flux:navlist.group :heading="__('Resources')">
|
||||
<flux:navlist.item icon="pencil" href="https://tailwindcss.com/docs/installation/using-vite" target="_blank">
|
||||
|
|
@ -72,6 +138,8 @@
|
|||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist>
|
||||
@endhasrole
|
||||
|
||||
<!-- Desktop User Menu -->
|
||||
<flux:dropdown position="bottom" align="start">
|
||||
<flux:profile
|
||||
|
|
@ -105,10 +173,35 @@
|
|||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<div x-data="{
|
||||
toggle() {
|
||||
$flux.appearance = $flux.appearance === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
}">
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance !== 'dark'"
|
||||
@click="toggle()"
|
||||
icon="moon"
|
||||
>
|
||||
{{ __('Dunkel') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance === 'dark'"
|
||||
x-cloak
|
||||
@click="toggle()"
|
||||
icon="sun"
|
||||
>
|
||||
{{ __('Hell') }}
|
||||
</flux:menu.item>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
<form method="POST" action="{{ route('auth.logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
|
|
@ -158,10 +251,35 @@
|
|||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('settings.profile')" icon="cog" wire:navigate>{{ __('Settings') }}</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<div x-data="{
|
||||
toggle() {
|
||||
$flux.appearance = $flux.appearance === 'dark' ? 'light' : 'dark';
|
||||
}
|
||||
}">
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance !== 'dark'"
|
||||
@click="toggle()"
|
||||
icon="moon"
|
||||
>
|
||||
{{ __('Dunkel') }}
|
||||
</flux:menu.item>
|
||||
<flux:menu.item
|
||||
x-show="$flux.appearance === 'dark'"
|
||||
x-cloak
|
||||
@click="toggle()"
|
||||
icon="sun"
|
||||
>
|
||||
{{ __('Hell') }}
|
||||
</flux:menu.item>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
<form method="POST" action="{{ route('auth.logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
|
|
|
|||
15
resources/views/components/success-alert.blade.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@props(['message' => null])
|
||||
|
||||
@if ($message || session('message'))
|
||||
<div {{ $attributes->merge(['class' => 'rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 p-4']) }}>
|
||||
<div class="flex items-start gap-3">
|
||||
@svg('heroicon-o-check-circle', 'h-5 w-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0')
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
{{ $message ?? session('message') ?? $slot }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||