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