## 🏗️ **Multi-Tenant System-Architektur** Note: Event Type-aware UI - Theme colors can come from `event_types.settings.palette` to skin headers/buttons per type. - Emotion picker fetches type-filtered emotions via `/api/events/:slug/emotions`. - Random task generator and gallery filters respect event type automatically via API. ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SYSTEM OVERVIEW (Multi-Tenant) │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ 🔧 SUPER ADMIN 👰🤵 BRAUTPAARE 📱 GÄSTE PWA │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ System Admin │ │ Customer Admin │ │ React PWA │ │ │ │ Panel │◄──►│ Panel │◄──►│ Event Photos │ │ │ │ │ │ │ │ │ │ │ │ • User Mgmt │ │ • Event Setup │ │ • Take Photos │ │ │ │ • System Config │ │ • Photo Review │ │ • Upload Share │ │ │ │ • Billing │ │ • Gallery Build │ │ • Live Feed │ │ │ │ • Analytics │ │ • Export │ │ • Like/Comment │ │ │ │ • Monitoring │ │ │ │ │ │ │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ │ │ │ │ └───────────────────────┼───────────────────────┘ │ │ │ │ │ ┌─────────────────────────────────────────────────────────────────────┐│ │ │ LARAVEL BACKEND API (Multi-Tenant) ││ │ │ • Tenant Isolation • User Management • Billing System ││ │ │ • System Monitoring • Global Analytics • Platform Administration ││ │ └─────────────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────────────┘ ``` ## 👑 **Super Admin System (Platform Management)** ### **Multi-Tenant Database Schema:** ```sql -- Tenant/Customer Management CREATE TABLE tenants ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, -- "Familie Müller" slug VARCHAR(255) UNIQUE NOT NULL, -- "familie-mueller" domain VARCHAR(255) UNIQUE, -- Optional: custom domain -- Contact Information contact_name VARCHAR(255) NOT NULL, contact_email VARCHAR(255) NOT NULL, contact_phone VARCHAR(255), -- Event-basierte Monetarisierung (keine Subscriptions) event_credits_balance INTEGER DEFAULT 1, free_event_granted_at TIMESTAMP, -- Limits & Quotas max_photos_per_event INTEGER DEFAULT 500, max_storage_mb INTEGER DEFAULT 1024, -- 1GB -- Feature Flags features JSON, -- {"custom_branding": true, "api_access": false} -- Metadata last_activity_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Erweiterte User-Tabelle für Multi-Tenancy ALTER TABLE users ADD COLUMN tenant_id INTEGER; ALTER TABLE users ADD COLUMN role ENUM('super_admin', 'tenant_admin', 'tenant_user') DEFAULT 'tenant_user'; ALTER TABLE users ADD CONSTRAINT fk_users_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id); -- Events gehören zu Tenants ALTER TABLE events ADD COLUMN tenant_id INTEGER NOT NULL; ALTER TABLE events ADD CONSTRAINT fk_events_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id); -- System-weite Konfiguration CREATE TABLE system_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, key VARCHAR(255) UNIQUE NOT NULL, value TEXT, description TEXT, is_public BOOLEAN DEFAULT 0, -- Für öffentliche API settings created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Platform Analytics CREATE TABLE platform_analytics ( id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id INTEGER, metric_name VARCHAR(100) NOT NULL, -- 'photos_uploaded', 'events_created' metric_value INTEGER NOT NULL, metric_date DATE NOT NULL, metadata JSON, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (tenant_id) REFERENCES tenants(id), INDEX idx_analytics_date_metric (metric_date, metric_name), INDEX idx_analytics_tenant (tenant_id, metric_date) ); -- Event Purchases (one-time) und Guthaben-Ledger CREATE TABLE event_purchases ( id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id INTEGER NOT NULL, events_purchased INTEGER NOT NULL DEFAULT 1, amount DECIMAL(10,2) NOT NULL, currency VARCHAR(3) DEFAULT 'EUR', provider ENUM('app_store', 'play_store', 'stripe', 'paypal') NOT NULL, external_receipt_id VARCHAR(255), status ENUM('pending', 'paid', 'failed', 'refunded', 'cancelled') DEFAULT 'pending', purchased_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (tenant_id) REFERENCES tenants(id), INDEX idx_event_purchases_tenant (tenant_id, purchased_at) ); CREATE TABLE event_credits_ledger ( id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id INTEGER NOT NULL, delta INTEGER NOT NULL, reason ENUM('initial_free','purchase','manual_adjustment','event_created','refund') NOT NULL, related_purchase_id INTEGER, note TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (tenant_id) REFERENCES tenants(id), FOREIGN KEY (related_purchase_id) REFERENCES event_purchases(id), INDEX idx_ledger_tenant (tenant_id, created_at) ); ``` ## 🛠️ **Super Admin Panel (Separate Filament Panel)** Note (Filament 4): - Forms/Infolists components live under `Filament\Schema\Components` (updated below). - Table row actions use `recordActions()`; actions are unified under `Filament\Actions`. - Keep `Tables\Columns`/`Tables\Filters` as-is; columns/filters remain in Tables. ### **Super Admin Panel Provider:** ```php id('super-admin') ->path('super-admin') ->login() ->colors([ 'primary' => Color::Indigo, 'secondary' => Color::Slate, ]) ->brandName('Fotobox Platform Admin') ->brandLogo(asset('images/platform-logo.png')) ->discoverResources(in: app_path('Filament/SuperAdmin/Resources'), for: 'App\\Filament\\SuperAdmin\\Resources') ->discoverPages(in: app_path('Filament/SuperAdmin/Pages'), for: 'App\\Filament\\SuperAdmin\\Pages') ->pages([ Pages\Dashboard::class, ]) ->widgets([ \App\Filament\SuperAdmin\Widgets\PlatformStatsWidget::class, \App\Filament\SuperAdmin\Widgets\RevenueChartWidget::class, \App\Filament\SuperAdmin\Widgets\TenantActivityWidget::class, ]) ->navigationGroups([ 'Customer Management', 'Platform Configuration', 'Legal & Compliance', 'Billing & Events', 'System Monitoring', 'Content Management' ]) ->authMiddleware([ \App\Http\Middleware\SuperAdminOnly::class, ]); } } ``` ## 👥 **Tenant Management Resource** ```php schema([ Schema\\Components\\Section::make('Kunden-Details') ->schema([ Schema\\Components\\TextInput::make('name') ->label('Kundenname') ->required() ->maxLength(255), Schema\\Components\\TextInput::make('slug') ->required() ->unique(Tenant::class, 'slug', ignoreRecord: true) ->helperText('Eindeutige URL für den Kunden'), Schema\\Components\\TextInput::make('domain') ->label('Custom Domain (optional)') ->url() ->unique(Tenant::class, 'domain', ignoreRecord: true), Schema\\Components\\TextInput::make('contact_name') ->label('Ansprechpartner') ->required(), Schema\\Components\\TextInput::make('contact_email') ->label('E-Mail') ->email() ->required(), Schema\\Components\\TextInput::make('contact_phone') ->label('Telefon') ->tel(), ]) ->columns(2), Schema\\Components\\Section::make('Subscription Management') ->schema([ Schema\\Components\\Select::make('plan_type') ->label('Plan') ->options([ 'free' => 'Free (Testversion)', 'basic' => 'Basic (29€/Monat)', 'premium' => 'Premium (49€/Monat)', 'enterprise' => 'Enterprise (Custom)' ]) ->required() ->live(), Schema\\Components\\Select::make('subscription_status') ->options([ 'trial' => 'Trial', 'active' => 'Aktiv', 'suspended' => 'Suspendiert', 'cancelled' => 'Gekündigt' ]) ->required(), Schema\\Components\\DateTimePicker::make('subscription_starts_at') ->label('Abo Start'), Schema\\Components\\DateTimePicker::make('subscription_ends_at') ->label('Abo Ende'), Schema\\Components\\DateTimePicker::make('trial_ends_at') ->label('Trial Ende') ->visible(fn ($get) => $get('subscription_status') === 'trial'), ]) ->columns(2), Schema\\Components\\Section::make('Limits & Kontingente') ->schema([ Schema\\Components\\TextInput::make('max_events') ->label('Max. Events') ->numeric() ->default(1), Schema\\Components\\TextInput::make('max_photos_per_event') ->label('Max. Fotos pro Event') ->numeric() ->default(500), Schema\\Components\\TextInput::make('max_storage_mb') ->label('Max. Speicher (MB)') ->numeric() ->default(1024), ]) ->columns(3), Schema\\Components\\Section::make('Feature Flags') ->schema([ Schema\\Components\\KeyValue::make('features') ->label('Aktivierte Features') ->keyLabel('Feature') ->valueLabel('Aktiviert') ->default([ 'custom_branding' => false, 'api_access' => false, 'advanced_analytics' => false, 'white_label' => false ]), ]), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('contact_email') ->label('Kontakt') ->searchable(), Tables\Columns\TextColumn::make('event_credits_balance') ->label('Event-Guthaben') ->badge() Tables\Columns\TextColumn::make('events_count') ->label('Events') ->counts('events'), Tables\Columns\TextColumn::make('total_photos_count') ->label('Gesamt Fotos') ->getStateUsing(fn ($record) => $record->events()->withCount('photos')->get()->sum('photos_count') ), Tables\Columns\TextColumn::make('free_event_granted_at') ->label('Kostenloses Event am') ->date('d.m.Y') ->sortable(), Tables\Columns\TextColumn::make('last_activity_at') ->label('Letzte Aktivität') ->since() ->sortable(), ]) ->filters([ Tables\Filters\SelectFilter::make('plan_type') ->options([ 'free' => 'Free', 'basic' => 'Basic', 'premium' => 'Premium', 'enterprise' => 'Enterprise' ]), Tables\Filters\SelectFilter::make('subscription_status') ->options([ 'trial' => 'Trial', 'active' => 'Aktiv', 'suspended' => 'Suspendiert', 'cancelled' => 'Gekündigt' ]), Tables\Filters\Filter::make('expiring_soon') ->label('Läuft bald ab') ->query(fn ($query) => $query->where('subscription_ends_at', '<=', now()->addDays(30))), ]) ->recordActions([ Actions\\ViewAction::make(), Actions\\EditAction::make(), Actions\\Action::make('login_as') ->label('Als Kunde anmelden') ->icon('heroicon-o-arrow-right-on-rectangle') ->url(fn ($record) => "/admin/impersonate/{$record->id}") ->openUrlInNewTab(), ]) ->bulkActions([ Actions\\BulkAction::make('send_notification') ->label('Benachrichtigung senden') ->icon('heroicon-o-envelope') ->form([ Schema\\Components\\TextInput::make('subject') ->label('Betreff') ->required(), Schema\\Components\\Textarea::make('message') ->label('Nachricht') ->required() ->rows(4), ]) ->action(function (array $data, Collection $records) { // Send bulk notification to selected customers foreach ($records as $tenant) { // Send email notification } }), ]) ->defaultSort('created_at', 'desc'); } public static function getPages(): array { return [ 'index' => Pages\ListTenants::route('/'), 'create' => Pages\CreateTenant::route('/create'), 'edit' => Pages\EditTenant::route('/{record}/edit'), 'view' => Pages\ViewTenant::route('/{record}'), ]; } } ``` ## 💰 **Subscription Plans Resource** ```php schema([ Schema\\Components\\Section::make('Plan Details') ->schema([ Schema\\Components\\TextInput::make('name') ->required() ->maxLength(100), Schema\\Components\\TextInput::make('slug') ->required() ->unique(SubscriptionPlan::class, 'slug', ignoreRecord: true), Schema\\Components\\TextInput::make('price_monthly') ->label('Monatspreis (€)') ->numeric() ->prefix('€'), Schema\\Components\\TextInput::make('price_yearly') ->label('Jahrespreis (€)') ->numeric() ->prefix('€'), Schema\\Components\\Toggle::make('is_active') ->label('Plan aktiv') ->default(true), ]) ->columns(2), Schema\\Components\\Section::make('Plan Limits') ->schema([ Schema\\Components\\TextInput::make('max_events') ->label('Max. Events') ->numeric() ->required(), Schema\\Components\\TextInput::make('max_photos_per_event') ->label('Max. Fotos pro Event') ->numeric() ->required(), Schema\\Components\\TextInput::make('max_storage_gb') ->label('Max. Speicher (GB)') ->numeric() ->required(), ]) ->columns(3), Schema\\Components\\Section::make('Features') ->schema([ Schema\\Components\\KeyValue::make('features') ->label('Plan Features') ->keyLabel('Feature') ->valueLabel('Enthalten') ->default([ 'custom_branding' => false, 'api_access' => false, 'advanced_analytics' => false, 'priority_support' => false, 'white_label' => false, 'custom_domain' => false ]), ]), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('price_monthly') ->label('Monatlich') ->money('EUR'), Tables\Columns\TextColumn::make('price_yearly') ->label('Jährlich') ->money('EUR'), Tables\Columns\TextColumn::make('max_events') ->label('Events'), Tables\Columns\TextColumn::make('max_photos_per_event') ->label('Fotos/Event'), Tables\Columns\TextColumn::make('max_storage_gb') ->label('Storage (GB)'), Tables\Columns\TextColumn::make('subscribers_count') ->label('Kunden') ->getStateUsing(fn ($record) => $record->tenants()->count()), Tables\Columns\IconColumn::make('is_active') ->boolean(), ]) ->recordActions([ Actions\\EditAction::make(), Actions\\Action::make('duplicate') ->label('Duplizieren') ->icon('heroicon-o-document-duplicate') ->action(function ($record) { $newPlan = $record->replicate(); $newPlan->name = $record->name . ' (Kopie)'; $newPlan->slug = $record->slug . '-copy'; $newPlan->save(); }), ]); } } ``` ## 📊 **System Settings Resource** ```php schema([ Schema\\Components\\Section::make('Setting Details') ->schema([ Schema\\Components\\TextInput::make('key') ->required() ->unique(SystemSetting::class, 'key', ignoreRecord: true) ->helperText('Eindeutiger Key für die Einstellung'), Schema\\Components\\Textarea::make('value') ->required() ->rows(3) ->helperText('JSON, String oder andere Werte'), Schema\\Components\\Textarea::make('description') ->rows(2) ->helperText('Beschreibung der Einstellung'), Schema\\Components\\Toggle::make('is_public') ->label('Öffentlich verfügbar') ->helperText('Über öffentliche API abrufbar'), ]), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('key') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('value') ->limit(50) ->searchable(), Tables\Columns\TextColumn::make('description') ->limit(100), Tables\Columns\IconColumn::make('is_public') ->boolean(), Tables\Columns\TextColumn::make('updated_at') ->label('Zuletzt geändert') ->dateTime('d.m.Y H:i') ->sortable(), ]) ->filters([ Tables\Filters\TernaryFilter::make('is_public') ->label('Öffentliche Einstellungen'), ]) ->actions([ Actions\\EditAction::make(), ]) ->groups([ 'category' => Tables\Grouping\Group::make('key') ->label('Kategorie') ->getDescriptionFromRecordUsing(fn ($record) => explode('.', $record->key)[0]) ]); } // Predefined System Settings public static function getDefaultSettings(): array { return [ 'platform.max_file_size' => '10485760', // 10MB 'platform.allowed_image_types' => 'jpeg,jpg,png,webp', // Event-basierte Preisgestaltung 'pricing.event_price_eur' => '9.99', 'pricing.free_event_on_app_purchase' => 'true', 'email.from_address' => 'noreply@fotobox-platform.de', 'email.support_address' => 'support@fotobox-platform.de', 'analytics.google_analytics_id' => '', 'features.maintenance_mode' => 'false', 'features.new_registrations' => 'true', 'storage.default_disk' => 'local', 'storage.cdn_url' => '', ]; } } ``` ## 📊 **Super Admin Widgets** ### **Platform Statistics Widget:** ```php count()) ->description('Mindestens ein Event vorhanden') ->descriptionIcon('heroicon-m-building-office') ->color('success'), Stat::make('Gesamt Events', Event::count()) ->description('Alle Events auf der Platform') ->descriptionIcon('heroicon-m-calendar-days') ->color('primary'), Stat::make('Gesamt Fotos', Photo::count()) ->description('Hochgeladene Fotos') ->descriptionIcon('heroicon-m-photo') ->color('warning'), Stat::make('Monthly Recurring Revenue', Tenant::where('subscription_status', 'active') ->whereIn('plan_type', ['basic', 'premium']) ->get() ->sum(fn($tenant) => match($tenant->plan_type) { 'basic' => 29, 'premium' => 49, default => 0 }) ) ->description('€ MRR') ->descriptionIcon('heroicon-m-currency-euro') ->color('success'), ]; } } ``` ### **Revenue Chart Widget:** ```php where('created_at', '>=', now()->subYear()) ->selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, SUM(amount) as revenue') ->groupBy('month') ->orderBy('month') ->get(); return [ 'datasets' => [ [ 'label' => 'Umsatz (€)', 'data' => $data->pluck('revenue'), 'borderColor' => 'rgb(99, 102, 241)', 'backgroundColor' => 'rgba(99, 102, 241, 0.1)', ], ], 'labels' => $data->pluck('month'), ]; } protected function getType(): string { return 'line'; } } ``` ## 🔐 **Super Admin Middleware** ```php check() || auth()->user()->role !== 'super_admin') { abort(403, 'Access denied. Super Admin privileges required.'); } return $next($request); } } ``` ## 🏢 **Tenant Scoping Middleware** ```php user()->role === 'super_admin') { return $next($request); // Super Admin bypasses tenant scoping } $tenant = auth()->user()->tenant; if (!$tenant) { abort(403, 'No tenant associated with user.'); } // Set global tenant scope config(['app.current_tenant' => $tenant]); return $next($request); } } ``` ## 🎯 **Super Admin Navigation Struktur** ``` 🏠 Dashboard ├── 📊 Platform Overview ├── 💰 Revenue Summary └── 🚨 System Alerts 👥 Customer Management ├── 🏢 Tenants/Kunden ├── 👤 User Management ├── 📞 Support Tickets └── 📧 Bulk Communications 💳 Billing & Subscriptions ├── 💰 Subscription Plans ├── 🧾 Invoices & Payments ├── 📊 Revenue Analytics └── 🎁 Promotions & Discounts ⚙️ Platform Configuration ├── 🔧 System Settings ├── 🎨 Global Themes ├── # Hochzeits-Fotobox App - Project Requirements & Prompt (PRP) ## 📋 Projekt-Übersicht **Projektname:** Emotionen-Fotobox für Deutsche Hochzeiten **Ziel:** Eine Progressive Web App (PWA), die Hochzeitsgäste spielerisch dazu motiviert, emotionale Fotos zu erstellen und zu teilen **Tech-Stack:** Laravel 12.x + Filament 4.x (Admin) + Vite + React + SQLite + Tailwind CSS > Versions as of 2025-09-01: Laravel 12 (latest 12.21.x), Filament 4 (stable). If you must stay on Filament v3 for compatibility, pin to 3.3.x. **Zielgruppe:** Deutsche Hochzeitsgäste aller Altersgruppen ## 🎯 Kern-Funktionalitäten ### 1. Emotions-basiertes Foto-System - **6 Haupt-Emotionen:** Liebe 💕, Freude 😂, Rührung 🥺, Nostalgie 📸, Überraschung 😲, Stolz 🏆 - **Aufgaben-Generator:** Pro Emotion 8-12 verschiedene Foto-Aufgaben - **Schwierigkeitsgrade:** Easy (Einzelperson), Medium (Gruppe), Hard (Kreativ/Nachstellung) ### 2. Social Media-inspiriertes UI - **Instagram Stories-Layout:** Horizontale Emotion-Bubbles - **TikTok-Style Kamera:** Vollbild mit Overlay-Aufgaben - **Feed-Design:** Kachel-Grid mit Like-Funktion - **Swipe-Navigation:** Intuitive Bedienung ### 3. Real-time Features - **Live-Photo-Feed:** Neue Fotos erscheinen sofort bei allen Gästen - **Kollaborative Galerie:** Gemeinsame Fotosortierung nach Emotionen - **Like-System:** Herzchen für beliebte Fotos ## 🏗️ Technische Architektur ### Backend: Laravel 12.x (PHP 8.4) #### Database Schema (SQLite) ```sql -- Event Types (wedding, christmas, birthday, corporate, ...) CREATE TABLE event_types ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- JSON (translatable: {"de":"Hochzeit","en":"Wedding"}) slug VARCHAR(100) UNIQUE NOT NULL, icon VARCHAR(64), settings JSON, -- e.g., {"palette": {...}} created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Events (Veranstaltungen) CREATE TABLE events ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- JSON (translatable) date DATE NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL, description TEXT, -- JSON (translatable) settings JSON, -- Konfiguration für Emotionen, Farben etc. event_type_id INTEGER NOT NULL, is_active BOOLEAN DEFAULT 1, default_locale VARCHAR(5) DEFAULT 'de', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (event_type_id) REFERENCES event_types(id) ); -- Emotionen-Kategorien (reusable; linked to event types via pivot) CREATE TABLE emotions ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- JSON (translatable) icon VARCHAR(50) NOT NULL, -- Emoji oder Icon-Klasse color VARCHAR(7) NOT NULL, -- Hex-Farbe description TEXT, -- JSON (translatable) sort_order INTEGER DEFAULT 0, is_active BOOLEAN DEFAULT 1 ); -- Emotion↔EventType Zuordnung (many-to-many) CREATE TABLE emotion_event_type ( emotion_id INTEGER NOT NULL, event_type_id INTEGER NOT NULL, PRIMARY KEY (emotion_id, event_type_id), FOREIGN KEY (emotion_id) REFERENCES emotions(id), FOREIGN KEY (event_type_id) REFERENCES event_types(id) ); CREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, emotion_id INTEGER NOT NULL, event_type_id INTEGER, -- optional: directly target a type title TEXT NOT NULL, -- JSON (translatable) description TEXT NOT NULL, -- JSON (translatable) difficulty ENUM('easy', 'medium', 'hard') DEFAULT 'easy', example_text TEXT, -- JSON (translatable) sort_order INTEGER DEFAULT 0, is_active BOOLEAN DEFAULT 1, -- multi-tenant scoping additions (see Tenant-Defined Tasks section) tenant_id INTEGER, scope TEXT DEFAULT 'global', -- 'global' | 'tenant' | 'event' event_id INTEGER, FOREIGN KEY (emotion_id) REFERENCES emotions(id) -- optional FKs for scoping ,FOREIGN KEY (event_type_id) REFERENCES event_types(id) ,FOREIGN KEY (tenant_id) REFERENCES tenants(id) ,FOREIGN KEY (event_id) REFERENCES events(id) ); -- Hochgeladene Fotos CREATE TABLE photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, emotion_id INTEGER NOT NULL, task_id INTEGER, guest_name VARCHAR(255) NOT NULL, file_path VARCHAR(255) NOT NULL, thumbnail_path VARCHAR(255) NOT NULL, likes_count INTEGER DEFAULT 0, is_featured BOOLEAN DEFAULT 0, -- Für Highlights metadata JSON, -- EXIF-Daten, Gerätinfo etc. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (event_id) REFERENCES events(id), FOREIGN KEY (emotion_id) REFERENCES emotions(id), FOREIGN KEY (task_id) REFERENCES tasks(id) ); -- Photo-Likes (für Gast-Interaktion) CREATE TABLE photo_likes ( id INTEGER PRIMARY KEY AUTOINCREMENT, photo_id INTEGER NOT NULL, guest_name VARCHAR(255) NOT NULL, ip_address VARCHAR(45), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (photo_id) REFERENCES photos(id), UNIQUE(photo_id, guest_name, ip_address) ); ``` #### Laravel Models **Event Model:** ```php Event model additions (event type): ```php // In App\Models\Event protected $fillable = ["name","date","slug","description","settings","event_type_id","is_active"]; public function type(){ return $this->belongsTo(\App\Models\EventType::class, "event_type_id"); } ``` 'date', 'settings' => 'array', 'is_active' => 'boolean' ]; public function photos() { return $this->hasMany(Photo::class)->latest(); } public function getRouteKeyName() { return 'slug'; } } ``` **Photo Model:** ```php 'array' ]; public function event() { return $this->belongsTo(Event::class); } public function emotion() { return $this->belongsTo(Emotion::class); } public function task() { return $this->belongsTo(Task::class); } public function likes() { return $this->hasMany(PhotoLike::class); } } ``` #### API Routes (routes/api.php) ```php middleware(['throttle:60,1'])->group(function () { // Event-Details Route::get('/', [EventController::class, 'show']); // Emotionen laden (per Event-Typ gefiltert + universal) Route::get('/emotions', [EmotionController::class, 'index']); // Aufgaben pro Emotion Route::get('/tasks/{emotion}', [TaskController::class, 'getByEmotion']); Route::get('/tasks/random/{emotion}', [TaskController::class, 'getRandomTask']); // Foto-Management Route::get('/photos', [PhotoController::class, 'index']); Route::post('/photos', [PhotoController::class, 'store']); Route::get('/photos/{photo}', [PhotoController::class, 'show']); Route::post('/photos/{photo}/like', [PhotoController::class, 'toggleLike']); // Live-Feed für Real-time Updates Route::get('/feed/latest', [LiveFeedController::class, 'getLatest']); Route::get('/feed/stream', [LiveFeedController::class, 'stream']); // SSE }); // Admin Routes (für Hochzeits-Setup) Route::prefix('admin')->middleware(['auth:sanctum'])->group(function () { Route::apiResource('events', EventController::class); Route::apiResource('emotions', EmotionController::class); Route::apiResource('tasks', TaskController::class); }); ``` #### Controller-Beispiele **PhotoController:** ```php validate([ 'photo' => 'required|image|mimes:jpeg,png,jpg|max:10240', // 10MB 'emotion_id' => 'required|exists:emotions,id', 'task_id' => 'nullable|exists:tasks,id', 'guest_name' => 'required|string|max:100|min:2' ]); $photo = $request->file('photo'); $filename = uniqid('photo_') . '.' . $photo->getClientOriginalExtension(); // Original-Bild speichern $originalPath = $photo->storeAs('photos', $filename, 'public'); // Thumbnail erstellen (300x300 quadratisch) $thumbnailPath = 'thumbnails/' . $filename; $thumbnail = Image::make($photo) ->fit(300, 300, function ($constraint) { $constraint->upsize(); }) ->encode('jpg', 85); Storage::disk('public')->put($thumbnailPath, $thumbnail); // Metadata sammeln $metadata = [ 'original_name' => $photo->getClientOriginalName(), 'size' => $photo->getSize(), 'mime_type' => $photo->getMimeType(), 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent() ]; // In Datenbank speichern $photoRecord = Photo::create([ 'event_id' => $event->id, 'emotion_id' => $request->emotion_id, 'task_id' => $request->task_id, 'guest_name' => $request->guest_name, 'file_path' => $originalPath, 'thumbnail_path' => $thumbnailPath, 'metadata' => $metadata ]); // Response mit Relations $photoRecord->load(['emotion', 'task']); // Optional: Broadcasting für Live-Updates // broadcast(new PhotoUploaded($photoRecord)); return response()->json([ 'success' => true, 'data' => $photoRecord, 'message' => 'Foto erfolgreich hochgeladen!' ], 201); } public function index(Request $request, Event $event) { $query = $event->photos()->with(['emotion', 'task', 'likes']); // Filter nach Emotion if ($request->has('emotion_id')) { $query->where('emotion_id', $request->emotion_id); } // Sortierung $sortBy = $request->get('sort', 'latest'); switch ($sortBy) { case 'popular': $query->withCount('likes')->orderByDesc('likes_count'); break; case 'oldest': $query->oldest(); break; default: $query->latest(); } $photos = $query->paginate(20); return response()->json($photos); } public function toggleLike(Request $request, Event $event, Photo $photo) { $request->validate([ 'guest_name' => 'required|string|max:100' ]); $like = PhotoLike::where([ 'photo_id' => $photo->id, 'guest_name' => $request->guest_name, 'ip_address' => $request->ip() ])->first(); if ($like) { $like->delete(); $photo->decrement('likes_count'); $liked = false; } else { PhotoLike::create([ 'photo_id' => $photo->id, 'guest_name' => $request->guest_name, 'ip_address' => $request->ip() ]); $photo->increment('likes_count'); $liked = true; } return response()->json([ 'liked' => $liked, 'likes_count' => $photo->fresh()->likes_count ]); } } ``` ### Frontend: React + Vite PWA Note: Event Type-aware UI - Theme colors can come from `event_types.settings.palette` to skin headers/buttons per type. - Emotion picker fetches type-filtered emotions via `/api/events/:slug/emotions`. - Random task generator and gallery filters respect event type automatically via API. #### Projekt-Struktur ``` resources/js/ ├── src/ │ ├── components/ │ │ ├── Camera/ │ │ │ ├── CameraCapture.jsx │ │ │ ├── PhotoPreview.jsx │ │ │ └── TaskOverlay.jsx │ │ ├── Emotions/ │ │ │ ├── EmotionPicker.jsx │ │ │ ├── EmotionBubble.jsx │ │ │ └── TaskGenerator.jsx │ │ ├── Gallery/ │ │ │ ├── PhotoGrid.jsx │ │ │ ├── PhotoModal.jsx │ │ │ └── PhotoCard.jsx │ │ ├── Layout/ │ │ │ ├── Header.jsx │ │ │ ├── Navigation.jsx │ │ │ └── LoadingSpinner.jsx │ │ └── Forms/ │ │ ├── GuestNameForm.jsx │ │ └── PhotoUpload.jsx │ ├── pages/ │ │ ├── GeneralLandingPage.jsx │ │ ├── EventSpecificLandingPage.jsx │ │ ├── HomePage.jsx │ │ ├── CameraPage.jsx │ │ ├── GalleryPage.jsx │ ├── hooks/ │ │ ├── useCamera.js │ │ ├── usePhotoUpload.js │ │ ├── useLocalStorage.js │ │ └── useRealTimeFeed.js │ ├── services/ │ │ ├── api.js │ │ ├── imageUtils.js │ │ └── cacheManager.js │ ├── context/ │ │ ├── AppContext.jsx │ │ └── EventContext.jsx │ └── utils/ │ ├── constants.js │ ├── helpers.js │ └── validation.js ``` #### Vite Configuration (vite.config.js) ```javascript import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import path from 'path' export default defineConfig({ plugins: [ react(), VitePWA({ registerType: 'prompt', includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'safari-pinned-tab.svg'], manifest: { name: 'Hochzeits-Fotobox', short_name: 'Fotobox', description: 'Emotionale Hochzeitsfotos für unvergessliche Momente', theme_color: '#ff6b9d', background_color: '#ffffff', display: 'standalone', orientation: 'portrait-primary', start_url: '/', scope: '/', icons: [ { src: '/icons/pwa-192x192.png', sizes: '192x192', type: 'image/png' }, { src: '/icons/pwa-512x512.png', sizes: '512x512', type: 'image/png' } ] }, workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], runtimeCaching: [ { urlPattern: /^\/api\//, handler: 'NetworkFirst', options: { cacheName: 'api-cache', expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 // 24 hours } } }, { urlPattern: /\.(png|jpg|jpeg|svg|gif)$/, handler: 'CacheFirst', options: { cacheName: 'images-cache', expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week } } } ] } }) ], resolve: { alias: { '@': path.resolve(__dirname, './resources/js/src') } }, build: { outDir: 'public/build', emptyOutDir: true, manifest: true, rollupOptions: { input: 'resources/js/app.jsx' } }, server: { host: true, hmr: { host: 'localhost' } } }) ``` #### React Haupt-Komponenten **App.jsx (Routing & Context)** ```jsx import React from 'react' import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { EventProvider } from '@/context/EventContext' import { AppProvider } from '@/context/AppContext' // Pages import EventLanding from '@/pages/EventLanding' import HomePage from '@/pages/HomePage' import CameraPage from '@/pages/CameraPage' import GalleryPage from '@/pages/GalleryPage' // Layout import Layout from '@/components/Layout/Layout' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes cacheTime: 1000 * 60 * 30, // 30 minutes retry: 2 } } }) function App() { return ( {/* Event Selection */} } /> {/* Main App (requires event context) */} } /> } /> } /> } /> } /> } /> } /> ) } export default App ``` **GeneralLandingPage.jsx** ```jsx import React from 'react'; import { useNavigate } from 'react-router-dom'; function GeneralLandingPage() { const navigate = useNavigate(); const handleQRScan = () => { // Trigger device camera to scan QR code // This would use a library like jsQR or react-qr-reader console.log("Opening QR scanner..."); }; const handleManualEntry = () => { // Open modal for manual code entry const code = prompt("Bitte gib den Event-Code ein:"); if (code) { // Validate and redirect to event navigate(`/event/${code}`); } }; return (
{/* Minimalist Header */}
Event Date
Branding Logo
{/* Main Content */}

