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

3741 lines
125 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 🏗️ **Multi-Tenant System-Architektur**
Note: Event Type-aware UI
- Theme colors can come from `event_types.settings.palette` to skin headers/buttons per type.
- Emotion picker fetches type-filtered emotions via `/api/events/:slug/emotions`.
- Random task generator and gallery filters respect event type automatically via API.
```
┌─────────────────────────────────────────────────────────────────────────┐
│ SYSTEM OVERVIEW (Multi-Tenant) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 🔧 SUPER ADMIN 👰🤵 BRAUTPAARE 📱 GÄSTE PWA │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ System Admin │ │ Customer Admin │ │ React PWA │ │
│ │ Panel │◄──►│ Panel │◄──►│ Event Photos │ │
│ │ │ │ │ │ │ │
│ │ • User Mgmt │ │ • Event Setup │ │ • Take Photos │ │
│ │ • System Config │ │ • Photo Review │ │ • Upload Share │ │
│ │ • Billing │ │ • Gallery Build │ │ • Live Feed │ │
│ │ • Analytics │ │ • Export │ │ • Like/Comment │ │
│ │ • Monitoring │ │ │ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │ │ │ │
│ └───────────────────────┼───────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────────┐│
│ │ LARAVEL BACKEND API (Multi-Tenant) ││
│ │ • Tenant Isolation • User Management • Billing System ││
│ │ • System Monitoring • Global Analytics • Platform Administration ││
│ └─────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────┘
```
## 👑 **Super Admin System (Platform Management)**
### **Multi-Tenant Database Schema:**
```sql
-- Tenant/Customer Management
CREATE TABLE tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL, -- "Familie Müller"
slug VARCHAR(255) UNIQUE NOT NULL, -- "familie-mueller"
domain VARCHAR(255) UNIQUE, -- Optional: custom domain
-- Contact Information
contact_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
contact_phone VARCHAR(255),
-- Event-basierte Monetarisierung (keine Subscriptions)
event_credits_balance INTEGER DEFAULT 1,
free_event_granted_at TIMESTAMP,
-- Limits & Quotas
max_photos_per_event INTEGER DEFAULT 500,
max_storage_mb INTEGER DEFAULT 1024, -- 1GB
-- Feature Flags
features JSON, -- {"custom_branding": true, "api_access": false}
-- Metadata
last_activity_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Erweiterte User-Tabelle für Multi-Tenancy
ALTER TABLE users ADD COLUMN tenant_id INTEGER;
ALTER TABLE users ADD COLUMN role ENUM('super_admin', 'tenant_admin', 'tenant_user') DEFAULT 'tenant_user';
ALTER TABLE users ADD CONSTRAINT fk_users_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
-- Events gehören zu Tenants
ALTER TABLE events ADD COLUMN tenant_id INTEGER NOT NULL;
ALTER TABLE events ADD CONSTRAINT fk_events_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
-- System-weite Konfiguration
CREATE TABLE system_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key VARCHAR(255) UNIQUE NOT NULL,
value TEXT,
description TEXT,
is_public BOOLEAN DEFAULT 0, -- Für öffentliche API settings
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Platform Analytics
CREATE TABLE platform_analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER,
metric_name VARCHAR(100) NOT NULL, -- 'photos_uploaded', 'events_created'
metric_value INTEGER NOT NULL,
metric_date DATE NOT NULL,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
INDEX idx_analytics_date_metric (metric_date, metric_name),
INDEX idx_analytics_tenant (tenant_id, metric_date)
);
-- Event Purchases (one-time) und Guthaben-Ledger
CREATE TABLE event_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
events_purchased INTEGER NOT NULL DEFAULT 1,
amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR',
provider ENUM('app_store', 'play_store', 'stripe', 'paypal') NOT NULL,
external_receipt_id VARCHAR(255),
status ENUM('pending', 'paid', 'failed', 'refunded', 'cancelled') DEFAULT 'pending',
purchased_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
INDEX idx_event_purchases_tenant (tenant_id, purchased_at)
);
CREATE TABLE event_credits_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
delta INTEGER NOT NULL,
reason ENUM('initial_free','purchase','manual_adjustment','event_created','refund') NOT NULL,
related_purchase_id INTEGER,
note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (related_purchase_id) REFERENCES event_purchases(id),
INDEX idx_ledger_tenant (tenant_id, created_at)
);
```
## 🛠️ **Super Admin Panel (Separate Filament Panel)**
Note (Filament 4):
- Forms/Infolists components live under `Filament\Schema\Components` (updated below).
- Table row actions use `recordActions()`; actions are unified under `Filament\Actions`.
- Keep `Tables\Columns`/`Tables\Filters` as-is; columns/filters remain in Tables.
### **Super Admin Panel Provider:**
```php
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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:**
```php
Event model additions (event type):
```php
// In App\Models\Event
protected $fillable = ["name","date","slug","description","settings","event_type_id","is_active"];
public function type(){ return $this->belongsTo(\App\Models\EventType::class, "event_type_id"); }
```
<?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 (
<QueryClientProvider client={queryClient}>
<AppProvider>
<Router>
<Routes>
{/* Event Selection */}
<Route path="/" element={<EventLanding />} />
{/* Main App (requires event context) */}
<Route path="/event/:eventSlug/*" element={
<EventProvider>
<Layout>
<Routes>
<Route index element={<HomePage />} />
<Route path="camera" element={<CameraPage />} />
<Route path="camera/:emotionId" element={<CameraPage />} />
<Route path="gallery" element={<GalleryPage />} />
<Route path="gallery/:emotionId" element={<GalleryPage />} />
<Route path="*" element={<Navigate to="../" replace />} />
</Routes>
</Layout>
</EventProvider>
} />
</Routes>
</Router>
</AppProvider>
</QueryClientProvider>
)
}
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 (
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-white to-purple-50 flex flex-col">
{/* Minimalist Header */}
<header className="p-4 flex justify-between items-center">
<div className="text-sm text-gray-500">Event Date</div>
<div className="text-sm text-gray-500">Branding Logo</div>
</header>
{/* Main Content */}
<main className="flex-grow flex flex-col items-center justify-center px-4">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">Willkommen bei der Fotobox! 🎉</h1>
<p className="text-gray-600">Dein Schlüssel zu unvergesslichen Hochzeitsmomenten.</p>
</div>
{/* Central Card for Actions */}
<div className="bg-white rounded-xl shadow-lg p-6 w-full max-w-md">
{/* QR Code Scanner Emphasis */}
<div className="mb-6">
<div className="bg-gray-200 border-2 border-dashed rounded-xl w-32 h-32 mx-auto mb-4" />
<button
onClick={handleQRScan}
className="w-full py-3 px-4 bg-pink-500 text-white font-semibold rounded-lg shadow-md hover:bg-pink-600 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:ring-opacity-75 transition-all"
>
QR-Code scannen
</button>
</div>
{/* Manual Code Entry (Fallback) */}
<div className="text-center">
<button
onClick={handleManualEntry}
className="text-pink-500 hover:text-pink-700 font-medium"
>
Oder Code manuell eingeben
</button>
</div>
</div>
</main>
{/* Minimalist Footer */}
<footer className="p-4 text-center text-sm text-gray-500">
© {new Date().getFullYear()} Emotionen-Fotobox
</footer>
</div>
);
}
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 (
<div className="min-h-screen bg-gradient-to-b from-pink-100 to-purple-100 flex flex-col">
{/* Header */}
<header className="p-4 flex justify-between items-start">
<div className="text-2xl">❤️📸</div> {/* Heart with Camera Icon */}
<div className="text-lg font-sans text-gray-700">01.01.2025</div> {/* Event Date */}
</header>
{/* Main Content - Vertically Centered */}
<main className="flex-grow flex flex-col items-center justify-center px-4">
{/* Title Section */}
<div className="text-center mb-8">
<p className="text-gray-700 uppercase tracking-wider font-sans mb-1">HOCHZEIT VON</p>
<h1 className="text-4xl font-bold italic font-handwriting text-gray-800 mb-2">MARIE & LUKAS</h1>
<p className="text-gray-600 text-sm font-sans">Fangt die schönsten Momente ein!</p>
</div>
{/* Input Section */}
<div className="bg-white bg-opacity-70 backdrop-blur-sm rounded-xl shadow-lg p-6 w-full max-w-md">
<p className="text-center text-gray-700 mb-4 font-medium">Bereit für Emotionen?</p>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Dein Name (z.B. Anna)"
value={guestName}
onChange={(e) => 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
/>
<button
type="submit"
className="w-full py-3 px-4 bg-[#F85B73] text-white font-semibold rounded-lg shadow-md hover:bg-[#e05067] focus:outline-none focus:ring-2 focus:ring-pink-400 focus:ring-opacity-75 transition-all transform hover:scale-105 active:scale-95"
>
LETS GO! 🎉
</button>
</form>
</div>
</main>
{/* Footer */}
<footer className="p-6 text-center">
<div className="text-2xl mb-1">❤️</div>
<p className="text-gray-700 font-sans">Emotionen-Fotobox</p>
</footer>
</div>
);
}
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 (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4 shadow-xl">
<div className="mx-auto w-full max-w-md">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">{t("ui:settings")}</h3>
<button onClick={onClose} className="text-gray-500">✕</button>
</div>
<div className="space-y-4">
<div>
<div className="text-sm text-gray-500 mb-1">{t("ui:language")}</div>
<div className="flex gap-2">
<button onClick={() => onLangChange("de")} className={`px-3 py-1 rounded border ${currentLang==="de"?"bg-gray-900 text-white":""}`}>DE</button>
<button onClick={() => onLangChange("en")} className={`px-3 py-1 rounded border ${currentLang==="en"?"bg-gray-900 text-white":""}`}>EN</button>
</div>
</div>
<div>
<div className="text-sm text-gray-500 mb-1">{t("ui:legal")}</div>
<ul className="space-y-2">
<li><Link to="/legal/imprint" onClick={onClose} className="text-pink-600">Impressum</Link></li>
<li><Link to="/legal/privacy" onClick={onClose} className="text-pink-600">Datenschutzerklärung</Link></li>
<li><Link to="/legal/terms" onClick={onClose} className="text-pink-600">AGB</Link></li>
</ul>
</div>
</div>
</div>
</div>
</div>
)
}
```
**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 <div className="p-4">Loading…</div>
if (error) return <div className="p-4 text-red-600">Error loading content.</div>
return (
<div className="max-w-3xl mx-auto p-4 prose prose-pink">
<h1 className="mb-2">{data.title}</h1>
<div className="text-sm text-gray-500 mb-6">
{data.updated_at ? new Date(data.updated_at).toLocaleDateString(lang) : null}
</div>
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>
{data.body_markdown || ""}
</ReactMarkdown>
</div>
)
}
```
**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 (
<header className="sticky top-0 z-40 bg-white/70 backdrop-blur border-b">
<div className="max-w-4xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="font-semibold">Fotobox</div>
<button
aria-label="Settings"
onClick={() => setOpen(true)}
className="p-2 rounded-full hover:bg-gray-100"
>
<Settings className="w-5 h-5 text-gray-700" />
</button>
</div>
<SettingsSheet
open={open}
onClose={() => setOpen(false)}
onLangChange={onLangChange}
currentLang={i18n.language || "de"}
/>
</header>
)
}
```
## 🌍 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
<Route path="/legal/:slug" element={<LegalPage />} />
// 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 <LoadingSpinner message="Emotionen werden geladen..." />
}
return (
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-white to-purple-50 bg-[url('/images/wedding-lights-background.svg')] bg-cover bg-center bg-no-repeat">
{/* Header */}
<div className="px-4 py-6 text-center">
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Willkommen zur Fotobox! 📸
</h1>
{guestName && (
<p className="text-sm text-pink-600 mt-2">
Hallo, {guestName}! 👋
</p>
)}
</div>
{/* Emotions-Picker */}
<div className="px-4 mb-8">
<div className="flex overflow-x-auto pb-4 gap-4 px-2">
{emotions?.map((emotion) => (
<EmotionBubble
key={emotion.id}
emotion={emotion}
onClick={() => handleEmotionClick(emotion.id)}
className="flex-shrink-0"
/>
))}
</div>
</div>
{/* "Alle anzeigen" Button */}
<div className="px-4 mb-8 text-center">
<button
onClick={() => navigate(`/event/${eventSlug}/gallery`)}
className="text-pink-600 text-sm font-medium hover:text-pink-700"
>
Alle anzeigen →
</button>
</div>
{/* Recent Photos Feed */}
<div className="px-4">
<h2 className="text-xl font-semibold text-gray-700 mb-4">
Neueste Momente
</h2>
{photosLoading ? (
<div className="grid grid-cols-2 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
) : (
<PhotoGrid
photos={recentPhotos?.data || []}
columns={2}
showEmotion={true}
/>
)}
</div>
</div>
)
}
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 (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pink-600 mx-auto mb-4"></div>
<p>Aufgabe wird geladen...</p>
</div>
</div>
)
}
return (
<div className="relative h-screen bg-black overflow-hidden">
{!capturedPhoto ? (
<>
{/* Live-Kamera */}
<CameraCapture
ref={cameraRef}
onCapture={handleCapture}
/>
{/* Task-Overlay */}
<TaskOverlay
task={currentTask}
onNewTask={handleNewTask}
onBack={() => navigate(`/event/${eventSlug}`)}
/>
</>
) : (
/* Foto-Vorschau */
<PhotoPreview
photo={capturedPhoto}
task={currentTask}
onRetake={handleRetake}
onUpload={handleUpload}
isUploading={uploadMutation.isLoading}
/>
)}
</div>
)
}
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 (
<motion.div
whileTap={{ scale: 0.95 }}
whileHover={{ scale: 1.05 }}
className={`
relative min-w-[100px] h-[100px] rounded-full
flex flex-col items-center justify-center
cursor-pointer transition-all duration-300
shadow-lg hover:shadow-xl
${isActive ? 'ring-4 ring-offset-2' : ''}
${className}
`}
style={{
backgroundColor: emotion.color,
ringColor: emotion.color
}}
onClick={() => onClick(emotion.id)}
>
{/* Emoji Icon */}
<span className="text-2xl mb-1">{emotion.icon}</span>
{/* Name */}
<span className="text-xs font-medium text-white text-center px-2">
{emotion.name}
</span>
{/* Pulse Animation für aktive Emotion */}
{isActive && (
<motion.div
className="absolute inset-0 rounded-full border-2 border-white"
animate={{ scale: [1, 1.2, 1], opacity: [0.7, 0, 0.7] }}
transition={{ repeat: Infinity, duration: 2 }}
/>
)}
</motion.div>
)
}
```
### 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 (
<div className="relative w-full h-full">
{/* Video Stream */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
{/* Hidden Canvas für Capture */}
<canvas ref={canvasRef} className="hidden" />
{/* Camera Controls */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6">
<div className="flex items-center justify-between">
{/* Flash Toggle */}
<button
onClick={toggleFlash}
disabled={!hasFlash}
className={`
p-3 rounded-full transition-all
${hasFlash
? (isFlashOn ? 'bg-yellow-500' : 'bg-white/20')
: 'bg-gray-500/50 cursor-not-allowed'
}
`}
>
{isFlashOn ? (
<Zap className="w-6 h-6 text-white" />
) : (
<ZapOff className="w-6 h-6 text-white" />
)}
</button>
{/* Capture Button */}
<button
onClick={capturePhoto}
className="w-20 h-20 bg-white rounded-full border-4 border-pink-500
flex items-center justify-center transition-all
hover:scale-105 active:scale-95"
>
<div className="w-16 h-16 bg-pink-500 rounded-full flex items-center justify-center">
<Camera className="w-8 h-8 text-white" />
</div>
</button>
{/* Camera Switch */}
<button
onClick={switchCamera}
className="p-3 rounded-full bg-white/20 transition-all hover:bg-white/30"
>
<RotateCcw className="w-6 h-6 text-white" />
</button>
</div>
</div>
</div>
)
})
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 (
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
className="absolute top-0 left-0 right-0 z-10"
>
{/* Background Gradient */}
<div className="bg-gradient-to-b from-black/70 via-black/50 to-transparent p-6">
{/* Header Controls */}
<div className="flex items-center justify-between mb-4">
<button
onClick={onBack}
className="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-all"
>
<ArrowLeft className="w-6 h-6 text-white" />
</button>
<div className="flex items-center gap-2">
<button
onClick={onShowHelp}
className="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-all"
>
<HelpCircle className="w-6 h-6 text-white" />
</button>
<button
onClick={onNewTask}
className="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-all"
>
<Shuffle className="w-6 h-6 text-white" />
</button>
</div>
</div>
{/* Task Content */}
<div className="text-center">
{/* Emotion Badge */}
<div
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-4"
style={{ backgroundColor: task.emotion?.color || '#ff6b9d' }}
>
<span className="text-xl">{task.emotion?.icon}</span>
<span className="text-white font-medium">{task.emotion?.name}</span>
</div>
{/* Task Title */}
<h2 className="text-2xl font-bold text-white mb-3 leading-tight">
{task.title}
</h2>
{/* Task Description */}
<p className="text-lg text-white/90 leading-relaxed max-w-sm mx-auto">
{task.description}
</p>
{/* Difficulty Indicator */}
<div className="flex items-center justify-center gap-1 mt-4">
{[...Array(3)].map((_, i) => (
<div
key={i}
className={`
w-2 h-2 rounded-full
${i < getDifficultyLevel(task.difficulty)
? 'bg-yellow-400'
: 'bg-white/30'
}
`}
/>
))}
<span className="text-white/70 text-sm ml-2 capitalize">
{task.difficulty}
</span>
</div>
{/* Example Hint */}
{task.example_text && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
className="mt-4 p-3 bg-white/10 rounded-lg"
>
<p className="text-sm text-white/80 italic">
💡 {task.example_text}
</p>
</motion.div>
)}
</div>
</div>
</motion.div>
)
}
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(
<EmotionBubble emotion={mockEmotion} onClick={jest.fn()} />
)
expect(screen.getByText('😂')).toBeInTheDocument()
expect(screen.getByText('Freude')).toBeInTheDocument()
})
test('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(
<EmotionBubble emotion={mockEmotion} onClick={handleClick} />
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledWith(1)
})
test('applies correct styling when active', () => {
render(
<EmotionBubble
emotion={mockEmotion}
onClick={jest.fn()}
isActive={true}
/>
)
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 <20> 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 <20>solved tasks<6B>: 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.