23-01-2026

This commit is contained in:
Kevin Adametz 2026-01-23 17:33:10 +01:00
parent 07959c0ba2
commit 854ce02bf6
166 changed files with 32909 additions and 1262 deletions

View file

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

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

View file

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

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

View file

@ -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/')) {

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

View file

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

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

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

View file

@ -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
// {

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

View file

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

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

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

View file

@ -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'])) {
Vite::useBuildDirectory($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]);
}
}
}
}

View file

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

View file

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

View file

@ -74,7 +74,9 @@
},
"extra": {
"laravel": {
"dont-discover": []
"dont-discover": [
"orchestra/workbench"
]
}
},
"config": {

1181
composer.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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
View 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
View 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`

View 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 (14 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 (1500, 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.

View 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
View 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.*

View file

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

View 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

View 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

View 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

View 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!**

View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

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

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

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

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

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

View file

@ -0,0 +1 @@
[2026-01-19 08:45:35] [INFO] Logging-System wurde eingerichtet

118
public/_cabinet/setup-logging.sh Executable file
View 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."

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

View 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

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -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 */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,56 +47,56 @@
rootMargin: '0px 0px -80px 0px'
};
// Erstelle Observer
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Füge is-visible Klasse mit kleinem Delay hinzu für sanfteren Effekt
setTimeout(() => {
entry.target.classList.add('is-visible');
}, 50);
// 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
setTimeout(() => {
entry.target.classList.add('is-visible');
}, 50);
// Observer beenden nach Animation für bessere Performance
observer.unobserve(entry.target);
}
});
}, observerOptions);
// Observer beenden nach Animation für bessere Performance
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
document.addEventListener('click', function(e) {
const target = e.target.closest('a[href^="#"]');
if (target && target.hash) {
const targetElement = document.querySelector(target.hash);
// 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) {
const targetElement = document.querySelector(target.hash);
if (targetElement) {
e.preventDefault();
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
if (targetElement) {
e.preventDefault();
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}
}
});
// Scroll Progress Indicator
initScrollProgress();
// Premium Sticky Header
initStickyHeader();
});
window.smoothScrollInitialized = true;
}
}
function initStickyHeader() {

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

View file

@ -0,0 +1,10 @@
<?php
return [
'broker' => 'Makler',
'retailer' => 'Händler',
'manufacturer' => 'Hersteller',
'customer' => 'Kunde',
'Sprache' => 'Sprache',
'role' => 'Rolle',
];

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

View file

@ -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="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 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 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" />
</div>
{{-- 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>

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

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

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

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

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

View 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

View file

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

View file

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

View 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

Some files were not shown because too many files have changed in this diff Show more