Willkommen bei der Fotobox! 🎉

Dein Schlüssel zu unvergesslichen Hochzeitsmomenten.

{/* Central Card for Actions */}
{/* QR Code Scanner Emphasis */}
{/* Manual Code Entry (Fallback) */}
{/* Minimalist Footer */}
); } export default GeneralLandingPage; ``` **EventSpecificLandingPage.jsx** ```jsx import React, { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; function EventSpecificLandingPage() { const { eventSlug } = useParams(); const navigate = useNavigate(); const [guestName, setGuestName] = useState(''); const handleSubmit = (e) => { e.preventDefault(); if (guestName.trim()) { // Store guest name in context or localStorage localStorage.setItem('guestName', guestName); // Redirect to main event page navigate(`/event/${eventSlug}`); } }; return (
{/* Header */}
❤️📸
{/* Heart with Camera Icon */}
01.01.2025
{/* Event Date */}
{/* Main Content - Vertically Centered */}
{/* Title Section */}

HOCHZEIT VON

MARIE & LUKAS

Fangt die schönsten Momente ein!

{/* Input Section */}

Bereit für Emotionen?

setGuestName(e.target.value)} className="w-full p-3 mb-4 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-300" required />
{/* Footer */}
); } export default EventSpecificLandingPage; ``` **SettingsSheet.jsx (gear icon → bottom sheet)** ```jsx import React from "react" import { Link } from "react-router-dom" import { useTranslation } from "react-i18next" export default function SettingsSheet({ open, onClose, onLangChange, currentLang }) { const { t } = useTranslation() if (!open) return null return (

{t("ui:settings")}

{t("ui:language")}
{t("ui:legal")}
  • Impressum
  • Datenschutzerklärung
  • AGB
) } ``` **LegalPage.jsx (renders platform-managed markdown)** ```jsx import React from "react" import { useParams } from "react-router-dom" import { useQuery } from "@tanstack/react-query" import ReactMarkdown from "react-markdown" import rehypeSanitize from "rehype-sanitize" import { useTranslation } from "react-i18next" export default function LegalPage(){ const { slug } = useParams() const { i18n } = useTranslation() const lang = i18n.language || "de" const { data, isLoading, error } = useQuery({ queryKey: ["legal", slug, lang], queryFn: async () => { const res = await fetch(`/api/legal/${slug}?lang=${lang}`) if(!res.ok) throw new Error("Failed to load legal page") return res.json() } }) if (isLoading) return
Loading…
if (error) return
Error loading content.
return (

{data.title}

{data.updated_at ? new Date(data.updated_at).toLocaleDateString(lang) : null}
{data.body_markdown || ""}
) } ``` **Header.jsx (gear button opens SettingsSheet and changes language)** ```jsx import React from "react" import { Settings } from "lucide-react" import { useTranslation } from "react-i18next" import SettingsSheet from "@/components/Layout/SettingsSheet" export default function Header() { const { i18n } = useTranslation() const [open, setOpen] = React.useState(false) const onLangChange = (lng) => { i18n.changeLanguage(lng) localStorage.setItem("lang", lng) setOpen(false) } return (
Fotobox
setOpen(false)} onLangChange={onLangChange} currentLang={i18n.language || "de"} />
) } ``` ## 🌍 Internationalization (i18n) ### Goals & Locales - Support multi-lingual content across admin, API, and PWA. - Initial locales: `de` (default) and `en`; easily extendable. ### Backend (Laravel 12 + PHP 8.4) - Dependency: `spatie/laravel-translatable` for JSON translations. - App config (`config/app.php`): set `locale`, `fallback_locale`, and optionally `APP_SUPPORTED_LOCALES=de,en`. #### i18n Data Model - Store translatable fields as JSON: - `event_types.name` - `events.name`, `events.description` - `emotions.name`, `emotions.description` - `tasks.title`, `tasks.description`, `tasks.example_text` #### Model Setup ```php schema([ Schema\Components\Select::make('slug') ->label('Type')->options([ 'imprint' => 'Impressum', 'privacy' => 'Datenschutzerklärung', 'terms' => 'AGB', 'custom' => 'Custom', ])->required(), Schema\Components\Tabs::make('Translations')->tabs([ Schema\Components\Tabs\Tab::make('Deutsch')->schema([ Schema\Components\TextInput::make('title.de')->label('Titel (DE)')->required(), Schema\Components\Textarea::make('body_markdown.de')->label('Inhalt (DE)')->rows(12)->required(), ]), Schema\Components\Tabs\Tab::make('English')->schema([ Schema\Components\TextInput::make('title.en')->label('Title (EN)')->required(), Schema\Components\Textarea::make('body_markdown.en')->label('Content (EN)')->rows(12)->required(), ]), ])->columnSpanFull(), Schema\Components\Grid::make(3)->schema([ Schema\Components\TextInput::make('version')->numeric()->default(1), Schema\Components\DateTimePicker::make('effective_from'), Schema\Components\Toggle::make('is_published')->label('Published'), ]), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\BadgeColumn::make('slug')->label('Type')->colors([ 'primary' => 'imprint','success'=>'privacy','warning'=>'terms','gray'=>'custom' ]), Tables\Columns\TextColumn::make('title')->label('Title')->formatStateUsing(fn($r)=>$r->getTranslation('title', app()->getLocale()))->limit(40), Tables\Columns\TextColumn::make('version')->sortable(), Tables\Columns\IconColumn::make('is_published')->boolean(), Tables\Columns\TextColumn::make('updated_at')->dateTime('d.m.Y H:i')->sortable(), ]) ->headerActions([ Actions\CreateAction::make(), ]) ->recordActions([ Actions\EditAction::make(), Actions\Action::make('preview')->label('Preview')->url(fn($r)=>url("/legal/{$r->slug}?lang=".app()->getLocale()))->openUrlInNewTab(), Actions\DeleteAction::make(), ]); } } ``` ### API (public) ```php // routes/api.php use App\Http\Controllers\Api\LegalController; Route::get('/legal/{slug}', [LegalController::class, 'show']); ``` ```php getLocale(); $page = LegalPage::query() ->where('slug', $slug) ->where('is_published', true) ->orderByDesc('version') ->firstOrFail(); return response()->json([ 'slug' => $page->slug, 'title' => $page->getTranslation('title', $locale), 'body_markdown' => $page->getTranslation('body_markdown', $locale), 'version' => $page->version, 'effective_from' => $page->effective_from, 'updated_at' => $page->updated_at, 'locale' => $locale, ]); } } ``` ### PWA UX - Header gear icon opens a Settings sheet with: - Language: DE/EN toggle - Legal: Impressum, Datenschutzerklärung, AGB (links) - Footer links (public pages): “Impressum · Datenschutz · AGB” (DE labels in DE locale) for easy/direct access. - Routes: `/legal/imprint`, `/legal/privacy`, `/legal/terms` → render via API. React components (sketch): ```jsx // routes } /> // components/SettingsSheet.jsx (triggered by gear button) // shows language selector and links to /legal/... routes ``` Workbox caching (vite-plugin-pwa): add runtime cache for `/api/legal/*` (CacheFirst with revalidate). Compliance (DE): ensure “Impressum” is labeled in German and reachable within ≤2 clicks; show last updated date; include provider details (name, address, contact, VAT/registry) in content. #### Locale Resolution (API) - Precedence: `?lang` → `Accept-Language` → `event.default_locale` → fallback. - Set `Content-Language` header. ```php class SetLocale { /* see middleware example above */ } ``` ### Admin (Filament v4) Forms - Use Tabs for per-locale inputs (Spatie accepts arrays): ```php Schema\Components\Tabs::make('Translations')->tabs([ Schema\Components\Tabs\Tab::make('Deutsch')->schema([ Schema\Components\TextInput::make('title.de')->label('Titel (DE)')->required(), Schema\Components\Textarea::make('description.de')->label('Beschreibung (DE)')->rows(3), Schema\Components\TextInput::make('example_text.de')->label('Beispiel (DE)'), ]), Schema\Components\Tabs\Tab::make('English')->schema([ Schema\Components\TextInput::make('title.en')->label('Title (EN)'), Schema\Components\Textarea::make('description.en')->label('Description (EN)')->rows(3), Schema\Components\TextInput::make('example_text.en')->label('Example (EN)'), ]), ]) ``` ### CSV (Localized Fields) - Extra headers allowed: `title:de`, `title:en`, `description:de`, `description:en`, `example_text:de`, `example_text:en`. - If only base columns exist, treat them as the active import locale. ```csv emotion,title:de,title:en,description:de,description:en,difficulty,is_active,scope,event_type Freude,Sprung-Foto,Jump Photo,Alle springen!,Everyone jump!,medium,1,tenant,corporate ``` ### Frontend (React PWA) - Dependencies: `i18next`, `react-i18next`, `i18next-browser-languagedetector`. - Initialize in `resources/js/src/i18n.js` and pass locale to API via `?lang=`. **HomePage.jsx (Emotions-Picker)** ```jsx import React from 'react' import { useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { useEvent } from '@/context/EventContext' import { useApp } from '@/context/AppContext' import EmotionBubble from '@/components/Emotions/EmotionBubble' import PhotoGrid from '@/components/Gallery/PhotoGrid' import LoadingSpinner from '@/components/Layout/LoadingSpinner' function HomePage() { const navigate = useNavigate() const { eventSlug, apiService } = useEvent() const { guestName, setGuestName } = useApp() // Emotionen laden const { data: emotions, isLoading: emotionsLoading } = useQuery({ queryKey: ['emotions', eventSlug], queryFn: () => apiService.getEmotions(), enabled: !!eventSlug }) // Neueste Fotos laden const { data: recentPhotos, isLoading: photosLoading } = useQuery({ queryKey: ['photos', eventSlug, 'recent'], queryFn: () => apiService.getPhotos({ limit: 8, sort: 'latest' }), enabled: !!eventSlug, refetchInterval: 30000 // Alle 30 Sekunden aktualisieren }) const handleEmotionClick = (emotionId) => { if (!guestName) { // Gast-Namen abfragen wenn noch nicht gesetzt const name = prompt('Wie sollen wir dich nennen?', '') if (name?.trim()) { setGuestName(name.trim()) } else { return } } navigate(`/event/${eventSlug}/camera/${emotionId}`) } if (emotionsLoading) { return } return (
{/* Header */}

