Files
fotospiel-app/fotospiel_prp.md
2025-09-08 14:03:43 +02:00

130 KiB
Raw Blame History

## 🏗️ 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:

-- 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
// app/Providers/Filament/SuperAdminPanelProvider.php

namespace App\Providers\Filament;

use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Pages;

class SuperAdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->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
// app/Filament/SuperAdmin/Resources/TenantResource.php

namespace App\Filament\SuperAdmin\Resources;

use App\Models\Tenant;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schema as Schema;
use Filament\Actions;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class TenantResource extends Resource
{
    protected static ?string $model = Tenant::class;
    protected static ?string $navigationIcon = 'heroicon-o-building-office';
    protected static ?string $navigationGroup = 'Customer Management';
    protected static ?string $label = 'Kunden';

    public static function form(Form $form): Form
    {
        return $form->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
// app/Filament/SuperAdmin/Resources/SubscriptionPlanResource.php

namespace App\Filament\SuperAdmin\Resources;

use App\Models\SubscriptionPlan;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\\Schema as Schema;
use Filament\\Actions;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class SubscriptionPlanResource extends Resource
{
    protected static ?string $model = SubscriptionPlan::class;
    protected static ?string $navigationIcon = 'heroicon-o-credit-card';
    protected static ?string $navigationGroup = 'Billing & Subscriptions';
    protected static ?string $label = 'Abo-Pläne';

    public static function form(Form $form): Form
    {
        return $form->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
// app/Filament/SuperAdmin/Resources/SystemSettingResource.php

namespace App\Filament\SuperAdmin\Resources;

use App\Models\SystemSetting;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\\Schema as Schema;
use Filament\\Actions;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class SystemSettingResource extends Resource
{
    protected static ?string $model = SystemSetting::class;
    protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
    protected static ?string $navigationGroup = 'Platform Configuration';
    protected static ?string $label = 'System-Einstellungen';

    public static function form(Form $form): Form
    {
        return $form->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
// app/Filament/SuperAdmin/Widgets/PlatformStatsWidget.php

namespace App\Filament\SuperAdmin\Widgets;

use App\Models\{Tenant, Event, Photo, User};
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class PlatformStatsWidget extends BaseWidget
{
    protected function getStats(): array
    {
        return [
            Stat::make('Kunden mit Events', Tenant::whereHas('events')->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
// app/Filament/SuperAdmin/Widgets/RevenueChartWidget.php

namespace App\Filament\SuperAdmin\Widgets;

use App\Models\EventPurchase;
use Filament\Widgets\ChartWidget;

class RevenueChartWidget extends ChartWidget
{
    protected static ?string $heading = 'Umsatz (Letzte 12 Monate)';

    protected function getData(): array
    {
        $data = EventPurchase::where('status', 'paid')
            ->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
// app/Http/Middleware/SuperAdminOnly.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class SuperAdminOnly
{
    public function handle(Request $request, Closure $next)
    {
        if (!auth()->check() || auth()->user()->role !== 'super_admin') {
            abort(403, 'Access denied. Super Admin privileges required.');
        }

        return $next($request);
    }
}

🏢 Tenant Scoping Middleware

<?php
// app/Http/Middleware/TenantScope.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\Tenant;

class TenantScope
{
    public function handle(Request $request, Closure $next)
    {
        if (auth()->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:


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"); }
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Event extends Model { protected $fillable = ['name', 'date', 'slug', 'description', 'settings', 'is_active']; protected $casts = [ 'date' => 'date', 'settings' => 'array', 'is_active' => 'boolean' ]; public function photos() { return $this->hasMany(Photo::class)->latest(); } public function getRouteKeyName() { return 'slug'; } } ``` **Photo Model:** ```php <?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Photo extends Model { protected $fillable = [ 'event_id', 'emotion_id', 'task_id', 'guest_name', 'file_path', 'thumbnail_path', 'metadata' ]; protected $casts = [ 'metadata' => '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 <?php use App\Http\Controllers\Api\{ EventController, EmotionController, TaskController, PhotoController, LiveFeedController }; // Event-spezifische Routes (über Slug-Parameter) Route::prefix('events/{event:slug}')->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 <?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\{Event, Photo, PhotoLike}; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Intervention\Image\Facades\Image; class PhotoController extends Controller { public function store(Request $request, Event $event) { $request->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 */}
QR-Code scannen
{/* Manual Code Entry (Fallback) */}
Oder Code manuell eingeben
{/* Minimalist Footer */} © {new Date().getFullYear()} Emotionen-Fotobox
); } 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 /> LET’S GO! 🎉
{/* Footer */}
❤️

Emotionen-Fotobox

); } 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")}
onLangChange("de")} className={`px-3 py-1 rounded border ${currentLang==="de"?"bg-gray-900 text-white":""}`}>DE onLangChange("en")} className={`px-3 py-1 rounded border ${currentLang==="en"?"bg-gray-900 text-white":""}`}>EN
{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(true)} className="p-2 rounded-full hover:bg-gray-100" >
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 <?php use Spatie\Translatable\HasTranslations; class Task extends Model { use HasTranslations; public $translatable = ['title','description','example_text']; } class Emotion extends Model { use HasTranslations; public $translatable = ['name','description']; } class EventType extends Model { use HasTranslations; public $translatable = ['name']; } class Event extends Model { use HasTranslations; public $translatable = ['name','description']; } ``` ## 📜 Legal Content Management (Impressum/Privacy/AGB) ### Schema ```sql CREATE TABLE legal_pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id INTEGER NULL, -- null = platform default; future: tenant override slug VARCHAR(32) NOT NULL, -- 'imprint' | 'privacy' | 'terms' | 'custom' title TEXT NOT NULL, -- JSON translatable: {"de":"Impressum","en":"Imprint"} body_markdown TEXT NOT NULL, -- JSON translatable; Markdown content locale_fallback VARCHAR(5) DEFAULT 'de', version INTEGER DEFAULT 1, effective_from TIMESTAMP NULL, is_published BOOLEAN DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(slug, tenant_id, version), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ); ``` ### Filament v4 — LegalPageResource (Super Admin) ```php <?php // app/Filament/SuperAdmin/Resources/LegalPageResource.php namespace App\Filament\SuperAdmin\Resources; use App\Models\LegalPage; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; use Filament\Forms\Form; use Filament\Schema as Schema; use Filament\Actions; class LegalPageResource extends Resource { protected static ?string $model = LegalPage::class; protected static ?string $navigationIcon = 'heroicon-o-document-text'; protected static ?string $navigationGroup = 'Legal & Compliance'; protected static ?string $label = 'Legal Pages'; public static function form(Form $form): Form { return $form->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 <?php // app/Http/Controllers/Api/LegalController.php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\LegalPage; use Illuminate\Http\Request; class LegalController extends Controller { public function show(Request $request, string $slug) { $locale = app()->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 */}
navigate(`/event/${eventSlug}/gallery`)} className="text-pink-600 text-sm font-medium hover:text-pink-700" > Alle anzeigen →
{/* 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 <?php // app/Filament/Tenant/Resources/TaskResource.php namespace App\Filament\Tenant\Resources; use App\Models\{Task, Emotion, Event, EventType}; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; use Filament\Actions; use Filament\Forms\Form; use Filament\Schema as Schema; use Illuminate\Support\Facades\DB; use League\Csv\Reader; class TaskResource extends Resource { protected static ?string $model = Task::class; protected static ?string $navigationIcon = 'heroicon-o-queue-list'; protected static ?string $navigationGroup = 'Content'; protected static ?string $label = 'Aufgaben'; public static function form(Form $form): Form { return $form->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 <?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\{Event, Emotion}; class EmotionController extends Controller { public function index(Event $event) { $typeId = $event->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 <?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\{Event, Task}; use Illuminate\Http\Request; class TaskController extends Controller { public function getByEmotion(Request $request, Event $event, $emotionId) { $tenantId = optional($request->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 <?php ### Super Admin — Emotion Resource (Event Type Filter) ```php <?php // app/Filament/SuperAdmin/Resources/EmotionResource.php namespace App\\Filament\\SuperAdmin\\Resources; use App\\Models\\{Emotion, EventType}; use Filament\\Resources\\Resource; use Filament\\Tables; use Filament\\Tables\\Table; use Filament\\Actions; use Filament\\Forms\\Form; use Filament\\Schema as Schema; class EmotionResource extends Resource { protected static ?string $model = Emotion::class; protected static ?string $navigationIcon = "heroicon-o-face-smile"; protected static ?string $navigationGroup = "Content Management"; protected static ?string $label = "Emotions"; public static function table(Table $table): Table { return $table ->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 <?php // app/Filament/SuperAdmin/Resources/TaskLibraryResource.php namespace App\Filament\SuperAdmin\Resources; use App\Models\{Task, Emotion, Tenant, Event, EventType}; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; use Filament\Actions; use Filament\Forms\Form; use Filament\Schema as Schema; use League\Csv\Reader; use Illuminate\Support\Facades\DB; class TaskLibraryResource extends Resource { protected static ?string $model = Task::class; protected static ?string $navigationIcon = 'heroicon-o-sparkles'; protected static ?string $navigationGroup = 'Content Management'; protected static ?string $label = 'Task Library'; public static function table(Table $table): Table { return $table ->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 <?php use Illuminate\Database\Seeder; use App\Models\Emotion; class EmotionsSeeder extends Seeder { public function run() { $emotions = [ [ 'name' => '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 <?php use Illuminate\Database\Seeder; use App\Models\{Emotion, Task}; class TasksSeeder extends Seeder { public function run() { $tasks = [ // Liebe-Tasks [ 'emotion' => '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 <?php // tests/Feature/PhotoUploadTest.php namespace Tests\Feature; use Tests\TestCase; use App\Models\{Event, Emotion, Task}; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; class PhotoUploadTest extends TestCase { public function test_photo_upload_successful() { Storage::fake('public'); $event = Event::factory()->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.