130 KiB
130 KiB
## ðŸ—ï¸ Multi-Tenant System-Architektur
Note: Event Type-aware UI
- Theme colors can come from
event_types.settings.paletteto skin headers/buttons per type. - Emotion picker fetches type-filtered emotions via
/api/events/:slug/emotions. - Random task generator and gallery filters respect event type automatically via API.
┌─────────────────────────────────────────────────────────────────────────â”
│ SYSTEM OVERVIEW (Multi-Tenant) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 🔧 SUPER ADMIN 👰🤵 BRAUTPAARE 📱 GÄSTE PWA │
│ ┌─────────────────┠┌─────────────────┠┌─────────────────┠│
│ │ System Admin │ │ Customer Admin │ │ React PWA │ │
│ │ Panel │◄──►│ Panel │◄──►│ Event Photos │ │
│ │ │ │ │ │ │ │
│ │ • User Mgmt │ │ • Event Setup │ │ • Take Photos │ │
│ │ • System Config │ │ • Photo Review │ │ • Upload Share │ │
│ │ • Billing │ │ • Gallery Build │ │ • Live Feed │ │
│ │ • Analytics │ │ • Export │ │ • Like/Comment │ │
│ │ • Monitoring │ │ │ │ │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │ │ │ │
│ └───────────────────────┼───────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────────â”│
│ │ LARAVEL BACKEND API (Multi-Tenant) ││
│ │ • Tenant Isolation • User Management • Billing System ││
│ │ • System Monitoring • Global Analytics • Platform Administration ││
│ └─────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────┘
👑 Super Admin System (Platform Management)
Multi-Tenant Database Schema:
-- Tenant/Customer Management
CREATE TABLE tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL, -- "Familie Müller"
slug VARCHAR(255) UNIQUE NOT NULL, -- "familie-mueller"
domain VARCHAR(255) UNIQUE, -- Optional: custom domain
-- Contact Information
contact_name VARCHAR(255) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
contact_phone VARCHAR(255),
-- Event-basierte Monetarisierung (keine Subscriptions)
event_credits_balance INTEGER DEFAULT 1,
free_event_granted_at TIMESTAMP,
-- Limits & Quotas
max_photos_per_event INTEGER DEFAULT 500,
max_storage_mb INTEGER DEFAULT 1024, -- 1GB
-- Feature Flags
features JSON, -- {"custom_branding": true, "api_access": false}
-- Metadata
last_activity_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Erweiterte User-Tabelle für Multi-Tenancy
ALTER TABLE users ADD COLUMN tenant_id INTEGER;
ALTER TABLE users ADD COLUMN role ENUM('super_admin', 'tenant_admin', 'tenant_user') DEFAULT 'tenant_user';
ALTER TABLE users ADD CONSTRAINT fk_users_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
-- Events gehören zu Tenants
ALTER TABLE events ADD COLUMN tenant_id INTEGER NOT NULL;
ALTER TABLE events ADD CONSTRAINT fk_events_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
-- System-weite Konfiguration
CREATE TABLE system_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key VARCHAR(255) UNIQUE NOT NULL,
value TEXT,
description TEXT,
is_public BOOLEAN DEFAULT 0, -- Für öffentliche API settings
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Platform Analytics
CREATE TABLE platform_analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER,
metric_name VARCHAR(100) NOT NULL, -- 'photos_uploaded', 'events_created'
metric_value INTEGER NOT NULL,
metric_date DATE NOT NULL,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
INDEX idx_analytics_date_metric (metric_date, metric_name),
INDEX idx_analytics_tenant (tenant_id, metric_date)
);
-- Event Purchases (one-time) und Guthaben-Ledger
CREATE TABLE event_purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
events_purchased INTEGER NOT NULL DEFAULT 1,
amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR',
provider ENUM('app_store', 'play_store', 'stripe', 'paypal') NOT NULL,
external_receipt_id VARCHAR(255),
status ENUM('pending', 'paid', 'failed', 'refunded', 'cancelled') DEFAULT 'pending',
purchased_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
INDEX idx_event_purchases_tenant (tenant_id, purchased_at)
);
CREATE TABLE event_credits_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
delta INTEGER NOT NULL,
reason ENUM('initial_free','purchase','manual_adjustment','event_created','refund') NOT NULL,
related_purchase_id INTEGER,
note TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (related_purchase_id) REFERENCES event_purchases(id),
INDEX idx_ledger_tenant (tenant_id, created_at)
);
ðŸ› ï¸ Super Admin Panel (Separate Filament Panel)
Note (Filament 4):
- Forms/Infolists components live under
Filament\Schema\Components(updated below). - Table row actions use
recordActions(); actions are unified underFilament\Actions. - Keep
Tables\Columns/Tables\Filtersas-is; columns/filters remain in Tables.
Super Admin Panel Provider:
<?php
// app/Providers/Filament/SuperAdminPanelProvider.php
namespace App\Providers\Filament;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Pages;
class SuperAdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('super-admin')
->path('super-admin')
->login()
->colors([
'primary' => Color::Indigo,
'secondary' => Color::Slate,
])
->brandName('Fotobox Platform Admin')
->brandLogo(asset('images/platform-logo.png'))
->discoverResources(in: app_path('Filament/SuperAdmin/Resources'), for: 'App\\Filament\\SuperAdmin\\Resources')
->discoverPages(in: app_path('Filament/SuperAdmin/Pages'), for: 'App\\Filament\\SuperAdmin\\Pages')
->pages([
Pages\Dashboard::class,
])
->widgets([
\App\Filament\SuperAdmin\Widgets\PlatformStatsWidget::class,
\App\Filament\SuperAdmin\Widgets\RevenueChartWidget::class,
\App\Filament\SuperAdmin\Widgets\TenantActivityWidget::class,
])
->navigationGroups([
'Customer Management',
'Platform Configuration',
'Legal & Compliance',
'Billing & Events',
'System Monitoring',
'Content Management'
])
->authMiddleware([
\App\Http\Middleware\SuperAdminOnly::class,
]);
}
}
👥 Tenant Management Resource
<?php
// app/Filament/SuperAdmin/Resources/TenantResource.php
namespace App\Filament\SuperAdmin\Resources;
use App\Models\Tenant;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schema as Schema;
use Filament\Actions;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class TenantResource extends Resource
{
protected static ?string $model = Tenant::class;
protected static ?string $navigationIcon = 'heroicon-o-building-office';
protected static ?string $navigationGroup = 'Customer Management';
protected static ?string $label = 'Kunden';
public static function form(Form $form): Form
{
return $form->schema([
Schema\\Components\\Section::make('Kunden-Details')
->schema([
Schema\\Components\\TextInput::make('name')
->label('Kundenname')
->required()
->maxLength(255),
Schema\\Components\\TextInput::make('slug')
->required()
->unique(Tenant::class, 'slug', ignoreRecord: true)
->helperText('Eindeutige URL für den Kunden'),
Schema\\Components\\TextInput::make('domain')
->label('Custom Domain (optional)')
->url()
->unique(Tenant::class, 'domain', ignoreRecord: true),
Schema\\Components\\TextInput::make('contact_name')
->label('Ansprechpartner')
->required(),
Schema\\Components\\TextInput::make('contact_email')
->label('E-Mail')
->email()
->required(),
Schema\\Components\\TextInput::make('contact_phone')
->label('Telefon')
->tel(),
])
->columns(2),
Schema\\Components\\Section::make('Subscription Management')
->schema([
Schema\\Components\\Select::make('plan_type')
->label('Plan')
->options([
'free' => 'Free (Testversion)',
'basic' => 'Basic (29€/Monat)',
'premium' => 'Premium (49€/Monat)',
'enterprise' => 'Enterprise (Custom)'
])
->required()
->live(),
Schema\\Components\\Select::make('subscription_status')
->options([
'trial' => 'Trial',
'active' => 'Aktiv',
'suspended' => 'Suspendiert',
'cancelled' => 'Gekündigt'
])
->required(),
Schema\\Components\\DateTimePicker::make('subscription_starts_at')
->label('Abo Start'),
Schema\\Components\\DateTimePicker::make('subscription_ends_at')
->label('Abo Ende'),
Schema\\Components\\DateTimePicker::make('trial_ends_at')
->label('Trial Ende')
->visible(fn ($get) => $get('subscription_status') === 'trial'),
])
->columns(2),
Schema\\Components\\Section::make('Limits & Kontingente')
->schema([
Schema\\Components\\TextInput::make('max_events')
->label('Max. Events')
->numeric()
->default(1),
Schema\\Components\\TextInput::make('max_photos_per_event')
->label('Max. Fotos pro Event')
->numeric()
->default(500),
Schema\\Components\\TextInput::make('max_storage_mb')
->label('Max. Speicher (MB)')
->numeric()
->default(1024),
])
->columns(3),
Schema\\Components\\Section::make('Feature Flags')
->schema([
Schema\\Components\\KeyValue::make('features')
->label('Aktivierte Features')
->keyLabel('Feature')
->valueLabel('Aktiviert')
->default([
'custom_branding' => false,
'api_access' => false,
'advanced_analytics' => false,
'white_label' => false
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('contact_email')
->label('Kontakt')
->searchable(),
Tables\Columns\TextColumn::make('event_credits_balance')
->label('Event-Guthaben')
->badge()
Tables\Columns\TextColumn::make('events_count')
->label('Events')
->counts('events'),
Tables\Columns\TextColumn::make('total_photos_count')
->label('Gesamt Fotos')
->getStateUsing(fn ($record) =>
$record->events()->withCount('photos')->get()->sum('photos_count')
),
Tables\Columns\TextColumn::make('free_event_granted_at')
->label('Kostenloses Event am')
->date('d.m.Y')
->sortable(),
Tables\Columns\TextColumn::make('last_activity_at')
->label('Letzte Aktivität')
->since()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('plan_type')
->options([
'free' => 'Free',
'basic' => 'Basic',
'premium' => 'Premium',
'enterprise' => 'Enterprise'
]),
Tables\Filters\SelectFilter::make('subscription_status')
->options([
'trial' => 'Trial',
'active' => 'Aktiv',
'suspended' => 'Suspendiert',
'cancelled' => 'Gekündigt'
]),
Tables\Filters\Filter::make('expiring_soon')
->label('Läuft bald ab')
->query(fn ($query) => $query->where('subscription_ends_at', '<=', now()->addDays(30))),
])
->recordActions([
Actions\\ViewAction::make(),
Actions\\EditAction::make(),
Actions\\Action::make('login_as')
->label('Als Kunde anmelden')
->icon('heroicon-o-arrow-right-on-rectangle')
->url(fn ($record) => "/admin/impersonate/{$record->id}")
->openUrlInNewTab(),
])
->bulkActions([
Actions\\BulkAction::make('send_notification')
->label('Benachrichtigung senden')
->icon('heroicon-o-envelope')
->form([
Schema\\Components\\TextInput::make('subject')
->label('Betreff')
->required(),
Schema\\Components\\Textarea::make('message')
->label('Nachricht')
->required()
->rows(4),
])
->action(function (array $data, Collection $records) {
// Send bulk notification to selected customers
foreach ($records as $tenant) {
// Send email notification
}
}),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenants::route('/'),
'create' => Pages\CreateTenant::route('/create'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
'view' => Pages\ViewTenant::route('/{record}'),
];
}
}
💰 Subscription Plans Resource
<?php
// app/Filament/SuperAdmin/Resources/SubscriptionPlanResource.php
namespace App\Filament\SuperAdmin\Resources;
use App\Models\SubscriptionPlan;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\\Schema as Schema;
use Filament\\Actions;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class SubscriptionPlanResource extends Resource
{
protected static ?string $model = SubscriptionPlan::class;
protected static ?string $navigationIcon = 'heroicon-o-credit-card';
protected static ?string $navigationGroup = 'Billing & Subscriptions';
protected static ?string $label = 'Abo-Pläne';
public static function form(Form $form): Form
{
return $form->schema([
Schema\\Components\\Section::make('Plan Details')
->schema([
Schema\\Components\\TextInput::make('name')
->required()
->maxLength(100),
Schema\\Components\\TextInput::make('slug')
->required()
->unique(SubscriptionPlan::class, 'slug', ignoreRecord: true),
Schema\\Components\\TextInput::make('price_monthly')
->label('Monatspreis (€)')
->numeric()
->prefix('€'),
Schema\\Components\\TextInput::make('price_yearly')
->label('Jahrespreis (€)')
->numeric()
->prefix('€'),
Schema\\Components\\Toggle::make('is_active')
->label('Plan aktiv')
->default(true),
])
->columns(2),
Schema\\Components\\Section::make('Plan Limits')
->schema([
Schema\\Components\\TextInput::make('max_events')
->label('Max. Events')
->numeric()
->required(),
Schema\\Components\\TextInput::make('max_photos_per_event')
->label('Max. Fotos pro Event')
->numeric()
->required(),
Schema\\Components\\TextInput::make('max_storage_gb')
->label('Max. Speicher (GB)')
->numeric()
->required(),
])
->columns(3),
Schema\\Components\\Section::make('Features')
->schema([
Schema\\Components\\KeyValue::make('features')
->label('Plan Features')
->keyLabel('Feature')
->valueLabel('Enthalten')
->default([
'custom_branding' => false,
'api_access' => false,
'advanced_analytics' => false,
'priority_support' => false,
'white_label' => false,
'custom_domain' => false
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('price_monthly')
->label('Monatlich')
->money('EUR'),
Tables\Columns\TextColumn::make('price_yearly')
->label('Jährlich')
->money('EUR'),
Tables\Columns\TextColumn::make('max_events')
->label('Events'),
Tables\Columns\TextColumn::make('max_photos_per_event')
->label('Fotos/Event'),
Tables\Columns\TextColumn::make('max_storage_gb')
->label('Storage (GB)'),
Tables\Columns\TextColumn::make('subscribers_count')
->label('Kunden')
->getStateUsing(fn ($record) => $record->tenants()->count()),
Tables\Columns\IconColumn::make('is_active')
->boolean(),
])
->recordActions([
Actions\\EditAction::make(),
Actions\\Action::make('duplicate')
->label('Duplizieren')
->icon('heroicon-o-document-duplicate')
->action(function ($record) {
$newPlan = $record->replicate();
$newPlan->name = $record->name . ' (Kopie)';
$newPlan->slug = $record->slug . '-copy';
$newPlan->save();
}),
]);
}
}
📊 System Settings Resource
<?php
// app/Filament/SuperAdmin/Resources/SystemSettingResource.php
namespace App\Filament\SuperAdmin\Resources;
use App\Models\SystemSetting;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\\Schema as Schema;
use Filament\\Actions;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class SystemSettingResource extends Resource
{
protected static ?string $model = SystemSetting::class;
protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
protected static ?string $navigationGroup = 'Platform Configuration';
protected static ?string $label = 'System-Einstellungen';
public static function form(Form $form): Form
{
return $form->schema([
Schema\\Components\\Section::make('Setting Details')
->schema([
Schema\\Components\\TextInput::make('key')
->required()
->unique(SystemSetting::class, 'key', ignoreRecord: true)
->helperText('Eindeutiger Key für die Einstellung'),
Schema\\Components\\Textarea::make('value')
->required()
->rows(3)
->helperText('JSON, String oder andere Werte'),
Schema\\Components\\Textarea::make('description')
->rows(2)
->helperText('Beschreibung der Einstellung'),
Schema\\Components\\Toggle::make('is_public')
->label('Öffentlich verfügbar')
->helperText('Über öffentliche API abrufbar'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('key')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('value')
->limit(50)
->searchable(),
Tables\Columns\TextColumn::make('description')
->limit(100),
Tables\Columns\IconColumn::make('is_public')
->boolean(),
Tables\Columns\TextColumn::make('updated_at')
->label('Zuletzt geändert')
->dateTime('d.m.Y H:i')
->sortable(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_public')
->label('Öffentliche Einstellungen'),
])
->actions([
Actions\\EditAction::make(),
])
->groups([
'category' => Tables\Grouping\Group::make('key')
->label('Kategorie')
->getDescriptionFromRecordUsing(fn ($record) => explode('.', $record->key)[0])
]);
}
// Predefined System Settings
public static function getDefaultSettings(): array
{
return [
'platform.max_file_size' => '10485760', // 10MB
'platform.allowed_image_types' => 'jpeg,jpg,png,webp',
// Event-basierte Preisgestaltung
'pricing.event_price_eur' => '9.99',
'pricing.free_event_on_app_purchase' => 'true',
'email.from_address' => 'noreply@fotobox-platform.de',
'email.support_address' => 'support@fotobox-platform.de',
'analytics.google_analytics_id' => '',
'features.maintenance_mode' => 'false',
'features.new_registrations' => 'true',
'storage.default_disk' => 'local',
'storage.cdn_url' => '',
];
}
}
📊 Super Admin Widgets
Platform Statistics Widget:
<?php
// app/Filament/SuperAdmin/Widgets/PlatformStatsWidget.php
namespace App\Filament\SuperAdmin\Widgets;
use App\Models\{Tenant, Event, Photo, User};
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class PlatformStatsWidget extends BaseWidget
{
protected function getStats(): array
{
return [
Stat::make('Kunden mit Events', Tenant::whereHas('events')->count())
->description('Mindestens ein Event vorhanden')
->descriptionIcon('heroicon-m-building-office')
->color('success'),
Stat::make('Gesamt Events', Event::count())
->description('Alle Events auf der Platform')
->descriptionIcon('heroicon-m-calendar-days')
->color('primary'),
Stat::make('Gesamt Fotos', Photo::count())
->description('Hochgeladene Fotos')
->descriptionIcon('heroicon-m-photo')
->color('warning'),
Stat::make('Monthly Recurring Revenue',
Tenant::where('subscription_status', 'active')
->whereIn('plan_type', ['basic', 'premium'])
->get()
->sum(fn($tenant) => match($tenant->plan_type) {
'basic' => 29,
'premium' => 49,
default => 0
})
)
->description('€ MRR')
->descriptionIcon('heroicon-m-currency-euro')
->color('success'),
];
}
}
Revenue Chart Widget:
<?php
// app/Filament/SuperAdmin/Widgets/RevenueChartWidget.php
namespace App\Filament\SuperAdmin\Widgets;
use App\Models\EventPurchase;
use Filament\Widgets\ChartWidget;
class RevenueChartWidget extends ChartWidget
{
protected static ?string $heading = 'Umsatz (Letzte 12 Monate)';
protected function getData(): array
{
$data = EventPurchase::where('status', 'paid')
->where('created_at', '>=', now()->subYear())
->selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, SUM(amount) as revenue')
->groupBy('month')
->orderBy('month')
->get();
return [
'datasets' => [
[
'label' => 'Umsatz (€)',
'data' => $data->pluck('revenue'),
'borderColor' => 'rgb(99, 102, 241)',
'backgroundColor' => 'rgba(99, 102, 241, 0.1)',
],
],
'labels' => $data->pluck('month'),
];
}
protected function getType(): string
{
return 'line';
}
}
🔠Super Admin Middleware
<?php
// app/Http/Middleware/SuperAdminOnly.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SuperAdminOnly
{
public function handle(Request $request, Closure $next)
{
if (!auth()->check() || auth()->user()->role !== 'super_admin') {
abort(403, 'Access denied. Super Admin privileges required.');
}
return $next($request);
}
}
🢠Tenant Scoping Middleware
<?php
// app/Http/Middleware/TenantScope.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Tenant;
class TenantScope
{
public function handle(Request $request, Closure $next)
{
if (auth()->user()->role === 'super_admin') {
return $next($request); // Super Admin bypasses tenant scoping
}
$tenant = auth()->user()->tenant;
if (!$tenant) {
abort(403, 'No tenant associated with user.');
}
// Set global tenant scope
config(['app.current_tenant' => $tenant]);
return $next($request);
}
}
🎯 Super Admin Navigation Struktur
ðŸ Dashboard
├── 📊 Platform Overview
├── 💰 Revenue Summary
└── 🚨 System Alerts
👥 Customer Management
├── 🢠Tenants/Kunden
├── 👤 User Management
├── 📞 Support Tickets
└── 📧 Bulk Communications
💳 Billing & Subscriptions
├── 💰 Subscription Plans
├── 🧾 Invoices & Payments
├── 📊 Revenue Analytics
└── 🎠Promotions & Discounts
âš™ï¸ Platform Configuration
├── 🔧 System Settings
├── 🎨 Global Themes
├── # Hochzeits-Fotobox App - Project Requirements & Prompt (PRP)
## 📋 Projekt-Übersicht
**Projektname:** Emotionen-Fotobox für Deutsche Hochzeiten
**Ziel:** Eine Progressive Web App (PWA), die Hochzeitsgäste spielerisch dazu motiviert, emotionale Fotos zu erstellen und zu teilen
**Tech-Stack:** Laravel 12.x + Filament 4.x (Admin) + Vite + React + SQLite + Tailwind CSS
> Versions as of 2025-09-01: Laravel 12 (latest 12.21.x), Filament 4 (stable). If you must stay on Filament v3 for compatibility, pin to 3.3.x.
**Zielgruppe:** Deutsche Hochzeitsgäste aller Altersgruppen
## 🎯 Kern-Funktionalitäten
### 1. Emotions-basiertes Foto-System
- **6 Haupt-Emotionen:** Liebe 💕, Freude 😂, Rührung 🥺, Nostalgie 📸, Überraschung 😲, Stolz ðŸ†
- **Aufgaben-Generator:** Pro Emotion 8-12 verschiedene Foto-Aufgaben
- **Schwierigkeitsgrade:** Easy (Einzelperson), Medium (Gruppe), Hard (Kreativ/Nachstellung)
### 2. Social Media-inspiriertes UI
- **Instagram Stories-Layout:** Horizontale Emotion-Bubbles
- **TikTok-Style Kamera:** Vollbild mit Overlay-Aufgaben
- **Feed-Design:** Kachel-Grid mit Like-Funktion
- **Swipe-Navigation:** Intuitive Bedienung
### 3. Real-time Features
- **Live-Photo-Feed:** Neue Fotos erscheinen sofort bei allen Gästen
- **Kollaborative Galerie:** Gemeinsame Fotosortierung nach Emotionen
- **Like-System:** Herzchen für beliebte Fotos
## ðŸ—ï¸ Technische Architektur
### Backend: Laravel 12.x (PHP 8.4)
#### Database Schema (SQLite)
```sql
-- Event Types (wedding, christmas, birthday, corporate, ...)
CREATE TABLE event_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- JSON (translatable: {"de":"Hochzeit","en":"Wedding"})
slug VARCHAR(100) UNIQUE NOT NULL,
icon VARCHAR(64),
settings JSON, -- e.g., {"palette": {...}}
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Events (Veranstaltungen)
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- JSON (translatable)
date DATE NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT, -- JSON (translatable)
settings JSON, -- Konfiguration für Emotionen, Farben etc.
event_type_id INTEGER NOT NULL,
is_active BOOLEAN DEFAULT 1,
default_locale VARCHAR(5) DEFAULT 'de',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (event_type_id) REFERENCES event_types(id)
);
-- Emotionen-Kategorien (reusable; linked to event types via pivot)
CREATE TABLE emotions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- JSON (translatable)
icon VARCHAR(50) NOT NULL, -- Emoji oder Icon-Klasse
color VARCHAR(7) NOT NULL, -- Hex-Farbe
description TEXT, -- JSON (translatable)
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1
);
-- Emotion↔EventType Zuordnung (many-to-many)
CREATE TABLE emotion_event_type (
emotion_id INTEGER NOT NULL,
event_type_id INTEGER NOT NULL,
PRIMARY KEY (emotion_id, event_type_id),
FOREIGN KEY (emotion_id) REFERENCES emotions(id),
FOREIGN KEY (event_type_id) REFERENCES event_types(id)
);
CREATE TABLE tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
emotion_id INTEGER NOT NULL,
event_type_id INTEGER, -- optional: directly target a type
title TEXT NOT NULL, -- JSON (translatable)
description TEXT NOT NULL, -- JSON (translatable)
difficulty ENUM('easy', 'medium', 'hard') DEFAULT 'easy',
example_text TEXT, -- JSON (translatable)
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1,
-- multi-tenant scoping additions (see Tenant-Defined Tasks section)
tenant_id INTEGER,
scope TEXT DEFAULT 'global', -- 'global' | 'tenant' | 'event'
event_id INTEGER,
FOREIGN KEY (emotion_id) REFERENCES emotions(id)
-- optional FKs for scoping
,FOREIGN KEY (event_type_id) REFERENCES event_types(id)
,FOREIGN KEY (tenant_id) REFERENCES tenants(id)
,FOREIGN KEY (event_id) REFERENCES events(id)
);
-- Hochgeladene Fotos
CREATE TABLE photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
emotion_id INTEGER NOT NULL,
task_id INTEGER,
guest_name VARCHAR(255) NOT NULL,
file_path VARCHAR(255) NOT NULL,
thumbnail_path VARCHAR(255) NOT NULL,
likes_count INTEGER DEFAULT 0,
is_featured BOOLEAN DEFAULT 0, -- Für Highlights
metadata JSON, -- EXIF-Daten, Gerätinfo etc.
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES events(id),
FOREIGN KEY (emotion_id) REFERENCES emotions(id),
FOREIGN KEY (task_id) REFERENCES tasks(id)
);
-- Photo-Likes (für Gast-Interaktion)
CREATE TABLE photo_likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
photo_id INTEGER NOT NULL,
guest_name VARCHAR(255) NOT NULL,
ip_address VARCHAR(45),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (photo_id) REFERENCES photos(id),
UNIQUE(photo_id, guest_name, ip_address)
);
Laravel Models
Event Model:
Event model additions (event type):
```php
// In App\Models\Event
protected $fillable = ["name","date","slug","description","settings","event_type_id","is_active"];
public function type(){ return $this->belongsTo(\App\Models\EventType::class, "event_type_id"); }
{/* Minimalist Header */}
{/* Central Card for Actions */}
);
}
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 (
Event Date
Branding Logo
{/* Main Content */}
Willkommen bei der Fotobox! 🎉
Dein Schlüssel zu unvergesslichen Hochzeitsmomenten.
{/* QR Code Scanner Emphasis */}
{/* Minimalist Footer */}
© {new Date().getFullYear()} Emotionen-Fotobox
QR-Code scannen
{/* Manual Code Entry (Fallback) */}
Oder Code manuell eingeben
{/* Header */}
{/* Input Section */}
{/* Footer */}
);
}
export default EventSpecificLandingPage;
```
**SettingsSheet.jsx (gear icon → bottom sheet)**
```jsx
import React from "react"
import { Link } from "react-router-dom"
import { useTranslation } from "react-i18next"
export default function SettingsSheet({ open, onClose, onLangChange, currentLang }) {
const { t } = useTranslation()
if (!open) return null
return (
â¤ï¸ðŸ“¸
{/* Heart with Camera Icon */}
01.01.2025
{/* Event Date */}
{/* Main Content - Vertically Centered */}
{/* Title Section */}
HOCHZEIT VON
MARIE & LUKAS
Fangt die schönsten Momente ein!
Bereit für Emotionen?
setGuestName(e.target.value)} className="w-full p-3 mb-4 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-300" required /> LET’S GO! 🎉â¤ï¸
Emotionen-Fotobox
{t("ui:settings")}
✕{t("ui:language")}
onLangChange("de")} className={`px-3 py-1 rounded border ${currentLang==="de"?"bg-gray-900 text-white":""}`}>DE
onLangChange("en")} className={`px-3 py-1 rounded border ${currentLang==="en"?"bg-gray-900 text-white":""}`}>EN
{t("ui:legal")}
- Impressum
- Datenschutzerklärung
- AGB
Loading…
if (error) return Error loading content.
return (
{data.title}
{data.updated_at ? new Date(data.updated_at).toLocaleDateString(lang) : null}
{data.body_markdown || ""}
Fotobox
setOpen(true)}
className="p-2 rounded-full hover:bg-gray-100"
>
{/* Header */}
{/* Emotions-Picker */}
{/* "Alle anzeigen" Button */}
)
}
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 (
)
}
return (
Willkommen zur Fotobox! 📸
{guestName && (Hallo, {guestName}! 👋
)}
{emotions?.map((emotion) => (
handleEmotionClick(emotion.id)}
className="flex-shrink-0"
/>
))}
navigate(`/event/${eventSlug}/gallery`)}
className="text-pink-600 text-sm font-medium hover:text-pink-700"
>
Alle anzeigen →
{/* Recent Photos Feed */}
Neueste Momente
{photosLoading ? (
{[...Array(4)].map((_, i) => (
))}
) : (
)}
Aufgabe wird geladen...
{!capturedPhoto ? (
<>
{/* Live-Kamera */}
{/* Task-Overlay */}
navigate(`/event/${eventSlug}`)}
/>
) : (
/* Foto-Vorschau */
)}
)
}
export default CameraPage
```
#### PWA Service Worker Features
**Offline-Funktionalität:**
```javascript
// public/sw.js (Custom Service Worker)
const CACHE_NAME = 'fotobox-v1'
const OFFLINE_URL = '/offline.html'
// Offline-Queue für Photo-Uploads
class PhotoUploadQueue {
constructor() {
this.queue = []
this.isProcessing = false
}
async add(photoData) {
this.queue.push(photoData)
await this.process()
}
async process() {
if (this.isProcessing || this.queue.length === 0) return
this.isProcessing = true
while (this.queue.length > 0) {
const photoData = this.queue.shift()
try {
const response = await fetch(photoData.url, {
method: 'POST',
body: photoData.formData
})
if (response.ok) {
// Upload erfolgreich - Cache invalidieren
await caches.delete('api-cache')
// Client benachrichtigen
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'PHOTO_UPLOADED',
data: photoData
})
})
})
} else {
// Fehlgeschlagen - zurück in die Queue
this.queue.unshift(photoData)
break
}
} catch (error) {
// Netzwerkfehler - zurück in die Queue
this.queue.unshift(photoData)
break
}
}
this.isProcessing = false
}
}
const uploadQueue = new PhotoUploadQueue()
// Network-first für API-Calls
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
// Response klonen für Cache
const responseClone = response.clone()
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseClone)
})
return response
})
.catch(() => {
// Fallback auf Cache
return caches.match(event.request)
})
)
}
})
```
## 🎨 UI/UX Design-Spezifikationen
### Farbschema
```javascript
// tailwind.config.js - Custom Colors
module.exports = {
theme: {
extend: {
colors: {
emotions: {
love: '#ff6b9d', // Liebe - Pink
joy: '#ffd93d', // Freude - Gelb
touched: '#6bcf7f', // Rührung - Grün
nostalgia: '#a78bfa', // Nostalgie - Lila
surprise: '#fb7185', // Überraschung - Rosa
pride: '#34d399' // Stolz - Türkis
},
wedding: {
primary: '#ff6b9d',
secondary: '#a78bfa',
accent: '#ffd93d',
neutral: '#6b7280',
success: '#10b981',
error: '#ef4444'
}
},
fontFamily: {
'wedding': ['Poppins', 'Inter', 'sans-serif']
},
animation: {
'bounce-soft': 'bounce 2s infinite',
'pulse-slow': 'pulse 3s infinite',
'wiggle': 'wiggle 1s ease-in-out infinite',
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out'
}
}
}
}
```
### Responsive Breakpoints
- **Mobile First:** 320px - 768px (Hauptzielgruppe)
- **Tablet:** 768px - 1024px (Landscape-Modus)
- **Desktop:** 1024px+ (Admin-Interface)
### Gestaltungs-Prinzipien
1. **Thumb-Friendly:** Alle wichtigen Buttons in Daumen-Reichweite
2. **High Contrast:** Gute Lesbarkeit auch bei schlechtem Licht
3. **Emotional Colors:** Jede Emotion hat eigene Farb-Identität
4. **Micro-Animations:** Subtile Feedback-Animationen
5. **Progressive Disclosure:** Komplexität schrittweise aufbauen
## 📱 Komponenten-Spezifikationen
### EmotionBubble Component
```jsx
import React from 'react'
import { motion } from 'framer-motion'
const EmotionBubble = ({ emotion, onClick, isActive, className }) => {
return (
onClick(emotion.id)}
>
{/* Emoji Icon */}
{emotion.icon}
{/* Name */}
{emotion.name}
{/* Pulse Animation für aktive Emotion */}
{isActive && (
)}
)
}
```
### CameraCapture Component
```jsx
import React, { useRef, useEffect, useState } from 'react'
import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react'
const CameraCapture = React.forwardRef(({ onCapture }, ref) => {
const videoRef = useRef(null)
const canvasRef = useRef(null)
const [stream, setStream] = useState(null)
const [hasFlash, setHasFlash] = useState(false)
const [isFlashOn, setIsFlashOn] = useState(false)
const [facingMode, setFacingMode] = useState('user') // 'user' oder 'environment'
// Kamera initialisieren
useEffect(() => {
startCamera()
return () => {
stopCamera()
}
}, [facingMode])
const startCamera = async () => {
try {
const constraints = {
video: {
facingMode: facingMode,
width: { ideal: 1080 },
height: { ideal: 1920 }
}
}
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
setStream(mediaStream)
if (videoRef.current) {
videoRef.current.srcObject = mediaStream
}
// Flash-Unterstützung prüfen
const videoTrack = mediaStream.getVideoTracks()[0]
const capabilities = videoTrack.getCapabilities()
setHasFlash(capabilities.torch === true)
} catch (error) {
console.error('Kamera-Zugriff fehlgeschlagen:', error)
alert('Kamera-Zugriff nicht möglich. Bitte Berechtigungen prüfen.')
}
}
const stopCamera = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop())
setStream(null)
}
}
const toggleFlash = async () => {
if (!hasFlash || !stream) return
const videoTrack = stream.getVideoTracks()[0]
try {
await videoTrack.applyConstraints({
advanced: [{ torch: !isFlashOn }]
})
setIsFlashOn(!isFlashOn)
} catch (error) {
console.error('Flash toggle fehlgeschlagen:', error)
}
}
const switchCamera = () => {
setFacingMode(prev => prev === 'user' ? 'environment' : 'user')
}
const capturePhoto = () => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
// Canvas-Größe an Video anpassen
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Video-Frame auf Canvas zeichnen
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Canvas zu Blob konvertieren
canvas.toBlob((blob) => {
if (blob && onCapture) {
onCapture(blob)
}
}, 'image/jpeg', 0.9)
}
return (
{/* Video Stream */}
)
})
export default CameraCapture
```
### TaskOverlay Component
```jsx
import React from 'react'
import { motion } from 'framer-motion'
import { Shuffle, ArrowLeft, HelpCircle } from 'lucide-react'
const TaskOverlay = ({ task, onNewTask, onBack, onShowHelp }) => {
if (!task) return null
return (
{/* Background Gradient */}
{/* Header Controls */}
{/* Task Content */}
{/* Emotion Badge */}
)
}
const getDifficultyLevel = (difficulty) => {
switch (difficulty) {
case 'easy': return 1
case 'medium': return 2
case 'hard': return 3
default: return 1
}
}
export default TaskOverlay
```
## 🧩 Tenant-Defined Tasks & CSV Import
### Goals
- Allow tenants (customers) to create and manage their own photo tasks.
- Support CSV import for bulk task creation for both Tenant Admins and Super Admin.
- Keep global (platform) task templates that tenants can also use.
### Schema Changes (Tenant Scoping for Tasks)
```sql
-- Extend tasks to support tenant/event scoping
ALTER TABLE tasks ADD COLUMN tenant_id INTEGER NULL; -- owner tenant for custom tasks
ALTER TABLE tasks ADD COLUMN scope TEXT DEFAULT 'global' CHECK (scope IN ('global','tenant','event'));
ALTER TABLE tasks ADD COLUMN event_id INTEGER NULL; -- optional link for event-specific tasks
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_event_id FOREIGN KEY (event_id) REFERENCES events(id);
-- Prevent duplicate titles per emotion within the same tenant (optional)
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_unique_per_tenant ON tasks(tenant_id, emotion_id, title);
```
Notes:
- Use `scope='global'` for Super Admin library tasks, `tenant` for customer-defined, and `event` for per-event overrides.
- `tenant_id`/`event_id` are NULL for `global` tasks.
### CSV Format
- Encoding: UTF-8, delimiter: comma.
- Header columns:
- emotion (name or ID)
- title
- description
- difficulty (easy|medium|hard)
- example_text (optional)
- is_active (1|0, optional; default 1)
- sort_order (integer, optional)
- scope (global|tenant|event, optional; default depends on UI context)
- event_slug (required if scope=event)`n - event_type (slug, optional; defaults to current event type or chosen library type)
Example:
```csv
emotion,title,description,difficulty,example_text,is_active,sort_order,scope,event_type,event_slug
Freude,Sprung-Foto,Alle springen gleichzeitig in die Luft!,medium,,1,10,tenant,corporate,
Liebe,Kuss-Foto,Macht ein romantisches Kuss-Foto,easy,Einfach küssen und lächeln!,1,5,global,wedding,
Überraschung,Plötzlicher Jubel,Erschreckt das Brautpaar mit Jubel,hard,,1,20,event,,mueller-hochzeit-2025
```
Validation rules:
- emotion must exist (map by name → ID considering tenant visibility).
- difficulty in allowed set; length limits per model.
- If scope=event, event_slug must resolve to a tenant-owned event.
### Filament v4 — Tenant Admin Task Resource (with CSV Import)
```php
<?php
// app/Filament/Tenant/Resources/TaskResource.php
namespace App\Filament\Tenant\Resources;
use App\Models\{Task, Emotion, Event, EventType};
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Filament\Forms\Form;
use Filament\Schema as Schema;
use Illuminate\Support\Facades\DB;
use League\Csv\Reader;
class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static ?string $navigationIcon = 'heroicon-o-queue-list';
protected static ?string $navigationGroup = 'Content';
protected static ?string $label = 'Aufgaben';
public static function form(Form $form): Form
{
return $form->schema([
Schema\Components\Section::make('Aufgabe')
->schema([
Schema\Components\Select::make('emotion_id')
->label('Emotion')
->relationship('emotion', 'name')
->required(),
Schema\Components\TextInput::make('title')->required()->maxLength(255),
Schema\Components\Textarea::make('description')->required()->rows(3),
Schema\Components\Select::make('difficulty')
->options(['easy'=>'Easy','medium'=>'Medium','hard'=>'Hard'])->required(),
Schema\Components\TextInput::make('example_text'),
Schema\Components\Toggle::make('is_active')->default(true),
Schema\Components\TextInput::make('sort_order')->numeric()->default(0),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('emotion.name')->label('Emotion')->sortable()->searchable(),
Tables\Columns\TextColumn::make('title')->searchable(),
Tables\Columns\BadgeColumn::make('difficulty')->colors(['easy'=>'success','medium'=>'warning','hard'=>'danger']),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('sort_order')->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('event_type_id')
->label('Event Type')
->options(fn () => \App\Models\EventType::orderBy('name')->pluck('name', 'id')->all())
->query(function ($query, $state) {
if (! $state) { return; }
$query->where(function($q) use ($state) {
$q->where('event_type_id', $state)
->orWhereExists(function($sub) use ($state) {
$sub->selectRaw(1)
->from('emotion_event_type as eet')
->whereColumn('eet.emotion_id', 'tasks.emotion_id')
->where('eet.event_type_id', $state);
});
});
}),
])
->headerActions([
Actions\CreateAction::make(),
Actions\Action::make('importCsv')
->label('CSV Import')
->icon('heroicon-o-arrow-up-tray')
->form([
Schema\Components\FileUpload::make('csv')->acceptedFileTypes(['text/csv','text/plain'])->required(),
Schema\Components\Select::make('scope')->options(['tenant'=>'Tenant','event'=>'Event'])->default('tenant')->required(),
Schema\Components\Select::make('event_id')
->label('Event (für Scope=Event)')
->relationship('event', 'name')
->searchable()
->visible(fn ($get) => $get('scope') === 'event'),
])
->action(function(array $data) {
$path = $data['csv'];
$full = storage_path('app/public/'.$path);
$csv = Reader::createFromPath($full, 'r');
$csv->setHeaderOffset(0);
DB::transaction(function() use ($csv, $data) {
foreach ($csv->getRecords() as $row) {
$emotion = Emotion::query()->where('name', $row['emotion'] ?? '')->first();
if (! $emotion) { continue; }
Task::create([
'tenant_id' => auth()->user()->tenant_id,
'event_id' => $data['scope'] === 'event' ? ($data['event_id'] ?? null) : null,
'scope' => $data['scope'],
'emotion_id' => $emotion->id,
'title' => trim($row['title'] ?? ''),
'description' => trim($row['description'] ?? ''),
'difficulty' => $row['difficulty'] ?? 'easy',
'example_text' => $row['example_text'] ?? null,
'is_active' => (int)($row['is_active'] ?? 1) === 1,
'sort_order' => (int)($row['sort_order'] ?? 0),
]);
}
});
})
->requiresConfirmation(),
])
->recordActions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
]);
}
public static function getEloquentQuery()
{
// Tenant scoping for tasks: show global + tenant-owned
return parent::getEloquentQuery()->where(function($q){
$tenantId = auth()->user()->tenant_id;
$q->whereNull('tenant_id')->where('scope','global')
->orWhere('tenant_id', $tenantId);
});
}
}
```
**EmotionController (filtered by event type):**
```php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\{Event, Emotion};
class EmotionController extends Controller
{
public function index(Event $event)
{
$typeId = $event->event_type_id;
$emotions = Emotion::query()
->where('is_active', true)
->where(function($q) use ($typeId) {
$q->whereIn('id', function($sub) use ($typeId) {
$sub->from('emotion_event_type')
->select('emotion_id')
->where('event_type_id', $typeId);
});
})
->orderBy('sort_order')
->get();
return response()->json($emotions);
}
}
```
**TaskController (respect event type and scope precedence):**
```php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\{Event, Task};
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function getByEmotion(Request $request, Event $event, $emotionId)
{
$tenantId = optional($request->user())->tenant_id; // optional auth
$typeId = $event->event_type_id;
$query = Task::query()
->where('emotion_id', $emotionId)
->where('is_active', true)
->where(function($q) use ($event, $tenantId, $typeId) {
$q->where('scope', 'event')->where('event_id', $event->id)
->orWhere(function($q2) use ($tenantId, $typeId) {
$q2->where('scope','tenant')
->where('tenant_id', $tenantId)
->where(function($qq) use ($typeId){
$qq->whereNull('event_type_id')->orWhere('event_type_id', $typeId);
});
})
->orWhere(function($q3) use ($typeId) {
$q3->where('scope','global')
->where(function($qq) use ($typeId){
$qq->whereNull('event_type_id')->orWhere('event_type_id', $typeId);
});
});
})
->orderByRaw("CASE WHEN scope='event' THEN 1 WHEN scope='tenant' THEN 2 ELSE 3 END")
->orderBy('sort_order')
->get();
return response()->json($query);
}
public function getRandomTask(Request $request, Event $event, $emotionId)
{
$request->merge(['limit' => 1]);
$tasks = $this->getByEmotion($request, $event, $emotionId)->getData();
// pick a random from top precedence group
$collection = collect($tasks);
$topScope = $collection->min(fn($t) => $t->scope === 'event' ? 1 : ($t->scope === 'tenant' ? 2 : 3));
$pool = $collection->filter(fn($t) => ($t->scope === 'event' ? 1 : ($t->scope === 'tenant' ? 2 : 3)) === $topScope);
return response()->json($pool->random());
}
}
```
### Super Admin — Event Types Resource
```php
<?php
### Super Admin — Emotion Resource (Event Type Filter)
```php
<?php
// app/Filament/SuperAdmin/Resources/EmotionResource.php
namespace App\\Filament\\SuperAdmin\\Resources;
use App\\Models\\{Emotion, EventType};
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Actions;
use Filament\\Forms\\Form;
use Filament\\Schema as Schema;
class EmotionResource extends Resource
{
protected static ?string $model = Emotion::class;
protected static ?string $navigationIcon = "heroicon-o-face-smile";
protected static ?string $navigationGroup = "Content Management";
protected static ?string $label = "Emotions";
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make("name")->searchable()->sortable(),
Tables\\Columns\\TextColumn::make("icon"),
Tables\\Columns\\TextColumn::make("color"),
])
->filters([
Tables\\Filters\\SelectFilter::make("event_type")
->label("Event Type")
->options(fn () => EventType::orderBy("name")->pluck("name","id")->all())
->query(function($query, $state){
if (! $state) { return; }
$query->whereIn("id", function($sub) use ($state) {
$sub->from("emotion_event_type")
->select("emotion_id")
->where("event_type_id", $state);
});
}),
])
->recordActions([
Actions\\EditAction::make(),
Actions\\DeleteAction::make(),
])
->headerActions([ Actions\\CreateAction::make() ]);
}
}
```
// app/Filament/SuperAdmin/Resources/EventTypeResource.php
namespace App\\Filament\\SuperAdmin\\Resources;
use App\\Models\\EventType;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Forms\\Form;
use Filament\\Schema as Schema;
use Filament\\Actions;
class EventTypeResource extends Resource
{
protected static ?string $model = EventType::class;
protected static ?string $navigationIcon = "heroicon-o-rectangle-stack";
protected static ?string $navigationGroup = "Platform Configuration";
protected static ?string $label = "Event Types";
public static function form(Form $form): Form
{
return $form->schema([
Schema\\Components\\TextInput::make("name")->required()->maxLength(100),
Schema\\Components\\TextInput::make("slug")->required()->maxLength(100)->unique(ignoreRecord: true),
Schema\\Components\\TextInput::make("icon")->maxLength(64),
Schema\\Components\\KeyValue::make("settings")->keyLabel("Key")->valueLabel("Value"),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\\Columns\\TextColumn::make("name")->searchable()->sortable(),
Tables\\Columns\\TextColumn::make("slug")->searchable(),
Tables\\Columns\\TextColumn::make("icon"),
])
->recordActions([
Actions\\EditAction::make(),
Actions\\DeleteAction::make(),
])
->headerActions([ Actions\\CreateAction::make() ]);
}
}
```
### Filament v4 — Super Admin Global Task Library (with CSV Import)
```php
<?php
// app/Filament/SuperAdmin/Resources/TaskLibraryResource.php
namespace App\Filament\SuperAdmin\Resources;
use App\Models\{Task, Emotion, Tenant, Event, EventType};
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Filament\Forms\Form;
use Filament\Schema as Schema;
use League\Csv\Reader;
use Illuminate\Support\Facades\DB;
class TaskLibraryResource extends Resource
{
protected static ?string $model = Task::class;
protected static ?string $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $navigationGroup = 'Content Management';
protected static ?string $label = 'Task Library';
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('scope')->badge(),
Tables\Columns\TextColumn::make('tenant.name')->label('Tenant')->toggleable(isToggledHidden: true),
Tables\Columns\TextColumn::make('emotion.name')->label('Emotion'),
Tables\Columns\TextColumn::make('title')->limit(60),
Tables\Columns\BadgeColumn::make('difficulty'),
Tables\Columns\IconColumn::make('is_active')->boolean(),
] ->filters([
Tables\\Filters\\SelectFilter::make('event_type_id')
->label('Event Type')
->options(fn () => \\App\\Models\\EventType::orderBy('name')->pluck('name','id')->all())
->query(function (,){
if(! ){ return; }
->where(function() use (){
->where('event_type_id',)
->orWhereExists(function() use (){
->selectRaw(1)
->from('emotion_event_type as eet')
->whereColumn('eet.emotion_id','tasks.emotion_id')
->where('eet.event_type_id',);
});
});
}),
])
)
->headerActions([
Actions\Action::make('importCsv')
->label('CSV Import')
->icon('heroicon-o-arrow-up-tray')
->form([
Schema\Components\FileUpload::make('csv')->acceptedFileTypes(['text/csv','text/plain'])->required(),
Schema\Components\Select::make('scope')->options(['global'=>'Global','tenant'=>'Tenant','event'=>'Event'])->default('global')->required(),
Schema\Components\Select::make('tenant_id')->relationship('tenant','name')->visible(fn($get)=>$get('scope')!=='global'),
Schema\Components\Select::make('event_id')->label('Event')->relationship('event','name')->visible(fn($get)=>$get('scope')==='event'),
])
->action(function(array $data){
$path = $data['csv'];
$full = storage_path('app/public/'.$path);
$csv = Reader::createFromPath($full, 'r');
$csv->setHeaderOffset(0);
DB::transaction(function() use ($csv, $data) {
foreach ($csv->getRecords() as $row) {
$emotion = Emotion::query()->where('name', $row['emotion'] ?? '')->first();
if (! $emotion) { continue; }
Task::create([
'scope' => $data['scope'],
'tenant_id' => $data['scope']!=='global' ? ($data['tenant_id'] ?? null) : null,
'event_id' => $data['scope']==='event' ? ($data['event_id'] ?? null) : null,
'emotion_id' => $emotion->id,
'title' => trim($row['title'] ?? ''),
'description' => trim($row['description'] ?? ''),
'difficulty' => $row['difficulty'] ?? 'easy',
'example_text' => $row['example_text'] ?? null,
'is_active' => (int)($row['is_active'] ?? 1) === 1,
'sort_order' => (int)($row['sort_order'] ?? 0),
]);
}
});
})
->requiresConfirmation(),
]);
}
}
```
Implementation notes:
- Add policies to restrict Tenant Admins from editing global tasks.
- When importing with scope=tenant/event in Super Admin, enforce that referenced tenant/event exist.
- Consider a background Job for large CSV files and a progress UI.
## 🚀 Deployment & Setup-Anweisungen
### Entwicklungs-Setup
#### 1. Laravel Backend Setup
```bash
# Neues Laravel-Projekt erstellen
composer create-project laravel/laravel:^12.0 hochzeits-fotobox
cd hochzeits-fotobox
# Abhängigkeiten installieren
composer require intervention/image
composer require pusher/pusher-php-server
composer require laravel/sanctum
composer require filament/filament:^4.0 -W
# Falls Filament v3 benoetigt wird (Kompatibilitaet):
# composer require filament/filament:^3.3 -W
composer require league/csv:^9.15 # CSV parsing for task imports
composer require spatie/laravel-translatable # JSON-based field translations (i18n)
# Environment konfigurieren
cp .env.example .env
# Datenbankverbindung auf SQLite ändern:
# DB_CONNECTION=sqlite
# DB_DATABASE=/absolute/path/to/database/database.sqlite
# SQLite-Datenbank erstellen
touch database/database.sqlite
# Migrations erstellen und ausführen
php artisan make:migration create_events_table
php artisan make:migration create_emotions_table
php artisan make:migration create_event_types_table
php artisan make:migration create_emotion_event_type_table
php artisan make:migration add_event_type_id_to_events
php artisan make:migration add_event_type_and_scoping_to_tasks
php artisan make:migration add_i18n_translatables_json --table=events
php artisan make:migration add_i18n_translatables_json_to_emotions --table=emotions
php artisan make:migration add_i18n_translatables_json_to_tasks --table=tasks
php artisan make:migration create_tasks_table
php artisan make:migration create_photos_table
php artisan make:migration create_photo_likes_table
php artisan migrate
# Models und Controller generieren
php artisan make:model Event -c
php artisan make:model Emotion -c
php artisan make:model Task -c
php artisan make:model Photo -c
php artisan make:model PhotoLike
# Seeder für Demo-Daten
php artisan make:seeder EmotionsSeeder
php artisan make:seeder TasksSeeder
php artisan make:seeder DemoEventSeeder
# Storage-Link erstellen
php artisan storage:link
# App-Key generieren
php artisan key:generate
```
#### 2. Frontend Setup
```bash
# NPM-Abhängigkeiten installieren
npm install
# React und Tools
npm install react react-dom @vitejs/plugin-react
npm install @tanstack/react-query
npm install react-router-dom
npm install framer-motion
npm install lucide-react
# Styling
npm install tailwindcss postcss autoprefixer
npm install @tailwindcss/forms @tailwindcss/aspect-ratio
# PWA
npm install vite-plugin-pwa workbox-window
# Utils
npm install date-fns
npm install clsx
npm install compressorjs
npm install react-markdown rehype-sanitize
# Tailwind initialisieren
npx tailwindcss init -p
# Verzeichnis-Struktur erstellen
mkdir -p resources/js/src/{components,pages,hooks,services,context,utils}
mkdir -p resources/js/src/components/{Camera,Emotions,Gallery,Layout,Forms}
mkdir -p public/icons
```
#### 3. Seeder-Daten
**EmotionsSeeder.php:**
```php
<?php
use Illuminate\Database\Seeder;
use App\Models\Emotion;
class EmotionsSeeder extends Seeder
{
public function run()
{
$emotions = [
[
'name' => 'Liebe',
'icon' => '💕',
'color' => '#ff6b9d',
'description' => 'Romantische und liebevolle Momente',
'sort_order' => 1
],
[
'name' => 'Freude',
'icon' => '😂',
'color' => '#ffd93d',
'description' => 'Lustige und fröhliche Augenblicke',
'sort_order' => 2
],
[
'name' => 'Rührung',
'icon' => '🥺',
'color' => '#6bcf7f',
'description' => 'Emotionale und berührende Szenen',
'sort_order' => 3
],
[
'name' => 'Nostalgie',
'icon' => '📸',
'color' => '#a78bfa',
'description' => 'Erinnerungen und nostalgische Gefühle',
'sort_order' => 4
],
[
'name' => 'Überraschung',
'icon' => '😲',
'color' => '#fb7185',
'description' => 'Unerwartete und überraschende Momente',
'sort_order' => 5
],
[
'name' => 'Stolz',
'icon' => 'ðŸ†',
'color' => '#34d399',
'description' => 'Stolze und triumphale Augenblicke',
'sort_order' => 6
]
];
foreach ($emotions as $emotionData) {
Emotion::create($emotionData);
}
}
}
```
**TasksSeeder.php:**
```php
<?php
use Illuminate\Database\Seeder;
use App\Models\{Emotion, Task};
class TasksSeeder extends Seeder
{
public function run()
{
$tasks = [
// Liebe-Tasks
[
'emotion' => 'Liebe',
'tasks' => [
[
'title' => 'Kuss-Foto',
'description' => 'Macht ein romantisches Kuss-Foto',
'difficulty' => 'easy',
'example_text' => 'Einfach küssen und lächeln!'
],
[
'title' => 'Händchen halten',
'description' => 'Zeigt eure Hände - verheiratet und verliebt',
'difficulty' => 'easy',
'example_text' => 'Ringe in Szene setzen'
],
[
'title' => 'Liebes-Pose',
'description' => 'Stellt die romantischste Szene aus einem Film nach',
'difficulty' => 'medium',
'example_text' => 'Titanic, Dirty Dancing oder eure Lieblings-Romanze'
],
[
'title' => 'Herz formen',
'description' => 'Formt mit euren Händen ein großes Herz',
'difficulty' => 'easy',
'example_text' => 'Gemeinsam über dem Kopf oder vor der Brust'
]
]
],
// Freude-Tasks
[
'emotion' => 'Freude',
'tasks' => [
[
'title' => 'Sprung-Foto',
'description' => 'Alle springen gleichzeitig in die Luft!',
'difficulty' => 'medium',
'example_text' => 'Auf drei: eins, zwei, drei - SPRUNG!'
],
[
'title' => 'Grimassen-Contest',
'description' => 'Macht die verrücktesten Gesichter',
'difficulty' => 'easy',
'example_text' => 'Je alberner, desto besser!'
],
[
'title' => 'Lach-Attacke',
'description' => 'Alle lachen so laut sie können',
'difficulty' => 'easy',
'example_text' => 'Echtes Lachen ist am schönsten'
],
[
'title' => 'Tanz-Pose',
'description' => 'Zeigt euren besten Tanz-Move',
'difficulty' => 'medium',
'example_text' => 'Disco, Hip-Hop oder klassischer Walzer'
]
]
],
// Rührung-Tasks
[
'emotion' => 'Rührung',
'tasks' => [
[
'title' => 'Tränen der Freude',
'description' => 'Zeigt eure emotionalen Gesichter',
'difficulty' => 'easy',
'example_text' => 'Echte Gefühle sind die schönsten'
],
[
'title' => 'Familien-Umarmung',
'description' => 'Große Gruppenumarmung mit der ganzen Familie',
'difficulty' => 'medium',
'example_text' => 'Alle kommen zusammen für eine warme Umarmung'
],
[
'title' => 'Stille Momente',
'description' => 'Ein ruhiges, besinnliches Foto',
'difficulty' => 'medium',
'example_text' => 'Manchmal sagt ein stiller Blick mehr als tausend Worte'
]
]
],
// Weitere Emotionen...
];
foreach ($tasks as $emotionGroup) {
$emotion = Emotion::where('name', $emotionGroup['emotion'])->first();
if ($emotion) {
foreach ($emotionGroup['tasks'] as $taskData) {
Task::create([
'emotion_id' => $emotion->id,
'title' => $taskData['title'],
'description' => $taskData['description'],
'difficulty' => $taskData['difficulty'],
'example_text' => $taskData['example_text'] ?? null
]);
}
}
}
}
}
```
### Production Deployment
#### 1. Shared Hosting Setup
```bash
# .htaccess für Laravel in public_html
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/public/
RewriteRule ^(.*)$ /public/$1 [L]
# Laravel-Optimierungen
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Frontend Build
npm run build
```
#### 2. VPS/Cloud Deployment (mit Docker)
```dockerfile
# Dockerfile
FROM php:8.4-apache
# PHP Extensions
RUN docker-php-ext-install pdo pdo_sqlite gd
# Apache Konfiguration
RUN a2enmod rewrite
COPY . /var/www/html
COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf
# Permissions
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
# Frontend Build
RUN npm ci && npm run build
EXPOSE 80
```
#### 3. Produktions-Optimierungen
**Laravel Optimierungen:**
```php
// config/app.php - Produktionseinstellungen
'debug' => false,
'log_level' => 'error',
// config/cache.php - File-Cache für bessere Performance
'default' => 'file',
// config/session.php - Datei-basierte Sessions
'driver' => 'file',
'lifetime' => 720, // 12 Stunden für Hochzeitsfeier
```
**Nginx Konfiguration:**
```nginx
server {
listen 80;
server_name your-domain.com;
root /var/www/hochzeits-fotobox/public;
index index.php;
# Gzip Kompression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# Caching für statische Assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# PHP Processing
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
# Laravel Routes
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
```
## 🧪 Testing & Qualitätssicherung
### Automatisierte Tests
#### 1. Backend Tests (PHPUnit)
```php
<?php
// tests/Feature/PhotoUploadTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\{Event, Emotion, Task};
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class PhotoUploadTest extends TestCase
{
public function test_photo_upload_successful()
{
Storage::fake('public');
$event = Event::factory()->create();
$emotion = Emotion::factory()->create();
$task = Task::factory()->create(['emotion_id' => $emotion->id]);
$file = UploadedFile::fake()->image('test-photo.jpg', 1200, 1600);
$response = $this->postJson("/api/events/{$event->slug}/photos", [
'photo' => $file,
'emotion_id' => $emotion->id,
'task_id' => $task->id,
'guest_name' => 'Test Gast'
]);
$response->assertStatus(201)
->assertJsonStructure([
'success',
'data' => [
'id',
'file_path',
'thumbnail_path',
'guest_name',
'emotion',
'task'
]
]);
// Dateien wurden erstellt
Storage::disk('public')->assertExists('photos/' . $file->hashName());
Storage::disk('public')->assertExists('thumbnails/' . $file->hashName());
// Datenbank-Eintrag
$this->assertDatabaseHas('photos', [
'event_id' => $event->id,
'guest_name' => 'Test Gast',
'emotion_id' => $emotion->id
]);
}
}
```
#### 2. Frontend Tests (Jest + React Testing Library)
```javascript
// src/__tests__/EmotionBubble.test.jsx
import React from 'react'
import { render, fireEvent, screen } from '@testing-library/react'
import EmotionBubble from '../components/Emotions/EmotionBubble'
const mockEmotion = {
id: 1,
name: 'Freude',
icon: '😂',
color: '#ffd93d'
}
describe('EmotionBubble', () => {
test('renders emotion correctly', () => {
render(
)
expect(screen.getByText('😂')).toBeInTheDocument()
expect(screen.getByText('Freude')).toBeInTheDocument()
})
test('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledWith(1)
})
test('applies correct styling when active', () => {
render(
)
const bubble = screen.getByRole('button')
expect(bubble).toHaveClass('ring-4')
})
})
```
### Performance-Monitoring
#### 1. Laravel Telescope (Development)
```bash
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
```
#### 2. Frontend Performance (Web Vitals)
```javascript
// src/utils/analytics.js
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
function sendToAnalytics(metric) {
// Production Analytics Service
if (process.env.NODE_ENV === 'production') {
console.log('Web Vital:', metric)
// Beispiel: Sende zu Google Analytics
// gtag('event', 'web_vitals', {
// event_category: 'Web Vitals',
// event_action: metric.name,
// event_value: Math.round(metric.value),
// non_interaction: true,
// })
}
}
export function initializeWebVitals() {
getCLS(sendToAnalytics)
getFID(sendToAnalytics)
getFCP(sendToAnalytics)
getLCP(sendToAnalytics)
getTTFB(sendToAnalytics)
}
```
## 📊 Analytics & Monitoring
### Event-Tracking
```javascript
// src/services/eventTracking.js
class EventTracker {
constructor(eventSlug) {
this.eventSlug = eventSlug
this.events = []
}
track(action, data = {}) {
const event = {
timestamp: new Date().toISOString(),
action,
data,
eventSlug: this.eventSlug,
sessionId: this.getSessionId(),
userAgent: navigator.userAgent
}
this.events.push(event)
this.sendToServer(event)
}
// Wichtige Events
trackPhotoCapture(emotionId, taskId) {
this.track('photo_capture', { emotionId, taskId })
}
trackPhotoUpload(photoId, success) {
this.track('photo_upload', { photoId, success })
}
trackEmotionSelect(emotionId) {
this.track('emotion_select', { emotionId })
}
trackTaskSkip(taskId) {
this.track('task_skip', { taskId })
}
sendToServer(event) {
// API-Endpoint für Analytics
fetch(`/api/events/${this.eventSlug}/analytics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
## Frontend PWA — Status Update (Sep 2025)
Implemented guest PWA per plan with React/Vite/Tailwind and PWA plugin. Highlights:
- App Structure
- Routes: /event/:slug/home, /event/:slug/camera, /event/:slug/gallery, /event/:slug/achievements, /legal/:slug.
- Event home adopts the event landing romance gradient; dark-mode override added.
- Offline fonts bundled (Inter 400/600, Playfair Display 400/700, Great Vibes 400).
- UI/UX
- Header settings as a sheet (language + theme). Dark/light/system theme with class-based Tailwind and no-flash boot.
- Camera page with floating capture bar: compact emotion scroller, large shutter with animation, last-capture thumbnail (opens Gallery), task lock badge.
- Gallery: grid, modal (Dialog), likes, skeleton placeholders, emotion filter and deep link.
- Achievements page (new): tabs People/Groups/Live. People aggregates claimed tasks by guest; Live shows recent task-claimed photos. i18n included.
- Navigation: replaced "Camera" tab with "Achievements" (trophy). Camera reachable via Home CTA.
- Data & APIs (current usage)
- Events: GET /api/events/{slug} (returns localized fields and palette when available).
- Emotions: GET /api/events/{slug}/emotions.
- Tasks: GET /api/events/{slug}/tasks/random/{emotion}.
- Photos: GET /api/events/{slug}/photos, POST /api/events/{slug}/photos (compressed < 1.5MB), POST /api/events/{slug}/photos/{photo}/like.
- Legal: GET /api/legal/{slug} (markdown rendered on client).
- PWA & Offline
- Compression to < 1.5MB before upload, EXIF stripped; offline queue with retry on "online"; toasts for success/queued/error.
- Workbox via vite-plugin-pwa; further SW Background Sync planned.
- shadcn-style Layer
- Added Button, Card, Dialog, Sheet, Skeleton, Badge primitives for consistency.
- Super Admin Login
- Switched to classic Laravel session login at /super-admin/login; Filament panel guarded by middleware.
- QR & Landings
- Root landing with QR scanner (zxing) + manual code entry; event landing redesigned (typography + romance gradient).
- Demo Data
- Seeder DemoAchievementsSeeder creates demo photos with ask_id for the Achievements page.
### Gamification Direction (Short-Event)
- Focus on “solved tasks”: photo claims a task (client), future: peer votes to verify.
- Achievements ideas saved: docs/achievements/ideas.md.
- Planned endpoint: POST /api/events/{slug}/photos/{photo}/task-vote { verdict: 'pass'|'fail', guest_name } ? returns { pass, fail, verified }.
### Open Items / Next
- Task verification voting (backend + modal UI) and badges (First/Finisher/Sampler/Assist etc.).
- SW Background Sync for uploads; install prompt + shortcuts.
- Admin: palette editor; (optional) groups/teams for Achievements.
- Accessibility polish, reduced-motion paths.
{task.emotion?.icon}
{task.emotion?.name}
{/* Task Title */}
{task.title}
{/* Task Description */}{task.description}
{/* Difficulty Indicator */}
{[...Array(3)].map((_, i) => (
))}
{task.difficulty}
{/* Example Hint */}
{task.example_text && (
💡 {task.example_text}
)}