Willkommen zur Fotobox! 📸

{guestName && (

Hallo, {guestName}! 👋

)}
{/* Emotions-Picker */}
{emotions?.map((emotion) => ( handleEmotionClick(emotion.id)} className="flex-shrink-0" /> ))}
{/* "Alle anzeigen" Button */}
{/* Recent Photos Feed */}

Neueste Momente

{photosLoading ? (
{[...Array(4)].map((_, i) => (
))}
) : ( )}
) } export default HomePage ``` **CameraPage.jsx (Foto-Aufnahme)** ```jsx import React, { useState, useRef, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useEvent } from '@/context/EventContext' import { useApp } from '@/context/AppContext' import CameraCapture from '@/components/Camera/CameraCapture' import TaskOverlay from '@/components/Camera/TaskOverlay' import PhotoPreview from '@/components/Camera/PhotoPreview' function CameraPage() { const { emotionId } = useParams() const navigate = useNavigate() const { eventSlug, apiService } = useEvent() const { guestName } = useApp() const queryClient = useQueryClient() const [capturedPhoto, setCapturedPhoto] = useState(null) const [currentTask, setCurrentTask] = useState(null) const cameraRef = useRef(null) // Aktueller Task laden const { data: tasks, refetch: refetchTasks } = useQuery({ queryKey: ['tasks', eventSlug, emotionId], queryFn: () => apiService.getRandomTask(emotionId), enabled: !!emotionId && !!eventSlug, onSuccess: (data) => { if (data && !currentTask) { setCurrentTask(data) } } }) // Foto-Upload Mutation const uploadMutation = useMutation({ mutationFn: (photoData) => apiService.uploadPhoto(photoData), onSuccess: () => { // Cache invalidieren für sofortige Updates queryClient.invalidateQueries(['photos', eventSlug]) // Zurück zur Startseite navigate(`/event/${eventSlug}`) }, onError: (error) => { console.error('Upload failed:', error) alert('Upload fehlgeschlagen. Bitte versuche es erneut.') } }) const handleCapture = useCallback((photoBlob) => { setCapturedPhoto(photoBlob) }, []) const handleRetake = useCallback(() => { setCapturedPhoto(null) }, []) const handleUpload = useCallback(() => { if (!capturedPhoto || !guestName || !currentTask) return const formData = new FormData() formData.append('photo', capturedPhoto, 'photo.jpg') formData.append('emotion_id', emotionId) formData.append('task_id', currentTask.id) formData.append('guest_name', guestName) uploadMutation.mutate(formData) }, [capturedPhoto, guestName, emotionId, currentTask, uploadMutation]) const handleNewTask = useCallback(() => { refetchTasks().then((result) => { if (result.data) { setCurrentTask(result.data) } }) }, [refetchTasks]) // Redirect wenn kein Gast-Name gesetzt React.useEffect(() => { if (!guestName) { navigate(`/event/${eventSlug}`) } }, [guestName, navigate, eventSlug]) if (!currentTask) { return (

Aufgabe wird geladen...

) } return (
{!capturedPhoto ? ( <> {/* Live-Kamera */} {/* Task-Overlay */} navigate(`/event/${eventSlug}`)} /> ) : ( /* Foto-Vorschau */ )}
) } export default CameraPage ``` #### PWA Service Worker Features **Offline-Funktionalität:** ```javascript // public/sw.js (Custom Service Worker) const CACHE_NAME = 'fotobox-v1' const OFFLINE_URL = '/offline.html' // Offline-Queue für Photo-Uploads class PhotoUploadQueue { constructor() { this.queue = [] this.isProcessing = false } async add(photoData) { this.queue.push(photoData) await this.process() } async process() { if (this.isProcessing || this.queue.length === 0) return this.isProcessing = true while (this.queue.length > 0) { const photoData = this.queue.shift() try { const response = await fetch(photoData.url, { method: 'POST', body: photoData.formData }) if (response.ok) { // Upload erfolgreich - Cache invalidieren await caches.delete('api-cache') // Client benachrichtigen self.clients.matchAll().then(clients => { clients.forEach(client => { client.postMessage({ type: 'PHOTO_UPLOADED', data: photoData }) }) }) } else { // Fehlgeschlagen - zurück in die Queue this.queue.unshift(photoData) break } } catch (error) { // Netzwerkfehler - zurück in die Queue this.queue.unshift(photoData) break } } this.isProcessing = false } } const uploadQueue = new PhotoUploadQueue() // Network-first für API-Calls self.addEventListener('fetch', event => { if (event.request.url.includes('/api/')) { event.respondWith( fetch(event.request) .then(response => { // Response klonen für Cache const responseClone = response.clone() caches.open(CACHE_NAME).then(cache => { cache.put(event.request, responseClone) }) return response }) .catch(() => { // Fallback auf Cache return caches.match(event.request) }) ) } }) ``` ## 🎨 UI/UX Design-Spezifikationen ### Farbschema ```javascript // tailwind.config.js - Custom Colors module.exports = { theme: { extend: { colors: { emotions: { love: '#ff6b9d', // Liebe - Pink joy: '#ffd93d', // Freude - Gelb touched: '#6bcf7f', // Rührung - Grün nostalgia: '#a78bfa', // Nostalgie - Lila surprise: '#fb7185', // Überraschung - Rosa pride: '#34d399' // Stolz - Türkis }, wedding: { primary: '#ff6b9d', secondary: '#a78bfa', accent: '#ffd93d', neutral: '#6b7280', success: '#10b981', error: '#ef4444' } }, fontFamily: { 'wedding': ['Poppins', 'Inter', 'sans-serif'] }, animation: { 'bounce-soft': 'bounce 2s infinite', 'pulse-slow': 'pulse 3s infinite', 'wiggle': 'wiggle 1s ease-in-out infinite', 'fade-in': 'fadeIn 0.5s ease-in-out', 'slide-up': 'slideUp 0.3s ease-out' } } } } ``` ### Responsive Breakpoints - **Mobile First:** 320px - 768px (Hauptzielgruppe) - **Tablet:** 768px - 1024px (Landscape-Modus) - **Desktop:** 1024px+ (Admin-Interface) ### Gestaltungs-Prinzipien 1. **Thumb-Friendly:** Alle wichtigen Buttons in Daumen-Reichweite 2. **High Contrast:** Gute Lesbarkeit auch bei schlechtem Licht 3. **Emotional Colors:** Jede Emotion hat eigene Farb-Identität 4. **Micro-Animations:** Subtile Feedback-Animationen 5. **Progressive Disclosure:** Komplexität schrittweise aufbauen ## 📱 Komponenten-Spezifikationen ### EmotionBubble Component ```jsx import React from 'react' import { motion } from 'framer-motion' const EmotionBubble = ({ emotion, onClick, isActive, className }) => { return ( onClick(emotion.id)} > {/* Emoji Icon */} {emotion.icon} {/* Name */} {emotion.name} {/* Pulse Animation für aktive Emotion */} {isActive && ( )} ) } ``` ### CameraCapture Component ```jsx import React, { useRef, useEffect, useState } from 'react' import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react' const CameraCapture = React.forwardRef(({ onCapture }, ref) => { const videoRef = useRef(null) const canvasRef = useRef(null) const [stream, setStream] = useState(null) const [hasFlash, setHasFlash] = useState(false) const [isFlashOn, setIsFlashOn] = useState(false) const [facingMode, setFacingMode] = useState('user') // 'user' oder 'environment' // Kamera initialisieren useEffect(() => { startCamera() return () => { stopCamera() } }, [facingMode]) const startCamera = async () => { try { const constraints = { video: { facingMode: facingMode, width: { ideal: 1080 }, height: { ideal: 1920 } } } const mediaStream = await navigator.mediaDevices.getUserMedia(constraints) setStream(mediaStream) if (videoRef.current) { videoRef.current.srcObject = mediaStream } // Flash-Unterstützung prüfen const videoTrack = mediaStream.getVideoTracks()[0] const capabilities = videoTrack.getCapabilities() setHasFlash(capabilities.torch === true) } catch (error) { console.error('Kamera-Zugriff fehlgeschlagen:', error) alert('Kamera-Zugriff nicht möglich. Bitte Berechtigungen prüfen.') } } const stopCamera = () => { if (stream) { stream.getTracks().forEach(track => track.stop()) setStream(null) } } const toggleFlash = async () => { if (!hasFlash || !stream) return const videoTrack = stream.getVideoTracks()[0] try { await videoTrack.applyConstraints({ advanced: [{ torch: !isFlashOn }] }) setIsFlashOn(!isFlashOn) } catch (error) { console.error('Flash toggle fehlgeschlagen:', error) } } const switchCamera = () => { setFacingMode(prev => prev === 'user' ? 'environment' : 'user') } const capturePhoto = () => { if (!videoRef.current || !canvasRef.current) return const video = videoRef.current const canvas = canvasRef.current const ctx = canvas.getContext('2d') // Canvas-Größe an Video anpassen canvas.width = video.videoWidth canvas.height = video.videoHeight // Video-Frame auf Canvas zeichnen ctx.drawImage(video, 0, 0, canvas.width, canvas.height) // Canvas zu Blob konvertieren canvas.toBlob((blob) => { if (blob && onCapture) { onCapture(blob) } }, 'image/jpeg', 0.9) } return (
{/* Video Stream */}
) }) export default CameraCapture ``` ### TaskOverlay Component ```jsx import React from 'react' import { motion } from 'framer-motion' import { Shuffle, ArrowLeft, HelpCircle } from 'lucide-react' const TaskOverlay = ({ task, onNewTask, onBack, onShowHelp }) => { if (!task) return null return ( {/* Background Gradient */}
{/* Header Controls */}
{/* Task Content */}
{/* Emotion Badge */}
{task.emotion?.icon} {task.emotion?.name}
{/* Task Title */}

{task.title}

{/* Task Description */}

{task.description}

{/* Difficulty Indicator */}
{[...Array(3)].map((_, i) => (
))} {task.difficulty}
{/* Example Hint */} {task.example_text && (

💡 {task.example_text}

)}
) } const getDifficultyLevel = (difficulty) => { switch (difficulty) { case 'easy': return 1 case 'medium': return 2 case 'hard': return 3 default: return 1 } } export default TaskOverlay ``` ## 🧩 Tenant-Defined Tasks & CSV Import ### Goals - Allow tenants (customers) to create and manage their own photo tasks. - Support CSV import for bulk task creation for both Tenant Admins and Super Admin. - Keep global (platform) task templates that tenants can also use. ### Schema Changes (Tenant Scoping for Tasks) ```sql -- Extend tasks to support tenant/event scoping ALTER TABLE tasks ADD COLUMN tenant_id INTEGER NULL; -- owner tenant for custom tasks ALTER TABLE tasks ADD COLUMN scope TEXT DEFAULT 'global' CHECK (scope IN ('global','tenant','event')); ALTER TABLE tasks ADD COLUMN event_id INTEGER NULL; -- optional link for event-specific tasks ALTER TABLE tasks ADD CONSTRAINT fk_tasks_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id); ALTER TABLE tasks ADD CONSTRAINT fk_tasks_event_id FOREIGN KEY (event_id) REFERENCES events(id); -- Prevent duplicate titles per emotion within the same tenant (optional) CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_unique_per_tenant ON tasks(tenant_id, emotion_id, title); ``` Notes: - Use `scope='global'` for Super Admin library tasks, `tenant` for customer-defined, and `event` for per-event overrides. - `tenant_id`/`event_id` are NULL for `global` tasks. ### CSV Format - Encoding: UTF-8, delimiter: comma. - Header columns: - emotion (name or ID) - title - description - difficulty (easy|medium|hard) - example_text (optional) - is_active (1|0, optional; default 1) - sort_order (integer, optional) - scope (global|tenant|event, optional; default depends on UI context) - event_slug (required if scope=event)`n - event_type (slug, optional; defaults to current event type or chosen library type) Example: ```csv emotion,title,description,difficulty,example_text,is_active,sort_order,scope,event_type,event_slug Freude,Sprung-Foto,Alle springen gleichzeitig in die Luft!,medium,,1,10,tenant,corporate, Liebe,Kuss-Foto,Macht ein romantisches Kuss-Foto,easy,Einfach küssen und lächeln!,1,5,global,wedding, Überraschung,Plötzlicher Jubel,Erschreckt das Brautpaar mit Jubel,hard,,1,20,event,,mueller-hochzeit-2025 ``` Validation rules: - emotion must exist (map by name → ID considering tenant visibility). - difficulty in allowed set; length limits per model. - If scope=event, event_slug must resolve to a tenant-owned event. ### Filament v4 — Tenant Admin Task Resource (with CSV Import) ```php schema([ Schema\Components\Section::make('Aufgabe') ->schema([ Schema\Components\Select::make('emotion_id') ->label('Emotion') ->relationship('emotion', 'name') ->required(), Schema\Components\TextInput::make('title')->required()->maxLength(255), Schema\Components\Textarea::make('description')->required()->rows(3), Schema\Components\Select::make('difficulty') ->options(['easy'=>'Easy','medium'=>'Medium','hard'=>'Hard'])->required(), Schema\Components\TextInput::make('example_text'), Schema\Components\Toggle::make('is_active')->default(true), Schema\Components\TextInput::make('sort_order')->numeric()->default(0), ])->columns(2), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('emotion.name')->label('Emotion')->sortable()->searchable(), Tables\Columns\TextColumn::make('title')->searchable(), Tables\Columns\BadgeColumn::make('difficulty')->colors(['easy'=>'success','medium'=>'warning','hard'=>'danger']), Tables\Columns\IconColumn::make('is_active')->boolean(), Tables\Columns\TextColumn::make('sort_order')->sortable(), ]) ->filters([ Tables\Filters\SelectFilter::make('event_type_id') ->label('Event Type') ->options(fn () => \App\Models\EventType::orderBy('name')->pluck('name', 'id')->all()) ->query(function ($query, $state) { if (! $state) { return; } $query->where(function($q) use ($state) { $q->where('event_type_id', $state) ->orWhereExists(function($sub) use ($state) { $sub->selectRaw(1) ->from('emotion_event_type as eet') ->whereColumn('eet.emotion_id', 'tasks.emotion_id') ->where('eet.event_type_id', $state); }); }); }), ]) ->headerActions([ Actions\CreateAction::make(), Actions\Action::make('importCsv') ->label('CSV Import') ->icon('heroicon-o-arrow-up-tray') ->form([ Schema\Components\FileUpload::make('csv')->acceptedFileTypes(['text/csv','text/plain'])->required(), Schema\Components\Select::make('scope')->options(['tenant'=>'Tenant','event'=>'Event'])->default('tenant')->required(), Schema\Components\Select::make('event_id') ->label('Event (für Scope=Event)') ->relationship('event', 'name') ->searchable() ->visible(fn ($get) => $get('scope') === 'event'), ]) ->action(function(array $data) { $path = $data['csv']; $full = storage_path('app/public/'.$path); $csv = Reader::createFromPath($full, 'r'); $csv->setHeaderOffset(0); DB::transaction(function() use ($csv, $data) { foreach ($csv->getRecords() as $row) { $emotion = Emotion::query()->where('name', $row['emotion'] ?? '')->first(); if (! $emotion) { continue; } Task::create([ 'tenant_id' => auth()->user()->tenant_id, 'event_id' => $data['scope'] === 'event' ? ($data['event_id'] ?? null) : null, 'scope' => $data['scope'], 'emotion_id' => $emotion->id, 'title' => trim($row['title'] ?? ''), 'description' => trim($row['description'] ?? ''), 'difficulty' => $row['difficulty'] ?? 'easy', 'example_text' => $row['example_text'] ?? null, 'is_active' => (int)($row['is_active'] ?? 1) === 1, 'sort_order' => (int)($row['sort_order'] ?? 0), ]); } }); }) ->requiresConfirmation(), ]) ->recordActions([ Actions\EditAction::make(), Actions\DeleteAction::make(), ]); } public static function getEloquentQuery() { // Tenant scoping for tasks: show global + tenant-owned return parent::getEloquentQuery()->where(function($q){ $tenantId = auth()->user()->tenant_id; $q->whereNull('tenant_id')->where('scope','global') ->orWhere('tenant_id', $tenantId); }); } } ``` **EmotionController (filtered by event type):** ```php event_type_id; $emotions = Emotion::query() ->where('is_active', true) ->where(function($q) use ($typeId) { $q->whereIn('id', function($sub) use ($typeId) { $sub->from('emotion_event_type') ->select('emotion_id') ->where('event_type_id', $typeId); }); }) ->orderBy('sort_order') ->get(); return response()->json($emotions); } } ``` **TaskController (respect event type and scope precedence):** ```php user())->tenant_id; // optional auth $typeId = $event->event_type_id; $query = Task::query() ->where('emotion_id', $emotionId) ->where('is_active', true) ->where(function($q) use ($event, $tenantId, $typeId) { $q->where('scope', 'event')->where('event_id', $event->id) ->orWhere(function($q2) use ($tenantId, $typeId) { $q2->where('scope','tenant') ->where('tenant_id', $tenantId) ->where(function($qq) use ($typeId){ $qq->whereNull('event_type_id')->orWhere('event_type_id', $typeId); }); }) ->orWhere(function($q3) use ($typeId) { $q3->where('scope','global') ->where(function($qq) use ($typeId){ $qq->whereNull('event_type_id')->orWhere('event_type_id', $typeId); }); }); }) ->orderByRaw("CASE WHEN scope='event' THEN 1 WHEN scope='tenant' THEN 2 ELSE 3 END") ->orderBy('sort_order') ->get(); return response()->json($query); } public function getRandomTask(Request $request, Event $event, $emotionId) { $request->merge(['limit' => 1]); $tasks = $this->getByEmotion($request, $event, $emotionId)->getData(); // pick a random from top precedence group $collection = collect($tasks); $topScope = $collection->min(fn($t) => $t->scope === 'event' ? 1 : ($t->scope === 'tenant' ? 2 : 3)); $pool = $collection->filter(fn($t) => ($t->scope === 'event' ? 1 : ($t->scope === 'tenant' ? 2 : 3)) === $topScope); return response()->json($pool->random()); } } ``` ### Super Admin — Event Types Resource ```php columns([ Tables\\Columns\\TextColumn::make("name")->searchable()->sortable(), Tables\\Columns\\TextColumn::make("icon"), Tables\\Columns\\TextColumn::make("color"), ]) ->filters([ Tables\\Filters\\SelectFilter::make("event_type") ->label("Event Type") ->options(fn () => EventType::orderBy("name")->pluck("name","id")->all()) ->query(function($query, $state){ if (! $state) { return; } $query->whereIn("id", function($sub) use ($state) { $sub->from("emotion_event_type") ->select("emotion_id") ->where("event_type_id", $state); }); }), ]) ->recordActions([ Actions\\EditAction::make(), Actions\\DeleteAction::make(), ]) ->headerActions([ Actions\\CreateAction::make() ]); } } ``` // app/Filament/SuperAdmin/Resources/EventTypeResource.php namespace App\\Filament\\SuperAdmin\\Resources; use App\\Models\\EventType; use Filament\\Resources\\Resource; use Filament\\Tables; use Filament\\Tables\\Table; use Filament\\Forms\\Form; use Filament\\Schema as Schema; use Filament\\Actions; class EventTypeResource extends Resource { protected static ?string $model = EventType::class; protected static ?string $navigationIcon = "heroicon-o-rectangle-stack"; protected static ?string $navigationGroup = "Platform Configuration"; protected static ?string $label = "Event Types"; public static function form(Form $form): Form { return $form->schema([ Schema\\Components\\TextInput::make("name")->required()->maxLength(100), Schema\\Components\\TextInput::make("slug")->required()->maxLength(100)->unique(ignoreRecord: true), Schema\\Components\\TextInput::make("icon")->maxLength(64), Schema\\Components\\KeyValue::make("settings")->keyLabel("Key")->valueLabel("Value"), ]); } public static function table(Table $table): Table { return $table ->columns([ Tables\\Columns\\TextColumn::make("name")->searchable()->sortable(), Tables\\Columns\\TextColumn::make("slug")->searchable(), Tables\\Columns\\TextColumn::make("icon"), ]) ->recordActions([ Actions\\EditAction::make(), Actions\\DeleteAction::make(), ]) ->headerActions([ Actions\\CreateAction::make() ]); } } ``` ### Filament v4 — Super Admin Global Task Library (with CSV Import) ```php columns([ Tables\Columns\TextColumn::make('scope')->badge(), Tables\Columns\TextColumn::make('tenant.name')->label('Tenant')->toggleable(isToggledHidden: true), Tables\Columns\TextColumn::make('emotion.name')->label('Emotion'), Tables\Columns\TextColumn::make('title')->limit(60), Tables\Columns\BadgeColumn::make('difficulty'), Tables\Columns\IconColumn::make('is_active')->boolean(), ] ->filters([ Tables\\Filters\\SelectFilter::make('event_type_id') ->label('Event Type') ->options(fn () => \\App\\Models\\EventType::orderBy('name')->pluck('name','id')->all()) ->query(function (,){ if(! ){ return; } ->where(function() use (){ ->where('event_type_id',) ->orWhereExists(function() use (){ ->selectRaw(1) ->from('emotion_event_type as eet') ->whereColumn('eet.emotion_id','tasks.emotion_id') ->where('eet.event_type_id',); }); }); }), ]) ) ->headerActions([ Actions\Action::make('importCsv') ->label('CSV Import') ->icon('heroicon-o-arrow-up-tray') ->form([ Schema\Components\FileUpload::make('csv')->acceptedFileTypes(['text/csv','text/plain'])->required(), Schema\Components\Select::make('scope')->options(['global'=>'Global','tenant'=>'Tenant','event'=>'Event'])->default('global')->required(), Schema\Components\Select::make('tenant_id')->relationship('tenant','name')->visible(fn($get)=>$get('scope')!=='global'), Schema\Components\Select::make('event_id')->label('Event')->relationship('event','name')->visible(fn($get)=>$get('scope')==='event'), ]) ->action(function(array $data){ $path = $data['csv']; $full = storage_path('app/public/'.$path); $csv = Reader::createFromPath($full, 'r'); $csv->setHeaderOffset(0); DB::transaction(function() use ($csv, $data) { foreach ($csv->getRecords() as $row) { $emotion = Emotion::query()->where('name', $row['emotion'] ?? '')->first(); if (! $emotion) { continue; } Task::create([ 'scope' => $data['scope'], 'tenant_id' => $data['scope']!=='global' ? ($data['tenant_id'] ?? null) : null, 'event_id' => $data['scope']==='event' ? ($data['event_id'] ?? null) : null, 'emotion_id' => $emotion->id, 'title' => trim($row['title'] ?? ''), 'description' => trim($row['description'] ?? ''), 'difficulty' => $row['difficulty'] ?? 'easy', 'example_text' => $row['example_text'] ?? null, 'is_active' => (int)($row['is_active'] ?? 1) === 1, 'sort_order' => (int)($row['sort_order'] ?? 0), ]); } }); }) ->requiresConfirmation(), ]); } } ``` Implementation notes: - Add policies to restrict Tenant Admins from editing global tasks. - When importing with scope=tenant/event in Super Admin, enforce that referenced tenant/event exist. - Consider a background Job for large CSV files and a progress UI. ## 🚀 Deployment & Setup-Anweisungen ### Entwicklungs-Setup #### 1. Laravel Backend Setup ```bash # Neues Laravel-Projekt erstellen composer create-project laravel/laravel:^12.0 hochzeits-fotobox cd hochzeits-fotobox # Abhängigkeiten installieren composer require intervention/image composer require pusher/pusher-php-server composer require laravel/sanctum composer require filament/filament:^4.0 -W # Falls Filament v3 benoetigt wird (Kompatibilitaet): # composer require filament/filament:^3.3 -W composer require league/csv:^9.15 # CSV parsing for task imports composer require spatie/laravel-translatable # JSON-based field translations (i18n) # Environment konfigurieren cp .env.example .env # Datenbankverbindung auf SQLite ändern: # DB_CONNECTION=sqlite # DB_DATABASE=/absolute/path/to/database/database.sqlite # SQLite-Datenbank erstellen touch database/database.sqlite # Migrations erstellen und ausführen php artisan make:migration create_events_table php artisan make:migration create_emotions_table php artisan make:migration create_event_types_table php artisan make:migration create_emotion_event_type_table php artisan make:migration add_event_type_id_to_events php artisan make:migration add_event_type_and_scoping_to_tasks php artisan make:migration add_i18n_translatables_json --table=events php artisan make:migration add_i18n_translatables_json_to_emotions --table=emotions php artisan make:migration add_i18n_translatables_json_to_tasks --table=tasks php artisan make:migration create_tasks_table php artisan make:migration create_photos_table php artisan make:migration create_photo_likes_table php artisan migrate # Models und Controller generieren php artisan make:model Event -c php artisan make:model Emotion -c php artisan make:model Task -c php artisan make:model Photo -c php artisan make:model PhotoLike # Seeder für Demo-Daten php artisan make:seeder EmotionsSeeder php artisan make:seeder TasksSeeder php artisan make:seeder DemoEventSeeder # Storage-Link erstellen php artisan storage:link # App-Key generieren php artisan key:generate ``` #### 2. Frontend Setup ```bash # NPM-Abhängigkeiten installieren npm install # React und Tools npm install react react-dom @vitejs/plugin-react npm install @tanstack/react-query npm install react-router-dom npm install framer-motion npm install lucide-react # Styling npm install tailwindcss postcss autoprefixer npm install @tailwindcss/forms @tailwindcss/aspect-ratio # PWA npm install vite-plugin-pwa workbox-window # Utils npm install date-fns npm install clsx npm install compressorjs npm install react-markdown rehype-sanitize # Tailwind initialisieren npx tailwindcss init -p # Verzeichnis-Struktur erstellen mkdir -p resources/js/src/{components,pages,hooks,services,context,utils} mkdir -p resources/js/src/components/{Camera,Emotions,Gallery,Layout,Forms} mkdir -p public/icons ``` #### 3. Seeder-Daten **EmotionsSeeder.php:** ```php 'Liebe', 'icon' => '💕', 'color' => '#ff6b9d', 'description' => 'Romantische und liebevolle Momente', 'sort_order' => 1 ], [ 'name' => 'Freude', 'icon' => '😂', 'color' => '#ffd93d', 'description' => 'Lustige und fröhliche Augenblicke', 'sort_order' => 2 ], [ 'name' => 'Rührung', 'icon' => '🥺', 'color' => '#6bcf7f', 'description' => 'Emotionale und berührende Szenen', 'sort_order' => 3 ], [ 'name' => 'Nostalgie', 'icon' => '📸', 'color' => '#a78bfa', 'description' => 'Erinnerungen und nostalgische Gefühle', 'sort_order' => 4 ], [ 'name' => 'Überraschung', 'icon' => '😲', 'color' => '#fb7185', 'description' => 'Unerwartete und überraschende Momente', 'sort_order' => 5 ], [ 'name' => 'Stolz', 'icon' => '🏆', 'color' => '#34d399', 'description' => 'Stolze und triumphale Augenblicke', 'sort_order' => 6 ] ]; foreach ($emotions as $emotionData) { Emotion::create($emotionData); } } } ``` **TasksSeeder.php:** ```php 'Liebe', 'tasks' => [ [ 'title' => 'Kuss-Foto', 'description' => 'Macht ein romantisches Kuss-Foto', 'difficulty' => 'easy', 'example_text' => 'Einfach küssen und lächeln!' ], [ 'title' => 'Händchen halten', 'description' => 'Zeigt eure Hände - verheiratet und verliebt', 'difficulty' => 'easy', 'example_text' => 'Ringe in Szene setzen' ], [ 'title' => 'Liebes-Pose', 'description' => 'Stellt die romantischste Szene aus einem Film nach', 'difficulty' => 'medium', 'example_text' => 'Titanic, Dirty Dancing oder eure Lieblings-Romanze' ], [ 'title' => 'Herz formen', 'description' => 'Formt mit euren Händen ein großes Herz', 'difficulty' => 'easy', 'example_text' => 'Gemeinsam über dem Kopf oder vor der Brust' ] ] ], // Freude-Tasks [ 'emotion' => 'Freude', 'tasks' => [ [ 'title' => 'Sprung-Foto', 'description' => 'Alle springen gleichzeitig in die Luft!', 'difficulty' => 'medium', 'example_text' => 'Auf drei: eins, zwei, drei - SPRUNG!' ], [ 'title' => 'Grimassen-Contest', 'description' => 'Macht die verrücktesten Gesichter', 'difficulty' => 'easy', 'example_text' => 'Je alberner, desto besser!' ], [ 'title' => 'Lach-Attacke', 'description' => 'Alle lachen so laut sie können', 'difficulty' => 'easy', 'example_text' => 'Echtes Lachen ist am schönsten' ], [ 'title' => 'Tanz-Pose', 'description' => 'Zeigt euren besten Tanz-Move', 'difficulty' => 'medium', 'example_text' => 'Disco, Hip-Hop oder klassischer Walzer' ] ] ], // Rührung-Tasks [ 'emotion' => 'Rührung', 'tasks' => [ [ 'title' => 'Tränen der Freude', 'description' => 'Zeigt eure emotionalen Gesichter', 'difficulty' => 'easy', 'example_text' => 'Echte Gefühle sind die schönsten' ], [ 'title' => 'Familien-Umarmung', 'description' => 'Große Gruppenumarmung mit der ganzen Familie', 'difficulty' => 'medium', 'example_text' => 'Alle kommen zusammen für eine warme Umarmung' ], [ 'title' => 'Stille Momente', 'description' => 'Ein ruhiges, besinnliches Foto', 'difficulty' => 'medium', 'example_text' => 'Manchmal sagt ein stiller Blick mehr als tausend Worte' ] ] ], // Weitere Emotionen... ]; foreach ($tasks as $emotionGroup) { $emotion = Emotion::where('name', $emotionGroup['emotion'])->first(); if ($emotion) { foreach ($emotionGroup['tasks'] as $taskData) { Task::create([ 'emotion_id' => $emotion->id, 'title' => $taskData['title'], 'description' => $taskData['description'], 'difficulty' => $taskData['difficulty'], 'example_text' => $taskData['example_text'] ?? null ]); } } } } } ``` ### Production Deployment #### 1. Shared Hosting Setup ```bash # .htaccess für Laravel in public_html RewriteEngine On RewriteCond %{REQUEST_URI} !^/public/ RewriteRule ^(.*)$ /public/$1 [L] # Laravel-Optimierungen php artisan config:cache php artisan route:cache php artisan view:cache # Frontend Build npm run build ``` #### 2. VPS/Cloud Deployment (mit Docker) ```dockerfile # Dockerfile FROM php:8.4-apache # PHP Extensions RUN docker-php-ext-install pdo pdo_sqlite gd # Apache Konfiguration RUN a2enmod rewrite COPY . /var/www/html COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf # Permissions RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache # Frontend Build RUN npm ci && npm run build EXPOSE 80 ``` #### 3. Produktions-Optimierungen **Laravel Optimierungen:** ```php // config/app.php - Produktionseinstellungen 'debug' => false, 'log_level' => 'error', // config/cache.php - File-Cache für bessere Performance 'default' => 'file', // config/session.php - Datei-basierte Sessions 'driver' => 'file', 'lifetime' => 720, // 12 Stunden für Hochzeitsfeier ``` **Nginx Konfiguration:** ```nginx server { listen 80; server_name your-domain.com; root /var/www/hochzeits-fotobox/public; index index.php; # Gzip Kompression gzip on; gzip_vary on; gzip_min_length 1024; gzip_types text/plain text/css application/json application/javascript text/xml application/xml; # Caching für statische Assets location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { expires 1y; add_header Cache-Control "public, immutable"; } # PHP Processing location ~ \.php$ { fastcgi_pass unix:/run/php/php8.4-fpm.sock; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; include fastcgi_params; } # Laravel Routes location / { try_files $uri $uri/ /index.php?$query_string; } } ``` ## 🧪 Testing & Qualitätssicherung ### Automatisierte Tests #### 1. Backend Tests (PHPUnit) ```php create(); $emotion = Emotion::factory()->create(); $task = Task::factory()->create(['emotion_id' => $emotion->id]); $file = UploadedFile::fake()->image('test-photo.jpg', 1200, 1600); $response = $this->postJson("/api/events/{$event->slug}/photos", [ 'photo' => $file, 'emotion_id' => $emotion->id, 'task_id' => $task->id, 'guest_name' => 'Test Gast' ]); $response->assertStatus(201) ->assertJsonStructure([ 'success', 'data' => [ 'id', 'file_path', 'thumbnail_path', 'guest_name', 'emotion', 'task' ] ]); // Dateien wurden erstellt Storage::disk('public')->assertExists('photos/' . $file->hashName()); Storage::disk('public')->assertExists('thumbnails/' . $file->hashName()); // Datenbank-Eintrag $this->assertDatabaseHas('photos', [ 'event_id' => $event->id, 'guest_name' => 'Test Gast', 'emotion_id' => $emotion->id ]); } } ``` #### 2. Frontend Tests (Jest + React Testing Library) ```javascript // src/__tests__/EmotionBubble.test.jsx import React from 'react' import { render, fireEvent, screen } from '@testing-library/react' import EmotionBubble from '../components/Emotions/EmotionBubble' const mockEmotion = { id: 1, name: 'Freude', icon: '😂', color: '#ffd93d' } describe('EmotionBubble', () => { test('renders emotion correctly', () => { render( ) expect(screen.getByText('😂')).toBeInTheDocument() expect(screen.getByText('Freude')).toBeInTheDocument() }) test('calls onClick when clicked', () => { const handleClick = jest.fn() render( ) fireEvent.click(screen.getByRole('button')) expect(handleClick).toHaveBeenCalledWith(1) }) test('applies correct styling when active', () => { render( ) const bubble = screen.getByRole('button') expect(bubble).toHaveClass('ring-4') }) }) ``` ### Performance-Monitoring #### 1. Laravel Telescope (Development) ```bash composer require laravel/telescope --dev php artisan telescope:install php artisan migrate ``` #### 2. Frontend Performance (Web Vitals) ```javascript // src/utils/analytics.js import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals' function sendToAnalytics(metric) { // Production Analytics Service if (process.env.NODE_ENV === 'production') { console.log('Web Vital:', metric) // Beispiel: Sende zu Google Analytics // gtag('event', 'web_vitals', { // event_category: 'Web Vitals', // event_action: metric.name, // event_value: Math.round(metric.value), // non_interaction: true, // }) } } export function initializeWebVitals() { getCLS(sendToAnalytics) getFID(sendToAnalytics) getFCP(sendToAnalytics) getLCP(sendToAnalytics) getTTFB(sendToAnalytics) } ``` ## 📊 Analytics & Monitoring ### Event-Tracking ```javascript // src/services/eventTracking.js class EventTracker { constructor(eventSlug) { this.eventSlug = eventSlug this.events = [] } track(action, data = {}) { const event = { timestamp: new Date().toISOString(), action, data, eventSlug: this.eventSlug, sessionId: this.getSessionId(), userAgent: navigator.userAgent } this.events.push(event) this.sendToServer(event) } // Wichtige Events trackPhotoCapture(emotionId, taskId) { this.track('photo_capture', { emotionId, taskId }) } trackPhotoUpload(photoId, success) { this.track('photo_upload', { photoId, success }) } trackEmotionSelect(emotionId) { this.track('emotion_select', { emotionId }) } trackTaskSkip(taskId) { this.track('task_skip', { taskId }) } sendToServer(event) { // API-Endpoint für Analytics fetch(`/api/events/${this.eventSlug}/analytics`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event) ## Frontend PWA Status Update (Sep 2025) Implemented guest PWA per plan with React/Vite/Tailwind and PWA plugin. Highlights: - App Structure - Routes: /event/:slug/home, /event/:slug/camera, /event/:slug/gallery, /event/:slug/achievements, /legal/:slug. - Event home adopts the event landing romance gradient; dark-mode override added. - Offline fonts bundled (Inter 400/600, Playfair Display 400/700, Great Vibes 400). - UI/UX - Header settings as a sheet (language + theme). Dark/light/system theme with class-based Tailwind and no-flash boot. - Camera page with floating capture bar: compact emotion scroller, large shutter with animation, last-capture thumbnail (opens Gallery), task lock badge. - Gallery: grid, modal (Dialog), likes, skeleton placeholders, emotion filter and deep link. - Achievements page (new): tabs People/Groups/Live. People aggregates claimed tasks by guest; Live shows recent task-claimed photos. i18n included. - Navigation: replaced "Camera" tab with "Achievements" (trophy). Camera reachable via Home CTA. - Data & APIs (current usage) - Events: GET /api/events/{slug} (returns localized fields and palette when available). - Emotions: GET /api/events/{slug}/emotions. - Tasks: GET /api/events/{slug}/tasks/random/{emotion}. - Photos: GET /api/events/{slug}/photos, POST /api/events/{slug}/photos (compressed < 1.5MB), POST /api/events/{slug}/photos/{photo}/like. - Legal: GET /api/legal/{slug} (markdown rendered on client). - PWA & Offline - Compression to < 1.5MB before upload, EXIF stripped; offline queue with retry on "online"; toasts for success/queued/error. - Workbox via vite-plugin-pwa; further SW Background Sync planned. - shadcn-style Layer - Added Button, Card, Dialog, Sheet, Skeleton, Badge primitives for consistency. - Super Admin Login - Switched to classic Laravel session login at /super-admin/login; Filament panel guarded by middleware. - QR & Landings - Root landing with QR scanner (zxing) + manual code entry; event landing redesigned (typography + romance gradient). - Demo Data - Seeder DemoAchievementsSeeder creates demo photos with ask_id for the Achievements page. ### Gamification Direction (Short-Event) - Focus on solved tasks: photo claims a task (client), future: peer votes to verify. - Achievements ideas saved: docs/achievements/ideas.md. - Planned endpoint: POST /api/events/{slug}/photos/{photo}/task-vote { verdict: 'pass'|'fail', guest_name } ? returns { pass, fail, verified }. ### Open Items / Next - Task verification voting (backend + modal UI) and badges (First/Finisher/Sampler/Assist etc.). - SW Background Sync for uploads; install prompt + shortcuts. - Admin: palette editor; (optional) groups/teams for Achievements. - Accessibility polish, reduced-motion paths.