3741 lines
130 KiB
Markdown
3741 lines
130 KiB
Markdown
## ðŸ—ï¸ **Multi-Tenant System-Architektur**
|
||
|
||
Note: Event Type-aware UI
|
||
- Theme colors can come from `event_types.settings.palette` to skin headers/buttons per type.
|
||
- Emotion picker fetches type-filtered emotions via `/api/events/:slug/emotions`.
|
||
- Random task generator and gallery filters respect event type automatically via API.
|
||
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────â”
|
||
│ SYSTEM OVERVIEW (Multi-Tenant) │
|
||
├─────────────────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 🔧 SUPER ADMIN 👰🤵 BRAUTPAARE 📱 GÄSTE PWA │
|
||
│ ┌─────────────────┠┌─────────────────┠┌─────────────────┠│
|
||
│ │ System Admin │ │ Customer Admin │ │ React PWA │ │
|
||
│ │ Panel │◄──►│ Panel │◄──►│ Event Photos │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ • User Mgmt │ │ • Event Setup │ │ • Take Photos │ │
|
||
│ │ • System Config │ │ • Photo Review │ │ • Upload Share │ │
|
||
│ │ • Billing │ │ • Gallery Build │ │ • Live Feed │ │
|
||
│ │ • Analytics │ │ • Export │ │ • Like/Comment │ │
|
||
│ │ • Monitoring │ │ │ │ │ │
|
||
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
||
│ │ │ │ │
|
||
│ └───────────────────────┼───────────────────────┘ │
|
||
│ │ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────â”│
|
||
│ │ LARAVEL BACKEND API (Multi-Tenant) ││
|
||
│ │ • Tenant Isolation • User Management • Billing System ││
|
||
│ │ • System Monitoring • Global Analytics • Platform Administration ││
|
||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## 👑 **Super Admin System (Platform Management)**
|
||
|
||
### **Multi-Tenant Database Schema:**
|
||
```sql
|
||
-- Tenant/Customer Management
|
||
CREATE TABLE tenants (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name VARCHAR(255) NOT NULL, -- "Familie Müller"
|
||
slug VARCHAR(255) UNIQUE NOT NULL, -- "familie-mueller"
|
||
domain VARCHAR(255) UNIQUE, -- Optional: custom domain
|
||
|
||
-- Contact Information
|
||
contact_name VARCHAR(255) NOT NULL,
|
||
contact_email VARCHAR(255) NOT NULL,
|
||
contact_phone VARCHAR(255),
|
||
|
||
-- Event-basierte Monetarisierung (keine Subscriptions)
|
||
event_credits_balance INTEGER DEFAULT 1,
|
||
free_event_granted_at TIMESTAMP,
|
||
|
||
-- Limits & Quotas
|
||
max_photos_per_event INTEGER DEFAULT 500,
|
||
max_storage_mb INTEGER DEFAULT 1024, -- 1GB
|
||
|
||
-- Feature Flags
|
||
features JSON, -- {"custom_branding": true, "api_access": false}
|
||
|
||
-- Metadata
|
||
last_activity_at TIMESTAMP,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- Erweiterte User-Tabelle für Multi-Tenancy
|
||
ALTER TABLE users ADD COLUMN tenant_id INTEGER;
|
||
ALTER TABLE users ADD COLUMN role ENUM('super_admin', 'tenant_admin', 'tenant_user') DEFAULT 'tenant_user';
|
||
ALTER TABLE users ADD CONSTRAINT fk_users_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
|
||
|
||
-- Events gehören zu Tenants
|
||
ALTER TABLE events ADD COLUMN tenant_id INTEGER NOT NULL;
|
||
ALTER TABLE events ADD CONSTRAINT fk_events_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
|
||
|
||
-- System-weite Konfiguration
|
||
CREATE TABLE system_settings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
key VARCHAR(255) UNIQUE NOT NULL,
|
||
value TEXT,
|
||
description TEXT,
|
||
is_public BOOLEAN DEFAULT 0, -- Für öffentliche API settings
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- Platform Analytics
|
||
CREATE TABLE platform_analytics (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
tenant_id INTEGER,
|
||
metric_name VARCHAR(100) NOT NULL, -- 'photos_uploaded', 'events_created'
|
||
metric_value INTEGER NOT NULL,
|
||
metric_date DATE NOT NULL,
|
||
metadata JSON,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||
INDEX idx_analytics_date_metric (metric_date, metric_name),
|
||
INDEX idx_analytics_tenant (tenant_id, metric_date)
|
||
);
|
||
|
||
-- Event Purchases (one-time) und Guthaben-Ledger
|
||
CREATE TABLE event_purchases (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
tenant_id INTEGER NOT NULL,
|
||
events_purchased INTEGER NOT NULL DEFAULT 1,
|
||
amount DECIMAL(10,2) NOT NULL,
|
||
currency VARCHAR(3) DEFAULT 'EUR',
|
||
provider ENUM('app_store', 'play_store', 'stripe', 'paypal') NOT NULL,
|
||
external_receipt_id VARCHAR(255),
|
||
status ENUM('pending', 'paid', 'failed', 'refunded', 'cancelled') DEFAULT 'pending',
|
||
purchased_at TIMESTAMP,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||
INDEX idx_event_purchases_tenant (tenant_id, purchased_at)
|
||
);
|
||
|
||
CREATE TABLE event_credits_ledger (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
tenant_id INTEGER NOT NULL,
|
||
delta INTEGER NOT NULL,
|
||
reason ENUM('initial_free','purchase','manual_adjustment','event_created','refund') NOT NULL,
|
||
related_purchase_id INTEGER,
|
||
note TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
|
||
FOREIGN KEY (related_purchase_id) REFERENCES event_purchases(id),
|
||
INDEX idx_ledger_tenant (tenant_id, created_at)
|
||
);
|
||
```
|
||
|
||
## ðŸ› ï¸ **Super Admin Panel (Separate Filament Panel)**
|
||
|
||
Note (Filament 4):
|
||
- Forms/Infolists components live under `Filament\Schema\Components` (updated below).
|
||
- Table row actions use `recordActions()`; actions are unified under `Filament\Actions`.
|
||
- Keep `Tables\Columns`/`Tables\Filters` as-is; columns/filters remain in Tables.
|
||
|
||
### **Super Admin Panel Provider:**
|
||
```php
|
||
<?php
|
||
// app/Providers/Filament/SuperAdminPanelProvider.php
|
||
|
||
namespace App\Providers\Filament;
|
||
|
||
use Filament\Panel;
|
||
use Filament\PanelProvider;
|
||
use Filament\Support\Colors\Color;
|
||
use Filament\Pages;
|
||
|
||
class SuperAdminPanelProvider extends PanelProvider
|
||
{
|
||
public function panel(Panel $panel): Panel
|
||
{
|
||
return $panel
|
||
->id('super-admin')
|
||
->path('super-admin')
|
||
->login()
|
||
->colors([
|
||
'primary' => Color::Indigo,
|
||
'secondary' => Color::Slate,
|
||
])
|
||
->brandName('Fotobox Platform Admin')
|
||
->brandLogo(asset('images/platform-logo.png'))
|
||
->discoverResources(in: app_path('Filament/SuperAdmin/Resources'), for: 'App\\Filament\\SuperAdmin\\Resources')
|
||
->discoverPages(in: app_path('Filament/SuperAdmin/Pages'), for: 'App\\Filament\\SuperAdmin\\Pages')
|
||
->pages([
|
||
Pages\Dashboard::class,
|
||
])
|
||
->widgets([
|
||
\App\Filament\SuperAdmin\Widgets\PlatformStatsWidget::class,
|
||
\App\Filament\SuperAdmin\Widgets\RevenueChartWidget::class,
|
||
\App\Filament\SuperAdmin\Widgets\TenantActivityWidget::class,
|
||
])
|
||
->navigationGroups([
|
||
'Customer Management',
|
||
'Platform Configuration',
|
||
'Legal & Compliance',
|
||
'Billing & Events',
|
||
'System Monitoring',
|
||
'Content Management'
|
||
])
|
||
->authMiddleware([
|
||
\App\Http\Middleware\SuperAdminOnly::class,
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 👥 **Tenant Management Resource**
|
||
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Resources/TenantResource.php
|
||
|
||
namespace App\Filament\SuperAdmin\Resources;
|
||
|
||
use App\Models\Tenant;
|
||
use Filament\Forms;
|
||
use Filament\Forms\Form;
|
||
use Filament\Schema as Schema;
|
||
use Filament\Actions;
|
||
use Filament\Resources\Resource;
|
||
use Filament\Tables;
|
||
use Filament\Tables\Table;
|
||
|
||
class TenantResource extends Resource
|
||
{
|
||
protected static ?string $model = Tenant::class;
|
||
protected static ?string $navigationIcon = 'heroicon-o-building-office';
|
||
protected static ?string $navigationGroup = 'Customer Management';
|
||
protected static ?string $label = 'Kunden';
|
||
|
||
public static function form(Form $form): Form
|
||
{
|
||
return $form->schema([
|
||
Schema\\Components\\Section::make('Kunden-Details')
|
||
->schema([
|
||
Schema\\Components\\TextInput::make('name')
|
||
->label('Kundenname')
|
||
->required()
|
||
->maxLength(255),
|
||
|
||
Schema\\Components\\TextInput::make('slug')
|
||
->required()
|
||
->unique(Tenant::class, 'slug', ignoreRecord: true)
|
||
->helperText('Eindeutige URL für den Kunden'),
|
||
|
||
Schema\\Components\\TextInput::make('domain')
|
||
->label('Custom Domain (optional)')
|
||
->url()
|
||
->unique(Tenant::class, 'domain', ignoreRecord: true),
|
||
|
||
Schema\\Components\\TextInput::make('contact_name')
|
||
->label('Ansprechpartner')
|
||
->required(),
|
||
|
||
Schema\\Components\\TextInput::make('contact_email')
|
||
->label('E-Mail')
|
||
->email()
|
||
->required(),
|
||
|
||
Schema\\Components\\TextInput::make('contact_phone')
|
||
->label('Telefon')
|
||
->tel(),
|
||
])
|
||
->columns(2),
|
||
|
||
Schema\\Components\\Section::make('Subscription Management')
|
||
->schema([
|
||
Schema\\Components\\Select::make('plan_type')
|
||
->label('Plan')
|
||
->options([
|
||
'free' => 'Free (Testversion)',
|
||
'basic' => 'Basic (29€/Monat)',
|
||
'premium' => 'Premium (49€/Monat)',
|
||
'enterprise' => 'Enterprise (Custom)'
|
||
])
|
||
->required()
|
||
->live(),
|
||
|
||
Schema\\Components\\Select::make('subscription_status')
|
||
->options([
|
||
'trial' => 'Trial',
|
||
'active' => 'Aktiv',
|
||
'suspended' => 'Suspendiert',
|
||
'cancelled' => 'Gekündigt'
|
||
])
|
||
->required(),
|
||
|
||
Schema\\Components\\DateTimePicker::make('subscription_starts_at')
|
||
->label('Abo Start'),
|
||
|
||
Schema\\Components\\DateTimePicker::make('subscription_ends_at')
|
||
->label('Abo Ende'),
|
||
|
||
Schema\\Components\\DateTimePicker::make('trial_ends_at')
|
||
->label('Trial Ende')
|
||
->visible(fn ($get) => $get('subscription_status') === 'trial'),
|
||
])
|
||
->columns(2),
|
||
|
||
Schema\\Components\\Section::make('Limits & Kontingente')
|
||
->schema([
|
||
Schema\\Components\\TextInput::make('max_events')
|
||
->label('Max. Events')
|
||
->numeric()
|
||
->default(1),
|
||
|
||
Schema\\Components\\TextInput::make('max_photos_per_event')
|
||
->label('Max. Fotos pro Event')
|
||
->numeric()
|
||
->default(500),
|
||
|
||
Schema\\Components\\TextInput::make('max_storage_mb')
|
||
->label('Max. Speicher (MB)')
|
||
->numeric()
|
||
->default(1024),
|
||
])
|
||
->columns(3),
|
||
|
||
Schema\\Components\\Section::make('Feature Flags')
|
||
->schema([
|
||
Schema\\Components\\KeyValue::make('features')
|
||
->label('Aktivierte Features')
|
||
->keyLabel('Feature')
|
||
->valueLabel('Aktiviert')
|
||
->default([
|
||
'custom_branding' => false,
|
||
'api_access' => false,
|
||
'advanced_analytics' => false,
|
||
'white_label' => false
|
||
]),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\Columns\TextColumn::make('name')
|
||
->searchable()
|
||
->sortable(),
|
||
|
||
Tables\Columns\TextColumn::make('contact_email')
|
||
->label('Kontakt')
|
||
->searchable(),
|
||
|
||
Tables\Columns\TextColumn::make('event_credits_balance')
|
||
->label('Event-Guthaben')
|
||
->badge()
|
||
|
||
|
||
|
||
Tables\Columns\TextColumn::make('events_count')
|
||
->label('Events')
|
||
->counts('events'),
|
||
|
||
Tables\Columns\TextColumn::make('total_photos_count')
|
||
->label('Gesamt Fotos')
|
||
->getStateUsing(fn ($record) =>
|
||
$record->events()->withCount('photos')->get()->sum('photos_count')
|
||
),
|
||
|
||
Tables\Columns\TextColumn::make('free_event_granted_at')
|
||
->label('Kostenloses Event am')
|
||
->date('d.m.Y')
|
||
->sortable(),
|
||
|
||
Tables\Columns\TextColumn::make('last_activity_at')
|
||
->label('Letzte Aktivität')
|
||
->since()
|
||
->sortable(),
|
||
])
|
||
->filters([
|
||
Tables\Filters\SelectFilter::make('plan_type')
|
||
->options([
|
||
'free' => 'Free',
|
||
'basic' => 'Basic',
|
||
'premium' => 'Premium',
|
||
'enterprise' => 'Enterprise'
|
||
]),
|
||
|
||
Tables\Filters\SelectFilter::make('subscription_status')
|
||
->options([
|
||
'trial' => 'Trial',
|
||
'active' => 'Aktiv',
|
||
'suspended' => 'Suspendiert',
|
||
'cancelled' => 'Gekündigt'
|
||
]),
|
||
|
||
Tables\Filters\Filter::make('expiring_soon')
|
||
->label('Läuft bald ab')
|
||
->query(fn ($query) => $query->where('subscription_ends_at', '<=', now()->addDays(30))),
|
||
])
|
||
->recordActions([
|
||
Actions\\ViewAction::make(),
|
||
Actions\\EditAction::make(),
|
||
Actions\\Action::make('login_as')
|
||
->label('Als Kunde anmelden')
|
||
->icon('heroicon-o-arrow-right-on-rectangle')
|
||
->url(fn ($record) => "/admin/impersonate/{$record->id}")
|
||
->openUrlInNewTab(),
|
||
|
||
])
|
||
->bulkActions([
|
||
Actions\\BulkAction::make('send_notification')
|
||
->label('Benachrichtigung senden')
|
||
->icon('heroicon-o-envelope')
|
||
->form([
|
||
Schema\\Components\\TextInput::make('subject')
|
||
->label('Betreff')
|
||
->required(),
|
||
Schema\\Components\\Textarea::make('message')
|
||
->label('Nachricht')
|
||
->required()
|
||
->rows(4),
|
||
])
|
||
->action(function (array $data, Collection $records) {
|
||
// Send bulk notification to selected customers
|
||
foreach ($records as $tenant) {
|
||
// Send email notification
|
||
}
|
||
}),
|
||
])
|
||
->defaultSort('created_at', 'desc');
|
||
}
|
||
|
||
public static function getPages(): array
|
||
{
|
||
return [
|
||
'index' => Pages\ListTenants::route('/'),
|
||
'create' => Pages\CreateTenant::route('/create'),
|
||
'edit' => Pages\EditTenant::route('/{record}/edit'),
|
||
'view' => Pages\ViewTenant::route('/{record}'),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
## 💰 **Subscription Plans Resource**
|
||
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Resources/SubscriptionPlanResource.php
|
||
|
||
namespace App\Filament\SuperAdmin\Resources;
|
||
|
||
use App\Models\SubscriptionPlan;
|
||
use Filament\Forms;
|
||
use Filament\Forms\Form;
|
||
use Filament\\Schema as Schema;
|
||
use Filament\\Actions;
|
||
use Filament\Resources\Resource;
|
||
use Filament\Tables;
|
||
use Filament\Tables\Table;
|
||
|
||
class SubscriptionPlanResource extends Resource
|
||
{
|
||
protected static ?string $model = SubscriptionPlan::class;
|
||
protected static ?string $navigationIcon = 'heroicon-o-credit-card';
|
||
protected static ?string $navigationGroup = 'Billing & Subscriptions';
|
||
protected static ?string $label = 'Abo-Pläne';
|
||
|
||
public static function form(Form $form): Form
|
||
{
|
||
return $form->schema([
|
||
Schema\\Components\\Section::make('Plan Details')
|
||
->schema([
|
||
Schema\\Components\\TextInput::make('name')
|
||
->required()
|
||
->maxLength(100),
|
||
|
||
Schema\\Components\\TextInput::make('slug')
|
||
->required()
|
||
->unique(SubscriptionPlan::class, 'slug', ignoreRecord: true),
|
||
|
||
Schema\\Components\\TextInput::make('price_monthly')
|
||
->label('Monatspreis (€)')
|
||
->numeric()
|
||
->prefix('€'),
|
||
|
||
Schema\\Components\\TextInput::make('price_yearly')
|
||
->label('Jahrespreis (€)')
|
||
->numeric()
|
||
->prefix('€'),
|
||
|
||
Schema\\Components\\Toggle::make('is_active')
|
||
->label('Plan aktiv')
|
||
->default(true),
|
||
])
|
||
->columns(2),
|
||
|
||
Schema\\Components\\Section::make('Plan Limits')
|
||
->schema([
|
||
Schema\\Components\\TextInput::make('max_events')
|
||
->label('Max. Events')
|
||
->numeric()
|
||
->required(),
|
||
|
||
Schema\\Components\\TextInput::make('max_photos_per_event')
|
||
->label('Max. Fotos pro Event')
|
||
->numeric()
|
||
->required(),
|
||
|
||
Schema\\Components\\TextInput::make('max_storage_gb')
|
||
->label('Max. Speicher (GB)')
|
||
->numeric()
|
||
->required(),
|
||
])
|
||
->columns(3),
|
||
|
||
Schema\\Components\\Section::make('Features')
|
||
->schema([
|
||
Schema\\Components\\KeyValue::make('features')
|
||
->label('Plan Features')
|
||
->keyLabel('Feature')
|
||
->valueLabel('Enthalten')
|
||
->default([
|
||
'custom_branding' => false,
|
||
'api_access' => false,
|
||
'advanced_analytics' => false,
|
||
'priority_support' => false,
|
||
'white_label' => false,
|
||
'custom_domain' => false
|
||
]),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\Columns\TextColumn::make('name')
|
||
->searchable()
|
||
->sortable(),
|
||
|
||
Tables\Columns\TextColumn::make('price_monthly')
|
||
->label('Monatlich')
|
||
->money('EUR'),
|
||
|
||
Tables\Columns\TextColumn::make('price_yearly')
|
||
->label('Jährlich')
|
||
->money('EUR'),
|
||
|
||
Tables\Columns\TextColumn::make('max_events')
|
||
->label('Events'),
|
||
|
||
Tables\Columns\TextColumn::make('max_photos_per_event')
|
||
->label('Fotos/Event'),
|
||
|
||
Tables\Columns\TextColumn::make('max_storage_gb')
|
||
->label('Storage (GB)'),
|
||
|
||
Tables\Columns\TextColumn::make('subscribers_count')
|
||
->label('Kunden')
|
||
->getStateUsing(fn ($record) => $record->tenants()->count()),
|
||
|
||
Tables\Columns\IconColumn::make('is_active')
|
||
->boolean(),
|
||
])
|
||
->recordActions([
|
||
Actions\\EditAction::make(),
|
||
Actions\\Action::make('duplicate')
|
||
->label('Duplizieren')
|
||
->icon('heroicon-o-document-duplicate')
|
||
->action(function ($record) {
|
||
$newPlan = $record->replicate();
|
||
$newPlan->name = $record->name . ' (Kopie)';
|
||
$newPlan->slug = $record->slug . '-copy';
|
||
$newPlan->save();
|
||
}),
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 📊 **System Settings Resource**
|
||
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Resources/SystemSettingResource.php
|
||
|
||
namespace App\Filament\SuperAdmin\Resources;
|
||
|
||
use App\Models\SystemSetting;
|
||
use Filament\Forms;
|
||
use Filament\Forms\Form;
|
||
use Filament\\Schema as Schema;
|
||
use Filament\\Actions;
|
||
use Filament\Resources\Resource;
|
||
use Filament\Tables;
|
||
use Filament\Tables\Table;
|
||
|
||
class SystemSettingResource extends Resource
|
||
{
|
||
protected static ?string $model = SystemSetting::class;
|
||
protected static ?string $navigationIcon = 'heroicon-o-cog-6-tooth';
|
||
protected static ?string $navigationGroup = 'Platform Configuration';
|
||
protected static ?string $label = 'System-Einstellungen';
|
||
|
||
public static function form(Form $form): Form
|
||
{
|
||
return $form->schema([
|
||
Schema\\Components\\Section::make('Setting Details')
|
||
->schema([
|
||
Schema\\Components\\TextInput::make('key')
|
||
->required()
|
||
->unique(SystemSetting::class, 'key', ignoreRecord: true)
|
||
->helperText('Eindeutiger Key für die Einstellung'),
|
||
|
||
Schema\\Components\\Textarea::make('value')
|
||
->required()
|
||
->rows(3)
|
||
->helperText('JSON, String oder andere Werte'),
|
||
|
||
Schema\\Components\\Textarea::make('description')
|
||
->rows(2)
|
||
->helperText('Beschreibung der Einstellung'),
|
||
|
||
Schema\\Components\\Toggle::make('is_public')
|
||
->label('Öffentlich verfügbar')
|
||
->helperText('Über öffentliche API abrufbar'),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\Columns\TextColumn::make('key')
|
||
->searchable()
|
||
->sortable(),
|
||
|
||
Tables\Columns\TextColumn::make('value')
|
||
->limit(50)
|
||
->searchable(),
|
||
|
||
Tables\Columns\TextColumn::make('description')
|
||
->limit(100),
|
||
|
||
Tables\Columns\IconColumn::make('is_public')
|
||
->boolean(),
|
||
|
||
Tables\Columns\TextColumn::make('updated_at')
|
||
->label('Zuletzt geändert')
|
||
->dateTime('d.m.Y H:i')
|
||
->sortable(),
|
||
])
|
||
->filters([
|
||
Tables\Filters\TernaryFilter::make('is_public')
|
||
->label('Öffentliche Einstellungen'),
|
||
])
|
||
->actions([
|
||
Actions\\EditAction::make(),
|
||
])
|
||
->groups([
|
||
'category' => Tables\Grouping\Group::make('key')
|
||
->label('Kategorie')
|
||
->getDescriptionFromRecordUsing(fn ($record) => explode('.', $record->key)[0])
|
||
]);
|
||
}
|
||
|
||
// Predefined System Settings
|
||
public static function getDefaultSettings(): array
|
||
{
|
||
return [
|
||
'platform.max_file_size' => '10485760', // 10MB
|
||
'platform.allowed_image_types' => 'jpeg,jpg,png,webp',
|
||
// Event-basierte Preisgestaltung
|
||
'pricing.event_price_eur' => '9.99',
|
||
'pricing.free_event_on_app_purchase' => 'true',
|
||
'email.from_address' => 'noreply@fotobox-platform.de',
|
||
'email.support_address' => 'support@fotobox-platform.de',
|
||
'analytics.google_analytics_id' => '',
|
||
'features.maintenance_mode' => 'false',
|
||
'features.new_registrations' => 'true',
|
||
'storage.default_disk' => 'local',
|
||
'storage.cdn_url' => '',
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
## 📊 **Super Admin Widgets**
|
||
|
||
### **Platform Statistics Widget:**
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Widgets/PlatformStatsWidget.php
|
||
|
||
namespace App\Filament\SuperAdmin\Widgets;
|
||
|
||
use App\Models\{Tenant, Event, Photo, User};
|
||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||
|
||
class PlatformStatsWidget extends BaseWidget
|
||
{
|
||
protected function getStats(): array
|
||
{
|
||
return [
|
||
Stat::make('Kunden mit Events', Tenant::whereHas('events')->count())
|
||
->description('Mindestens ein Event vorhanden')
|
||
->descriptionIcon('heroicon-m-building-office')
|
||
->color('success'),
|
||
|
||
Stat::make('Gesamt Events', Event::count())
|
||
->description('Alle Events auf der Platform')
|
||
->descriptionIcon('heroicon-m-calendar-days')
|
||
->color('primary'),
|
||
|
||
Stat::make('Gesamt Fotos', Photo::count())
|
||
->description('Hochgeladene Fotos')
|
||
->descriptionIcon('heroicon-m-photo')
|
||
->color('warning'),
|
||
|
||
Stat::make('Monthly Recurring Revenue',
|
||
Tenant::where('subscription_status', 'active')
|
||
->whereIn('plan_type', ['basic', 'premium'])
|
||
->get()
|
||
->sum(fn($tenant) => match($tenant->plan_type) {
|
||
'basic' => 29,
|
||
'premium' => 49,
|
||
default => 0
|
||
})
|
||
)
|
||
->description('€ MRR')
|
||
->descriptionIcon('heroicon-m-currency-euro')
|
||
->color('success'),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
### **Revenue Chart Widget:**
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Widgets/RevenueChartWidget.php
|
||
|
||
namespace App\Filament\SuperAdmin\Widgets;
|
||
|
||
use App\Models\EventPurchase;
|
||
use Filament\Widgets\ChartWidget;
|
||
|
||
class RevenueChartWidget extends ChartWidget
|
||
{
|
||
protected static ?string $heading = 'Umsatz (Letzte 12 Monate)';
|
||
|
||
protected function getData(): array
|
||
{
|
||
$data = EventPurchase::where('status', 'paid')
|
||
->where('created_at', '>=', now()->subYear())
|
||
->selectRaw('DATE_FORMAT(created_at, "%Y-%m") as month, SUM(amount) as revenue')
|
||
->groupBy('month')
|
||
->orderBy('month')
|
||
->get();
|
||
|
||
return [
|
||
'datasets' => [
|
||
[
|
||
'label' => 'Umsatz (€)',
|
||
'data' => $data->pluck('revenue'),
|
||
'borderColor' => 'rgb(99, 102, 241)',
|
||
'backgroundColor' => 'rgba(99, 102, 241, 0.1)',
|
||
],
|
||
],
|
||
'labels' => $data->pluck('month'),
|
||
];
|
||
}
|
||
|
||
protected function getType(): string
|
||
{
|
||
return 'line';
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🔠**Super Admin Middleware**
|
||
|
||
```php
|
||
<?php
|
||
// app/Http/Middleware/SuperAdminOnly.php
|
||
|
||
namespace App\Http\Middleware;
|
||
|
||
use Closure;
|
||
use Illuminate\Http\Request;
|
||
|
||
class SuperAdminOnly
|
||
{
|
||
public function handle(Request $request, Closure $next)
|
||
{
|
||
if (!auth()->check() || auth()->user()->role !== 'super_admin') {
|
||
abort(403, 'Access denied. Super Admin privileges required.');
|
||
}
|
||
|
||
return $next($request);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🢠**Tenant Scoping Middleware**
|
||
|
||
```php
|
||
<?php
|
||
// app/Http/Middleware/TenantScope.php
|
||
|
||
namespace App\Http\Middleware;
|
||
|
||
use Closure;
|
||
use Illuminate\Http\Request;
|
||
use App\Models\Tenant;
|
||
|
||
class TenantScope
|
||
{
|
||
public function handle(Request $request, Closure $next)
|
||
{
|
||
if (auth()->user()->role === 'super_admin') {
|
||
return $next($request); // Super Admin bypasses tenant scoping
|
||
}
|
||
|
||
$tenant = auth()->user()->tenant;
|
||
|
||
if (!$tenant) {
|
||
abort(403, 'No tenant associated with user.');
|
||
}
|
||
|
||
// Set global tenant scope
|
||
config(['app.current_tenant' => $tenant]);
|
||
|
||
return $next($request);
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🎯 **Super Admin Navigation Struktur**
|
||
|
||
```
|
||
ðŸ Dashboard
|
||
├── 📊 Platform Overview
|
||
├── 💰 Revenue Summary
|
||
└── 🚨 System Alerts
|
||
|
||
👥 Customer Management
|
||
├── 🢠Tenants/Kunden
|
||
├── 👤 User Management
|
||
├── 📞 Support Tickets
|
||
└── 📧 Bulk Communications
|
||
|
||
💳 Billing & Subscriptions
|
||
├── 💰 Subscription Plans
|
||
├── 🧾 Invoices & Payments
|
||
├── 📊 Revenue Analytics
|
||
└── 🎠Promotions & Discounts
|
||
|
||
âš™ï¸ Platform Configuration
|
||
├── 🔧 System Settings
|
||
├── 🎨 Global Themes
|
||
├── # Hochzeits-Fotobox App - Project Requirements & Prompt (PRP)
|
||
|
||
## 📋 Projekt-Übersicht
|
||
|
||
**Projektname:** Emotionen-Fotobox für Deutsche Hochzeiten
|
||
**Ziel:** Eine Progressive Web App (PWA), die Hochzeitsgäste spielerisch dazu motiviert, emotionale Fotos zu erstellen und zu teilen
|
||
**Tech-Stack:** Laravel 12.x + Filament 4.x (Admin) + Vite + React + SQLite + Tailwind CSS
|
||
> Versions as of 2025-09-01: Laravel 12 (latest 12.21.x), Filament 4 (stable). If you must stay on Filament v3 for compatibility, pin to 3.3.x.
|
||
**Zielgruppe:** Deutsche Hochzeitsgäste aller Altersgruppen
|
||
|
||
## 🎯 Kern-Funktionalitäten
|
||
|
||
### 1. Emotions-basiertes Foto-System
|
||
- **6 Haupt-Emotionen:** Liebe 💕, Freude 😂, Rührung 🥺, Nostalgie 📸, Überraschung 😲, Stolz ðŸ†
|
||
- **Aufgaben-Generator:** Pro Emotion 8-12 verschiedene Foto-Aufgaben
|
||
- **Schwierigkeitsgrade:** Easy (Einzelperson), Medium (Gruppe), Hard (Kreativ/Nachstellung)
|
||
|
||
### 2. Social Media-inspiriertes UI
|
||
- **Instagram Stories-Layout:** Horizontale Emotion-Bubbles
|
||
- **TikTok-Style Kamera:** Vollbild mit Overlay-Aufgaben
|
||
- **Feed-Design:** Kachel-Grid mit Like-Funktion
|
||
- **Swipe-Navigation:** Intuitive Bedienung
|
||
|
||
### 3. Real-time Features
|
||
- **Live-Photo-Feed:** Neue Fotos erscheinen sofort bei allen Gästen
|
||
- **Kollaborative Galerie:** Gemeinsame Fotosortierung nach Emotionen
|
||
- **Like-System:** Herzchen für beliebte Fotos
|
||
|
||
## ðŸ—ï¸ Technische Architektur
|
||
|
||
### Backend: Laravel 12.x (PHP 8.4)
|
||
|
||
#### Database Schema (SQLite)
|
||
```sql
|
||
-- Event Types (wedding, christmas, birthday, corporate, ...)
|
||
CREATE TABLE event_types (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL, -- JSON (translatable: {"de":"Hochzeit","en":"Wedding"})
|
||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||
icon VARCHAR(64),
|
||
settings JSON, -- e.g., {"palette": {...}}
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- Events (Veranstaltungen)
|
||
CREATE TABLE events (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL, -- JSON (translatable)
|
||
date DATE NOT NULL,
|
||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||
description TEXT, -- JSON (translatable)
|
||
settings JSON, -- Konfiguration für Emotionen, Farben etc.
|
||
event_type_id INTEGER NOT NULL,
|
||
is_active BOOLEAN DEFAULT 1,
|
||
default_locale VARCHAR(5) DEFAULT 'de',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (event_type_id) REFERENCES event_types(id)
|
||
);
|
||
|
||
-- Emotionen-Kategorien (reusable; linked to event types via pivot)
|
||
CREATE TABLE emotions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL, -- JSON (translatable)
|
||
icon VARCHAR(50) NOT NULL, -- Emoji oder Icon-Klasse
|
||
color VARCHAR(7) NOT NULL, -- Hex-Farbe
|
||
description TEXT, -- JSON (translatable)
|
||
sort_order INTEGER DEFAULT 0,
|
||
is_active BOOLEAN DEFAULT 1
|
||
);
|
||
|
||
-- Emotion↔EventType Zuordnung (many-to-many)
|
||
CREATE TABLE emotion_event_type (
|
||
emotion_id INTEGER NOT NULL,
|
||
event_type_id INTEGER NOT NULL,
|
||
PRIMARY KEY (emotion_id, event_type_id),
|
||
FOREIGN KEY (emotion_id) REFERENCES emotions(id),
|
||
FOREIGN KEY (event_type_id) REFERENCES event_types(id)
|
||
);
|
||
|
||
CREATE TABLE tasks (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
emotion_id INTEGER NOT NULL,
|
||
event_type_id INTEGER, -- optional: directly target a type
|
||
title TEXT NOT NULL, -- JSON (translatable)
|
||
description TEXT NOT NULL, -- JSON (translatable)
|
||
difficulty ENUM('easy', 'medium', 'hard') DEFAULT 'easy',
|
||
example_text TEXT, -- JSON (translatable)
|
||
sort_order INTEGER DEFAULT 0,
|
||
is_active BOOLEAN DEFAULT 1,
|
||
-- multi-tenant scoping additions (see Tenant-Defined Tasks section)
|
||
tenant_id INTEGER,
|
||
scope TEXT DEFAULT 'global', -- 'global' | 'tenant' | 'event'
|
||
event_id INTEGER,
|
||
FOREIGN KEY (emotion_id) REFERENCES emotions(id)
|
||
-- optional FKs for scoping
|
||
,FOREIGN KEY (event_type_id) REFERENCES event_types(id)
|
||
,FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||
,FOREIGN KEY (event_id) REFERENCES events(id)
|
||
);
|
||
|
||
-- Hochgeladene Fotos
|
||
CREATE TABLE photos (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
event_id INTEGER NOT NULL,
|
||
emotion_id INTEGER NOT NULL,
|
||
task_id INTEGER,
|
||
guest_name VARCHAR(255) NOT NULL,
|
||
file_path VARCHAR(255) NOT NULL,
|
||
thumbnail_path VARCHAR(255) NOT NULL,
|
||
likes_count INTEGER DEFAULT 0,
|
||
is_featured BOOLEAN DEFAULT 0, -- Für Highlights
|
||
metadata JSON, -- EXIF-Daten, Gerätinfo etc.
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (event_id) REFERENCES events(id),
|
||
FOREIGN KEY (emotion_id) REFERENCES emotions(id),
|
||
FOREIGN KEY (task_id) REFERENCES tasks(id)
|
||
);
|
||
|
||
-- Photo-Likes (für Gast-Interaktion)
|
||
CREATE TABLE photo_likes (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
photo_id INTEGER NOT NULL,
|
||
guest_name VARCHAR(255) NOT NULL,
|
||
ip_address VARCHAR(45),
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (photo_id) REFERENCES photos(id),
|
||
UNIQUE(photo_id, guest_name, ip_address)
|
||
);
|
||
```
|
||
|
||
#### Laravel Models
|
||
|
||
**Event Model:**
|
||
```php
|
||
|
||
Event model additions (event type):
|
||
```php
|
||
// In App\Models\Event
|
||
protected $fillable = ["name","date","slug","description","settings","event_type_id","is_active"];
|
||
public function type(){ return $this->belongsTo(\App\Models\EventType::class, "event_type_id"); }
|
||
```
|
||
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Model;
|
||
|
||
class Event extends Model
|
||
{
|
||
protected $fillable = ['name', 'date', 'slug', 'description', 'settings', 'is_active'];
|
||
|
||
protected $casts = [
|
||
'date' => 'date',
|
||
'settings' => 'array',
|
||
'is_active' => 'boolean'
|
||
];
|
||
|
||
public function photos()
|
||
{
|
||
return $this->hasMany(Photo::class)->latest();
|
||
}
|
||
|
||
public function getRouteKeyName()
|
||
{
|
||
return 'slug';
|
||
}
|
||
}
|
||
```
|
||
|
||
**Photo Model:**
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Illuminate\Database\Eloquent\Model;
|
||
|
||
class Photo extends Model
|
||
{
|
||
protected $fillable = [
|
||
'event_id', 'emotion_id', 'task_id', 'guest_name',
|
||
'file_path', 'thumbnail_path', 'metadata'
|
||
];
|
||
|
||
protected $casts = [
|
||
'metadata' => 'array'
|
||
];
|
||
|
||
public function event()
|
||
{
|
||
return $this->belongsTo(Event::class);
|
||
}
|
||
|
||
public function emotion()
|
||
{
|
||
return $this->belongsTo(Emotion::class);
|
||
}
|
||
|
||
public function task()
|
||
{
|
||
return $this->belongsTo(Task::class);
|
||
}
|
||
|
||
public function likes()
|
||
{
|
||
return $this->hasMany(PhotoLike::class);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### API Routes (routes/api.php)
|
||
```php
|
||
<?php
|
||
|
||
use App\Http\Controllers\Api\{
|
||
EventController,
|
||
EmotionController,
|
||
TaskController,
|
||
PhotoController,
|
||
LiveFeedController
|
||
};
|
||
|
||
// Event-spezifische Routes (über Slug-Parameter)
|
||
Route::prefix('events/{event:slug}')->middleware(['throttle:60,1'])->group(function () {
|
||
// Event-Details
|
||
Route::get('/', [EventController::class, 'show']);
|
||
|
||
// Emotionen laden (per Event-Typ gefiltert + universal)
|
||
Route::get('/emotions', [EmotionController::class, 'index']);
|
||
|
||
// Aufgaben pro Emotion
|
||
Route::get('/tasks/{emotion}', [TaskController::class, 'getByEmotion']);
|
||
Route::get('/tasks/random/{emotion}', [TaskController::class, 'getRandomTask']);
|
||
|
||
// Foto-Management
|
||
Route::get('/photos', [PhotoController::class, 'index']);
|
||
Route::post('/photos', [PhotoController::class, 'store']);
|
||
Route::get('/photos/{photo}', [PhotoController::class, 'show']);
|
||
Route::post('/photos/{photo}/like', [PhotoController::class, 'toggleLike']);
|
||
|
||
// Live-Feed für Real-time Updates
|
||
Route::get('/feed/latest', [LiveFeedController::class, 'getLatest']);
|
||
Route::get('/feed/stream', [LiveFeedController::class, 'stream']); // SSE
|
||
});
|
||
|
||
// Admin Routes (für Hochzeits-Setup)
|
||
Route::prefix('admin')->middleware(['auth:sanctum'])->group(function () {
|
||
Route::apiResource('events', EventController::class);
|
||
Route::apiResource('emotions', EmotionController::class);
|
||
Route::apiResource('tasks', TaskController::class);
|
||
});
|
||
```
|
||
|
||
#### Controller-Beispiele
|
||
|
||
**PhotoController:**
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\{Event, Photo, PhotoLike};
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Storage;
|
||
use Intervention\Image\Facades\Image;
|
||
|
||
class PhotoController extends Controller
|
||
{
|
||
public function store(Request $request, Event $event)
|
||
{
|
||
$request->validate([
|
||
'photo' => 'required|image|mimes:jpeg,png,jpg|max:10240', // 10MB
|
||
'emotion_id' => 'required|exists:emotions,id',
|
||
'task_id' => 'nullable|exists:tasks,id',
|
||
'guest_name' => 'required|string|max:100|min:2'
|
||
]);
|
||
|
||
$photo = $request->file('photo');
|
||
$filename = uniqid('photo_') . '.' . $photo->getClientOriginalExtension();
|
||
|
||
// Original-Bild speichern
|
||
$originalPath = $photo->storeAs('photos', $filename, 'public');
|
||
|
||
// Thumbnail erstellen (300x300 quadratisch)
|
||
$thumbnailPath = 'thumbnails/' . $filename;
|
||
$thumbnail = Image::make($photo)
|
||
->fit(300, 300, function ($constraint) {
|
||
$constraint->upsize();
|
||
})
|
||
->encode('jpg', 85);
|
||
|
||
Storage::disk('public')->put($thumbnailPath, $thumbnail);
|
||
|
||
// Metadata sammeln
|
||
$metadata = [
|
||
'original_name' => $photo->getClientOriginalName(),
|
||
'size' => $photo->getSize(),
|
||
'mime_type' => $photo->getMimeType(),
|
||
'ip_address' => $request->ip(),
|
||
'user_agent' => $request->userAgent()
|
||
];
|
||
|
||
// In Datenbank speichern
|
||
$photoRecord = Photo::create([
|
||
'event_id' => $event->id,
|
||
'emotion_id' => $request->emotion_id,
|
||
'task_id' => $request->task_id,
|
||
'guest_name' => $request->guest_name,
|
||
'file_path' => $originalPath,
|
||
'thumbnail_path' => $thumbnailPath,
|
||
'metadata' => $metadata
|
||
]);
|
||
|
||
// Response mit Relations
|
||
$photoRecord->load(['emotion', 'task']);
|
||
|
||
// Optional: Broadcasting für Live-Updates
|
||
// broadcast(new PhotoUploaded($photoRecord));
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'data' => $photoRecord,
|
||
'message' => 'Foto erfolgreich hochgeladen!'
|
||
], 201);
|
||
}
|
||
|
||
public function index(Request $request, Event $event)
|
||
{
|
||
$query = $event->photos()->with(['emotion', 'task', 'likes']);
|
||
|
||
// Filter nach Emotion
|
||
if ($request->has('emotion_id')) {
|
||
$query->where('emotion_id', $request->emotion_id);
|
||
}
|
||
|
||
// Sortierung
|
||
$sortBy = $request->get('sort', 'latest');
|
||
switch ($sortBy) {
|
||
case 'popular':
|
||
$query->withCount('likes')->orderByDesc('likes_count');
|
||
break;
|
||
case 'oldest':
|
||
$query->oldest();
|
||
break;
|
||
default:
|
||
$query->latest();
|
||
}
|
||
|
||
$photos = $query->paginate(20);
|
||
|
||
return response()->json($photos);
|
||
}
|
||
|
||
public function toggleLike(Request $request, Event $event, Photo $photo)
|
||
{
|
||
$request->validate([
|
||
'guest_name' => 'required|string|max:100'
|
||
]);
|
||
|
||
$like = PhotoLike::where([
|
||
'photo_id' => $photo->id,
|
||
'guest_name' => $request->guest_name,
|
||
'ip_address' => $request->ip()
|
||
])->first();
|
||
|
||
if ($like) {
|
||
$like->delete();
|
||
$photo->decrement('likes_count');
|
||
$liked = false;
|
||
} else {
|
||
PhotoLike::create([
|
||
'photo_id' => $photo->id,
|
||
'guest_name' => $request->guest_name,
|
||
'ip_address' => $request->ip()
|
||
]);
|
||
$photo->increment('likes_count');
|
||
$liked = true;
|
||
}
|
||
|
||
return response()->json([
|
||
'liked' => $liked,
|
||
'likes_count' => $photo->fresh()->likes_count
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Frontend: React + Vite PWA
|
||
|
||
Note: Event Type-aware UI
|
||
- Theme colors can come from `event_types.settings.palette` to skin headers/buttons per type.
|
||
- Emotion picker fetches type-filtered emotions via `/api/events/:slug/emotions`.
|
||
- Random task generator and gallery filters respect event type automatically via API.
|
||
|
||
#### Projekt-Struktur
|
||
```
|
||
resources/js/
|
||
├── src/
|
||
│ ├── components/
|
||
│ │ ├── Camera/
|
||
│ │ │ ├── CameraCapture.jsx
|
||
│ │ │ ├── PhotoPreview.jsx
|
||
│ │ │ └── TaskOverlay.jsx
|
||
│ │ ├── Emotions/
|
||
│ │ │ ├── EmotionPicker.jsx
|
||
│ │ │ ├── EmotionBubble.jsx
|
||
│ │ │ └── TaskGenerator.jsx
|
||
│ │ ├── Gallery/
|
||
│ │ │ ├── PhotoGrid.jsx
|
||
│ │ │ ├── PhotoModal.jsx
|
||
│ │ │ └── PhotoCard.jsx
|
||
│ │ ├── Layout/
|
||
│ │ │ ├── Header.jsx
|
||
│ │ │ ├── Navigation.jsx
|
||
│ │ │ └── LoadingSpinner.jsx
|
||
│ │ └── Forms/
|
||
│ │ ├── GuestNameForm.jsx
|
||
│ │ └── PhotoUpload.jsx
|
||
│ ├── pages/
|
||
│ │ ├── GeneralLandingPage.jsx
|
||
│ │ ├── EventSpecificLandingPage.jsx
|
||
│ │ ├── HomePage.jsx
|
||
│ │ ├── CameraPage.jsx
|
||
│ │ ├── GalleryPage.jsx
|
||
│ ├── hooks/
|
||
│ │ ├── useCamera.js
|
||
│ │ ├── usePhotoUpload.js
|
||
│ │ ├── useLocalStorage.js
|
||
│ │ └── useRealTimeFeed.js
|
||
│ ├── services/
|
||
│ │ ├── api.js
|
||
│ │ ├── imageUtils.js
|
||
│ │ └── cacheManager.js
|
||
│ ├── context/
|
||
│ │ ├── AppContext.jsx
|
||
│ │ └── EventContext.jsx
|
||
│ └── utils/
|
||
│ ├── constants.js
|
||
│ ├── helpers.js
|
||
│ └── validation.js
|
||
```
|
||
|
||
#### Vite Configuration (vite.config.js)
|
||
```javascript
|
||
import { defineConfig } from 'vite'
|
||
import react from '@vitejs/plugin-react'
|
||
import { VitePWA } from 'vite-plugin-pwa'
|
||
import path from 'path'
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
react(),
|
||
VitePWA({
|
||
registerType: 'prompt',
|
||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'safari-pinned-tab.svg'],
|
||
manifest: {
|
||
name: 'Hochzeits-Fotobox',
|
||
short_name: 'Fotobox',
|
||
description: 'Emotionale Hochzeitsfotos für unvergessliche Momente',
|
||
theme_color: '#ff6b9d',
|
||
background_color: '#ffffff',
|
||
display: 'standalone',
|
||
orientation: 'portrait-primary',
|
||
start_url: '/',
|
||
scope: '/',
|
||
icons: [
|
||
{
|
||
src: '/icons/pwa-192x192.png',
|
||
sizes: '192x192',
|
||
type: 'image/png'
|
||
},
|
||
{
|
||
src: '/icons/pwa-512x512.png',
|
||
sizes: '512x512',
|
||
type: 'image/png'
|
||
}
|
||
]
|
||
},
|
||
workbox: {
|
||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||
runtimeCaching: [
|
||
{
|
||
urlPattern: /^\/api\//,
|
||
handler: 'NetworkFirst',
|
||
options: {
|
||
cacheName: 'api-cache',
|
||
expiration: {
|
||
maxEntries: 100,
|
||
maxAgeSeconds: 60 * 60 * 24 // 24 hours
|
||
}
|
||
}
|
||
},
|
||
{
|
||
urlPattern: /\.(png|jpg|jpeg|svg|gif)$/,
|
||
handler: 'CacheFirst',
|
||
options: {
|
||
cacheName: 'images-cache',
|
||
expiration: {
|
||
maxEntries: 200,
|
||
maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
|
||
}
|
||
}
|
||
}
|
||
]
|
||
}
|
||
})
|
||
],
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, './resources/js/src')
|
||
}
|
||
},
|
||
build: {
|
||
outDir: 'public/build',
|
||
emptyOutDir: true,
|
||
manifest: true,
|
||
rollupOptions: {
|
||
input: 'resources/js/app.jsx'
|
||
}
|
||
},
|
||
server: {
|
||
host: true,
|
||
hmr: {
|
||
host: 'localhost'
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
#### React Haupt-Komponenten
|
||
|
||
**App.jsx (Routing & Context)**
|
||
```jsx
|
||
import React from 'react'
|
||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||
import { EventProvider } from '@/context/EventContext'
|
||
import { AppProvider } from '@/context/AppContext'
|
||
|
||
// Pages
|
||
import EventLanding from '@/pages/EventLanding'
|
||
import HomePage from '@/pages/HomePage'
|
||
import CameraPage from '@/pages/CameraPage'
|
||
import GalleryPage from '@/pages/GalleryPage'
|
||
|
||
// Layout
|
||
import Layout from '@/components/Layout/Layout'
|
||
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: {
|
||
queries: {
|
||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||
cacheTime: 1000 * 60 * 30, // 30 minutes
|
||
retry: 2
|
||
}
|
||
}
|
||
})
|
||
|
||
function App() {
|
||
return (
|
||
<QueryClientProvider client={queryClient}>
|
||
<AppProvider>
|
||
<Router>
|
||
<Routes>
|
||
{/* Event Selection */}
|
||
<Route path="/" element={<EventLanding />} />
|
||
|
||
{/* Main App (requires event context) */}
|
||
<Route path="/event/:eventSlug/*" element={
|
||
<EventProvider>
|
||
<Layout>
|
||
<Routes>
|
||
<Route index element={<HomePage />} />
|
||
<Route path="camera" element={<CameraPage />} />
|
||
<Route path="camera/:emotionId" element={<CameraPage />} />
|
||
<Route path="gallery" element={<GalleryPage />} />
|
||
<Route path="gallery/:emotionId" element={<GalleryPage />} />
|
||
<Route path="*" element={<Navigate to="../" replace />} />
|
||
</Routes>
|
||
</Layout>
|
||
</EventProvider>
|
||
} />
|
||
</Routes>
|
||
</Router>
|
||
</AppProvider>
|
||
</QueryClientProvider>
|
||
)
|
||
}
|
||
|
||
export default App
|
||
```
|
||
|
||
|
||
**GeneralLandingPage.jsx**
|
||
```jsx
|
||
import React from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
function GeneralLandingPage() {
|
||
const navigate = useNavigate();
|
||
|
||
const handleQRScan = () => {
|
||
// Trigger device camera to scan QR code
|
||
// This would use a library like jsQR or react-qr-reader
|
||
console.log("Opening QR scanner...");
|
||
};
|
||
|
||
const handleManualEntry = () => {
|
||
// Open modal for manual code entry
|
||
const code = prompt("Bitte gib den Event-Code ein:");
|
||
if (code) {
|
||
// Validate and redirect to event
|
||
navigate(`/event/${code}`);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-white to-purple-50 flex flex-col">
|
||
{/* Minimalist Header */}
|
||
<header className="p-4 flex justify-between items-center">
|
||
<div className="text-sm text-gray-500">Event Date</div>
|
||
<div className="text-sm text-gray-500">Branding Logo</div>
|
||
</header>
|
||
|
||
{/* Main Content */}
|
||
<main className="flex-grow flex flex-col items-center justify-center px-4">
|
||
<div className="text-center mb-8">
|
||
<h1 className="text-3xl font-bold text-gray-800 mb-2">Willkommen bei der Fotobox! 🎉</h1>
|
||
<p className="text-gray-600">Dein Schlüssel zu unvergesslichen Hochzeitsmomenten.</p>
|
||
</div>
|
||
|
||
{/* Central Card for Actions */}
|
||
<div className="bg-white rounded-xl shadow-lg p-6 w-full max-w-md">
|
||
{/* QR Code Scanner Emphasis */}
|
||
<div className="mb-6">
|
||
<div className="bg-gray-200 border-2 border-dashed rounded-xl w-32 h-32 mx-auto mb-4" />
|
||
<button
|
||
onClick={handleQRScan}
|
||
className="w-full py-3 px-4 bg-pink-500 text-white font-semibold rounded-lg shadow-md hover:bg-pink-600 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:ring-opacity-75 transition-all"
|
||
>
|
||
QR-Code scannen
|
||
</button>
|
||
</div>
|
||
|
||
{/* Manual Code Entry (Fallback) */}
|
||
<div className="text-center">
|
||
<button
|
||
onClick={handleManualEntry}
|
||
className="text-pink-500 hover:text-pink-700 font-medium"
|
||
>
|
||
Oder Code manuell eingeben
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Minimalist Footer */}
|
||
<footer className="p-4 text-center text-sm text-gray-500">
|
||
© {new Date().getFullYear()} Emotionen-Fotobox
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default GeneralLandingPage;
|
||
```
|
||
|
||
**EventSpecificLandingPage.jsx**
|
||
```jsx
|
||
import React, { useState } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
|
||
function EventSpecificLandingPage() {
|
||
const { eventSlug } = useParams();
|
||
const navigate = useNavigate();
|
||
const [guestName, setGuestName] = useState('');
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault();
|
||
if (guestName.trim()) {
|
||
// Store guest name in context or localStorage
|
||
localStorage.setItem('guestName', guestName);
|
||
// Redirect to main event page
|
||
navigate(`/event/${eventSlug}`);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-b from-pink-100 to-purple-100 flex flex-col">
|
||
{/* Header */}
|
||
<header className="p-4 flex justify-between items-start">
|
||
<div className="text-2xl">â¤ï¸ðŸ“¸</div> {/* Heart with Camera Icon */}
|
||
<div className="text-lg font-sans text-gray-700">01.01.2025</div> {/* Event Date */}
|
||
</header>
|
||
|
||
{/* Main Content - Vertically Centered */}
|
||
<main className="flex-grow flex flex-col items-center justify-center px-4">
|
||
{/* Title Section */}
|
||
<div className="text-center mb-8">
|
||
<p className="text-gray-700 uppercase tracking-wider font-sans mb-1">HOCHZEIT VON</p>
|
||
<h1 className="text-4xl font-bold italic font-handwriting text-gray-800 mb-2">MARIE & LUKAS</h1>
|
||
<p className="text-gray-600 text-sm font-sans">Fangt die schönsten Momente ein!</p>
|
||
</div>
|
||
|
||
{/* Input Section */}
|
||
<div className="bg-white bg-opacity-70 backdrop-blur-sm rounded-xl shadow-lg p-6 w-full max-w-md">
|
||
<p className="text-center text-gray-700 mb-4 font-medium">Bereit für Emotionen?</p>
|
||
<form onSubmit={handleSubmit}>
|
||
<input
|
||
type="text"
|
||
placeholder="Dein Name (z.B. Anna)"
|
||
value={guestName}
|
||
onChange={(e) => setGuestName(e.target.value)}
|
||
className="w-full p-3 mb-4 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-300"
|
||
required
|
||
/>
|
||
<button
|
||
type="submit"
|
||
className="w-full py-3 px-4 bg-[#F85B73] text-white font-semibold rounded-lg shadow-md hover:bg-[#e05067] focus:outline-none focus:ring-2 focus:ring-pink-400 focus:ring-opacity-75 transition-all transform hover:scale-105 active:scale-95"
|
||
>
|
||
LET’S GO! 🎉
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Footer */}
|
||
<footer className="p-6 text-center">
|
||
<div className="text-2xl mb-1">â¤ï¸</div>
|
||
<p className="text-gray-700 font-sans">Emotionen-Fotobox</p>
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default EventSpecificLandingPage;
|
||
```
|
||
|
||
**SettingsSheet.jsx (gear icon → bottom sheet)**
|
||
```jsx
|
||
import React from "react"
|
||
import { Link } from "react-router-dom"
|
||
import { useTranslation } from "react-i18next"
|
||
|
||
export default function SettingsSheet({ open, onClose, onLangChange, currentLang }) {
|
||
const { t } = useTranslation()
|
||
if (!open) return null
|
||
return (
|
||
<div className="fixed inset-0 z-50">
|
||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||
<div className="absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl p-4 shadow-xl">
|
||
<div className="mx-auto w-full max-w-md">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-lg font-semibold">{t("ui:settings")}</h3>
|
||
<button onClick={onClose} className="text-gray-500">✕</button>
|
||
</div>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<div className="text-sm text-gray-500 mb-1">{t("ui:language")}</div>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => onLangChange("de")} className={`px-3 py-1 rounded border ${currentLang==="de"?"bg-gray-900 text-white":""}`}>DE</button>
|
||
<button onClick={() => onLangChange("en")} className={`px-3 py-1 rounded border ${currentLang==="en"?"bg-gray-900 text-white":""}`}>EN</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-sm text-gray-500 mb-1">{t("ui:legal")}</div>
|
||
<ul className="space-y-2">
|
||
<li><Link to="/legal/imprint" onClick={onClose} className="text-pink-600">Impressum</Link></li>
|
||
<li><Link to="/legal/privacy" onClick={onClose} className="text-pink-600">Datenschutzerklärung</Link></li>
|
||
<li><Link to="/legal/terms" onClick={onClose} className="text-pink-600">AGB</Link></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
**LegalPage.jsx (renders platform-managed markdown)**
|
||
```jsx
|
||
import React from "react"
|
||
import { useParams } from "react-router-dom"
|
||
import { useQuery } from "@tanstack/react-query"
|
||
import ReactMarkdown from "react-markdown"
|
||
import rehypeSanitize from "rehype-sanitize"
|
||
import { useTranslation } from "react-i18next"
|
||
|
||
export default function LegalPage(){
|
||
const { slug } = useParams()
|
||
const { i18n } = useTranslation()
|
||
const lang = i18n.language || "de"
|
||
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ["legal", slug, lang],
|
||
queryFn: async () => {
|
||
const res = await fetch(`/api/legal/${slug}?lang=${lang}`)
|
||
if(!res.ok) throw new Error("Failed to load legal page")
|
||
return res.json()
|
||
}
|
||
})
|
||
|
||
if (isLoading) return <div className="p-4">Loading…</div>
|
||
if (error) return <div className="p-4 text-red-600">Error loading content.</div>
|
||
|
||
return (
|
||
<div className="max-w-3xl mx-auto p-4 prose prose-pink">
|
||
<h1 className="mb-2">{data.title}</h1>
|
||
<div className="text-sm text-gray-500 mb-6">
|
||
{data.updated_at ? new Date(data.updated_at).toLocaleDateString(lang) : null}
|
||
</div>
|
||
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>
|
||
{data.body_markdown || ""}
|
||
</ReactMarkdown>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
**Header.jsx (gear button opens SettingsSheet and changes language)**
|
||
```jsx
|
||
import React from "react"
|
||
import { Settings } from "lucide-react"
|
||
import { useTranslation } from "react-i18next"
|
||
import SettingsSheet from "@/components/Layout/SettingsSheet"
|
||
|
||
export default function Header() {
|
||
const { i18n } = useTranslation()
|
||
const [open, setOpen] = React.useState(false)
|
||
|
||
const onLangChange = (lng) => {
|
||
i18n.changeLanguage(lng)
|
||
localStorage.setItem("lang", lng)
|
||
setOpen(false)
|
||
}
|
||
|
||
return (
|
||
<header className="sticky top-0 z-40 bg-white/70 backdrop-blur border-b">
|
||
<div className="max-w-4xl mx-auto px-4 h-12 flex items-center justify-between">
|
||
<div className="font-semibold">Fotobox</div>
|
||
<button
|
||
aria-label="Settings"
|
||
onClick={() => setOpen(true)}
|
||
className="p-2 rounded-full hover:bg-gray-100"
|
||
>
|
||
<Settings className="w-5 h-5 text-gray-700" />
|
||
</button>
|
||
</div>
|
||
<SettingsSheet
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
onLangChange={onLangChange}
|
||
currentLang={i18n.language || "de"}
|
||
/>
|
||
</header>
|
||
)
|
||
}
|
||
```
|
||
## 🌠Internationalization (i18n)
|
||
|
||
### Goals & Locales
|
||
- Support multi-lingual content across admin, API, and PWA.
|
||
- Initial locales: `de` (default) and `en`; easily extendable.
|
||
|
||
### Backend (Laravel 12 + PHP 8.4)
|
||
- Dependency: `spatie/laravel-translatable` for JSON translations.
|
||
- App config (`config/app.php`): set `locale`, `fallback_locale`, and optionally `APP_SUPPORTED_LOCALES=de,en`.
|
||
|
||
#### i18n Data Model
|
||
- Store translatable fields as JSON:
|
||
- `event_types.name`
|
||
- `events.name`, `events.description`
|
||
- `emotions.name`, `emotions.description`
|
||
- `tasks.title`, `tasks.description`, `tasks.example_text`
|
||
|
||
#### Model Setup
|
||
```php
|
||
<?php
|
||
use Spatie\Translatable\HasTranslations;
|
||
|
||
class Task extends Model {
|
||
use HasTranslations;
|
||
public $translatable = ['title','description','example_text'];
|
||
}
|
||
|
||
class Emotion extends Model {
|
||
use HasTranslations;
|
||
public $translatable = ['name','description'];
|
||
}
|
||
|
||
class EventType extends Model {
|
||
use HasTranslations;
|
||
public $translatable = ['name'];
|
||
}
|
||
|
||
class Event extends Model {
|
||
use HasTranslations;
|
||
public $translatable = ['name','description'];
|
||
}
|
||
```
|
||
|
||
## 📜 Legal Content Management (Impressum/Privacy/AGB)
|
||
|
||
### Schema
|
||
```sql
|
||
CREATE TABLE legal_pages (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
tenant_id INTEGER NULL, -- null = platform default; future: tenant override
|
||
slug VARCHAR(32) NOT NULL, -- 'imprint' | 'privacy' | 'terms' | 'custom'
|
||
title TEXT NOT NULL, -- JSON translatable: {"de":"Impressum","en":"Imprint"}
|
||
body_markdown TEXT NOT NULL, -- JSON translatable; Markdown content
|
||
locale_fallback VARCHAR(5) DEFAULT 'de',
|
||
version INTEGER DEFAULT 1,
|
||
effective_from TIMESTAMP NULL,
|
||
is_published BOOLEAN DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE(slug, tenant_id, version),
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||
);
|
||
```
|
||
|
||
### Filament v4 — LegalPageResource (Super Admin)
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Resources/LegalPageResource.php
|
||
|
||
namespace App\Filament\SuperAdmin\Resources;
|
||
|
||
use App\Models\LegalPage;
|
||
use Filament\Resources\Resource;
|
||
use Filament\Tables; use Filament\Tables\Table;
|
||
use Filament\Forms\Form; use Filament\Schema as Schema; use Filament\Actions;
|
||
|
||
class LegalPageResource extends Resource
|
||
{
|
||
protected static ?string $model = LegalPage::class;
|
||
protected static ?string $navigationIcon = 'heroicon-o-document-text';
|
||
protected static ?string $navigationGroup = 'Legal & Compliance';
|
||
protected static ?string $label = 'Legal Pages';
|
||
|
||
public static function form(Form $form): Form
|
||
{
|
||
return $form->schema([
|
||
Schema\Components\Select::make('slug')
|
||
->label('Type')->options([
|
||
'imprint' => 'Impressum',
|
||
'privacy' => 'Datenschutzerklärung',
|
||
'terms' => 'AGB',
|
||
'custom' => 'Custom',
|
||
])->required(),
|
||
|
||
Schema\Components\Tabs::make('Translations')->tabs([
|
||
Schema\Components\Tabs\Tab::make('Deutsch')->schema([
|
||
Schema\Components\TextInput::make('title.de')->label('Titel (DE)')->required(),
|
||
Schema\Components\Textarea::make('body_markdown.de')->label('Inhalt (DE)')->rows(12)->required(),
|
||
]),
|
||
Schema\Components\Tabs\Tab::make('English')->schema([
|
||
Schema\Components\TextInput::make('title.en')->label('Title (EN)')->required(),
|
||
Schema\Components\Textarea::make('body_markdown.en')->label('Content (EN)')->rows(12)->required(),
|
||
]),
|
||
])->columnSpanFull(),
|
||
|
||
Schema\Components\Grid::make(3)->schema([
|
||
Schema\Components\TextInput::make('version')->numeric()->default(1),
|
||
Schema\Components\DateTimePicker::make('effective_from'),
|
||
Schema\Components\Toggle::make('is_published')->label('Published'),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\Columns\BadgeColumn::make('slug')->label('Type')->colors([
|
||
'primary' => 'imprint','success'=>'privacy','warning'=>'terms','gray'=>'custom'
|
||
]),
|
||
Tables\Columns\TextColumn::make('title')->label('Title')->formatStateUsing(fn($r)=>$r->getTranslation('title', app()->getLocale()))->limit(40),
|
||
Tables\Columns\TextColumn::make('version')->sortable(),
|
||
Tables\Columns\IconColumn::make('is_published')->boolean(),
|
||
Tables\Columns\TextColumn::make('updated_at')->dateTime('d.m.Y H:i')->sortable(),
|
||
])
|
||
->headerActions([
|
||
Actions\CreateAction::make(),
|
||
])
|
||
->recordActions([
|
||
Actions\EditAction::make(),
|
||
Actions\Action::make('preview')->label('Preview')->url(fn($r)=>url("/legal/{$r->slug}?lang=".app()->getLocale()))->openUrlInNewTab(),
|
||
Actions\DeleteAction::make(),
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
### API (public)
|
||
```php
|
||
// routes/api.php
|
||
use App\Http\Controllers\Api\LegalController;
|
||
Route::get('/legal/{slug}', [LegalController::class, 'show']);
|
||
```
|
||
|
||
```php
|
||
<?php
|
||
// app/Http/Controllers/Api/LegalController.php
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\LegalPage;
|
||
use Illuminate\Http\Request;
|
||
|
||
class LegalController extends Controller
|
||
{
|
||
public function show(Request $request, string $slug)
|
||
{
|
||
$locale = app()->getLocale();
|
||
$page = LegalPage::query()
|
||
->where('slug', $slug)
|
||
->where('is_published', true)
|
||
->orderByDesc('version')
|
||
->firstOrFail();
|
||
|
||
return response()->json([
|
||
'slug' => $page->slug,
|
||
'title' => $page->getTranslation('title', $locale),
|
||
'body_markdown' => $page->getTranslation('body_markdown', $locale),
|
||
'version' => $page->version,
|
||
'effective_from' => $page->effective_from,
|
||
'updated_at' => $page->updated_at,
|
||
'locale' => $locale,
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
### PWA UX
|
||
- Header gear icon opens a Settings sheet with:
|
||
- Language: DE/EN toggle
|
||
- Legal: Impressum, Datenschutzerklärung, AGB (links)
|
||
- Footer links (public pages): “Impressum · Datenschutz · AGB†(DE labels in DE locale) for easy/direct access.
|
||
- Routes: `/legal/imprint`, `/legal/privacy`, `/legal/terms` → render via API.
|
||
|
||
React components (sketch):
|
||
```jsx
|
||
// routes
|
||
<Route path="/legal/:slug" element={<LegalPage />} />
|
||
|
||
// components/SettingsSheet.jsx (triggered by gear button)
|
||
// shows language selector and links to /legal/... routes
|
||
```
|
||
|
||
Workbox caching (vite-plugin-pwa): add runtime cache for `/api/legal/*` (CacheFirst with revalidate).
|
||
|
||
Compliance (DE): ensure “Impressum†is labeled in German and reachable within ≤2 clicks; show last updated date; include provider details (name, address, contact, VAT/registry) in content.
|
||
|
||
#### Locale Resolution (API)
|
||
- Precedence: `?lang` → `Accept-Language` → `event.default_locale` → fallback.
|
||
- Set `Content-Language` header.
|
||
```php
|
||
class SetLocale { /* see middleware example above */ }
|
||
```
|
||
|
||
### Admin (Filament v4) Forms
|
||
- Use Tabs for per-locale inputs (Spatie accepts arrays):
|
||
```php
|
||
Schema\Components\Tabs::make('Translations')->tabs([
|
||
Schema\Components\Tabs\Tab::make('Deutsch')->schema([
|
||
Schema\Components\TextInput::make('title.de')->label('Titel (DE)')->required(),
|
||
Schema\Components\Textarea::make('description.de')->label('Beschreibung (DE)')->rows(3),
|
||
Schema\Components\TextInput::make('example_text.de')->label('Beispiel (DE)'),
|
||
]),
|
||
Schema\Components\Tabs\Tab::make('English')->schema([
|
||
Schema\Components\TextInput::make('title.en')->label('Title (EN)'),
|
||
Schema\Components\Textarea::make('description.en')->label('Description (EN)')->rows(3),
|
||
Schema\Components\TextInput::make('example_text.en')->label('Example (EN)'),
|
||
]),
|
||
])
|
||
```
|
||
|
||
### CSV (Localized Fields)
|
||
- Extra headers allowed: `title:de`, `title:en`, `description:de`, `description:en`, `example_text:de`, `example_text:en`.
|
||
- If only base columns exist, treat them as the active import locale.
|
||
```csv
|
||
emotion,title:de,title:en,description:de,description:en,difficulty,is_active,scope,event_type
|
||
Freude,Sprung-Foto,Jump Photo,Alle springen!,Everyone jump!,medium,1,tenant,corporate
|
||
```
|
||
|
||
### Frontend (React PWA)
|
||
- Dependencies: `i18next`, `react-i18next`, `i18next-browser-languagedetector`.
|
||
- Initialize in `resources/js/src/i18n.js` and pass locale to API via `?lang=`.
|
||
|
||
**HomePage.jsx (Emotions-Picker)**
|
||
```jsx
|
||
import React from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { useEvent } from '@/context/EventContext'
|
||
import { useApp } from '@/context/AppContext'
|
||
import EmotionBubble from '@/components/Emotions/EmotionBubble'
|
||
import PhotoGrid from '@/components/Gallery/PhotoGrid'
|
||
import LoadingSpinner from '@/components/Layout/LoadingSpinner'
|
||
|
||
function HomePage() {
|
||
const navigate = useNavigate()
|
||
const { eventSlug, apiService } = useEvent()
|
||
const { guestName, setGuestName } = useApp()
|
||
|
||
// Emotionen laden
|
||
const { data: emotions, isLoading: emotionsLoading } = useQuery({
|
||
queryKey: ['emotions', eventSlug],
|
||
queryFn: () => apiService.getEmotions(),
|
||
enabled: !!eventSlug
|
||
})
|
||
|
||
// Neueste Fotos laden
|
||
const { data: recentPhotos, isLoading: photosLoading } = useQuery({
|
||
queryKey: ['photos', eventSlug, 'recent'],
|
||
queryFn: () => apiService.getPhotos({ limit: 8, sort: 'latest' }),
|
||
enabled: !!eventSlug,
|
||
refetchInterval: 30000 // Alle 30 Sekunden aktualisieren
|
||
})
|
||
|
||
const handleEmotionClick = (emotionId) => {
|
||
if (!guestName) {
|
||
// Gast-Namen abfragen wenn noch nicht gesetzt
|
||
const name = prompt('Wie sollen wir dich nennen?', '')
|
||
if (name?.trim()) {
|
||
setGuestName(name.trim())
|
||
} else {
|
||
return
|
||
}
|
||
}
|
||
navigate(`/event/${eventSlug}/camera/${emotionId}`)
|
||
}
|
||
|
||
if (emotionsLoading) {
|
||
return <LoadingSpinner message="Emotionen werden geladen..." />
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-pink-50 via-white to-purple-50 bg-[url('/images/wedding-lights-background.svg')] bg-cover bg-center bg-no-repeat">
|
||
{/* Header */}
|
||
<div className="px-4 py-6 text-center">
|
||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||
Willkommen zur Fotobox! 📸
|
||
</h1>
|
||
{guestName && (
|
||
<p className="text-sm text-pink-600 mt-2">
|
||
Hallo, {guestName}! 👋
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Emotions-Picker */}
|
||
<div className="px-4 mb-8">
|
||
<div className="flex overflow-x-auto pb-4 gap-4 px-2">
|
||
{emotions?.map((emotion) => (
|
||
<EmotionBubble
|
||
key={emotion.id}
|
||
emotion={emotion}
|
||
onClick={() => handleEmotionClick(emotion.id)}
|
||
className="flex-shrink-0"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* "Alle anzeigen" Button */}
|
||
<div className="px-4 mb-8 text-center">
|
||
<button
|
||
onClick={() => navigate(`/event/${eventSlug}/gallery`)}
|
||
className="text-pink-600 text-sm font-medium hover:text-pink-700"
|
||
>
|
||
Alle anzeigen →
|
||
</button>
|
||
</div>
|
||
|
||
{/* Recent Photos Feed */}
|
||
<div className="px-4">
|
||
<h2 className="text-xl font-semibold text-gray-700 mb-4">
|
||
Neueste Momente
|
||
</h2>
|
||
|
||
{photosLoading ? (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{[...Array(4)].map((_, i) => (
|
||
<div key={i} className="aspect-square bg-gray-200 rounded-lg animate-pulse" />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<PhotoGrid
|
||
photos={recentPhotos?.data || []}
|
||
columns={2}
|
||
showEmotion={true}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default HomePage
|
||
```
|
||
|
||
**CameraPage.jsx (Foto-Aufnahme)**
|
||
```jsx
|
||
import React, { useState, useRef, useCallback } from 'react'
|
||
import { useParams, useNavigate } from 'react-router-dom'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { useEvent } from '@/context/EventContext'
|
||
import { useApp } from '@/context/AppContext'
|
||
import CameraCapture from '@/components/Camera/CameraCapture'
|
||
import TaskOverlay from '@/components/Camera/TaskOverlay'
|
||
import PhotoPreview from '@/components/Camera/PhotoPreview'
|
||
|
||
function CameraPage() {
|
||
const { emotionId } = useParams()
|
||
const navigate = useNavigate()
|
||
const { eventSlug, apiService } = useEvent()
|
||
const { guestName } = useApp()
|
||
const queryClient = useQueryClient()
|
||
|
||
const [capturedPhoto, setCapturedPhoto] = useState(null)
|
||
const [currentTask, setCurrentTask] = useState(null)
|
||
const cameraRef = useRef(null)
|
||
|
||
// Aktueller Task laden
|
||
const { data: tasks, refetch: refetchTasks } = useQuery({
|
||
queryKey: ['tasks', eventSlug, emotionId],
|
||
queryFn: () => apiService.getRandomTask(emotionId),
|
||
enabled: !!emotionId && !!eventSlug,
|
||
onSuccess: (data) => {
|
||
if (data && !currentTask) {
|
||
setCurrentTask(data)
|
||
}
|
||
}
|
||
})
|
||
|
||
// Foto-Upload Mutation
|
||
const uploadMutation = useMutation({
|
||
mutationFn: (photoData) => apiService.uploadPhoto(photoData),
|
||
onSuccess: () => {
|
||
// Cache invalidieren für sofortige Updates
|
||
queryClient.invalidateQueries(['photos', eventSlug])
|
||
|
||
// Zurück zur Startseite
|
||
navigate(`/event/${eventSlug}`)
|
||
},
|
||
onError: (error) => {
|
||
console.error('Upload failed:', error)
|
||
alert('Upload fehlgeschlagen. Bitte versuche es erneut.')
|
||
}
|
||
})
|
||
|
||
const handleCapture = useCallback((photoBlob) => {
|
||
setCapturedPhoto(photoBlob)
|
||
}, [])
|
||
|
||
const handleRetake = useCallback(() => {
|
||
setCapturedPhoto(null)
|
||
}, [])
|
||
|
||
const handleUpload = useCallback(() => {
|
||
if (!capturedPhoto || !guestName || !currentTask) return
|
||
|
||
const formData = new FormData()
|
||
formData.append('photo', capturedPhoto, 'photo.jpg')
|
||
formData.append('emotion_id', emotionId)
|
||
formData.append('task_id', currentTask.id)
|
||
formData.append('guest_name', guestName)
|
||
|
||
uploadMutation.mutate(formData)
|
||
}, [capturedPhoto, guestName, emotionId, currentTask, uploadMutation])
|
||
|
||
const handleNewTask = useCallback(() => {
|
||
refetchTasks().then((result) => {
|
||
if (result.data) {
|
||
setCurrentTask(result.data)
|
||
}
|
||
})
|
||
}, [refetchTasks])
|
||
|
||
// Redirect wenn kein Gast-Name gesetzt
|
||
React.useEffect(() => {
|
||
if (!guestName) {
|
||
navigate(`/event/${eventSlug}`)
|
||
}
|
||
}, [guestName, navigate, eventSlug])
|
||
|
||
if (!currentTask) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pink-600 mx-auto mb-4"></div>
|
||
<p>Aufgabe wird geladen...</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="relative h-screen bg-black overflow-hidden">
|
||
{!capturedPhoto ? (
|
||
<>
|
||
{/* Live-Kamera */}
|
||
<CameraCapture
|
||
ref={cameraRef}
|
||
onCapture={handleCapture}
|
||
/>
|
||
|
||
{/* Task-Overlay */}
|
||
<TaskOverlay
|
||
task={currentTask}
|
||
onNewTask={handleNewTask}
|
||
onBack={() => navigate(`/event/${eventSlug}`)}
|
||
/>
|
||
</>
|
||
) : (
|
||
/* Foto-Vorschau */
|
||
<PhotoPreview
|
||
photo={capturedPhoto}
|
||
task={currentTask}
|
||
onRetake={handleRetake}
|
||
onUpload={handleUpload}
|
||
isUploading={uploadMutation.isLoading}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default CameraPage
|
||
```
|
||
|
||
#### PWA Service Worker Features
|
||
|
||
**Offline-Funktionalität:**
|
||
```javascript
|
||
// public/sw.js (Custom Service Worker)
|
||
const CACHE_NAME = 'fotobox-v1'
|
||
const OFFLINE_URL = '/offline.html'
|
||
|
||
// Offline-Queue für Photo-Uploads
|
||
class PhotoUploadQueue {
|
||
constructor() {
|
||
this.queue = []
|
||
this.isProcessing = false
|
||
}
|
||
|
||
async add(photoData) {
|
||
this.queue.push(photoData)
|
||
await this.process()
|
||
}
|
||
|
||
async process() {
|
||
if (this.isProcessing || this.queue.length === 0) return
|
||
|
||
this.isProcessing = true
|
||
|
||
while (this.queue.length > 0) {
|
||
const photoData = this.queue.shift()
|
||
|
||
try {
|
||
const response = await fetch(photoData.url, {
|
||
method: 'POST',
|
||
body: photoData.formData
|
||
})
|
||
|
||
if (response.ok) {
|
||
// Upload erfolgreich - Cache invalidieren
|
||
await caches.delete('api-cache')
|
||
|
||
// Client benachrichtigen
|
||
self.clients.matchAll().then(clients => {
|
||
clients.forEach(client => {
|
||
client.postMessage({
|
||
type: 'PHOTO_UPLOADED',
|
||
data: photoData
|
||
})
|
||
})
|
||
})
|
||
} else {
|
||
// Fehlgeschlagen - zurück in die Queue
|
||
this.queue.unshift(photoData)
|
||
break
|
||
}
|
||
} catch (error) {
|
||
// Netzwerkfehler - zurück in die Queue
|
||
this.queue.unshift(photoData)
|
||
break
|
||
}
|
||
}
|
||
|
||
this.isProcessing = false
|
||
}
|
||
}
|
||
|
||
const uploadQueue = new PhotoUploadQueue()
|
||
|
||
// Network-first für API-Calls
|
||
self.addEventListener('fetch', event => {
|
||
if (event.request.url.includes('/api/')) {
|
||
event.respondWith(
|
||
fetch(event.request)
|
||
.then(response => {
|
||
// Response klonen für Cache
|
||
const responseClone = response.clone()
|
||
caches.open(CACHE_NAME).then(cache => {
|
||
cache.put(event.request, responseClone)
|
||
})
|
||
return response
|
||
})
|
||
.catch(() => {
|
||
// Fallback auf Cache
|
||
return caches.match(event.request)
|
||
})
|
||
)
|
||
}
|
||
})
|
||
```
|
||
|
||
## 🎨 UI/UX Design-Spezifikationen
|
||
|
||
### Farbschema
|
||
```javascript
|
||
// tailwind.config.js - Custom Colors
|
||
module.exports = {
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
emotions: {
|
||
love: '#ff6b9d', // Liebe - Pink
|
||
joy: '#ffd93d', // Freude - Gelb
|
||
touched: '#6bcf7f', // Rührung - Grün
|
||
nostalgia: '#a78bfa', // Nostalgie - Lila
|
||
surprise: '#fb7185', // Überraschung - Rosa
|
||
pride: '#34d399' // Stolz - Türkis
|
||
},
|
||
wedding: {
|
||
primary: '#ff6b9d',
|
||
secondary: '#a78bfa',
|
||
accent: '#ffd93d',
|
||
neutral: '#6b7280',
|
||
success: '#10b981',
|
||
error: '#ef4444'
|
||
}
|
||
},
|
||
fontFamily: {
|
||
'wedding': ['Poppins', 'Inter', 'sans-serif']
|
||
},
|
||
animation: {
|
||
'bounce-soft': 'bounce 2s infinite',
|
||
'pulse-slow': 'pulse 3s infinite',
|
||
'wiggle': 'wiggle 1s ease-in-out infinite',
|
||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||
'slide-up': 'slideUp 0.3s ease-out'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Responsive Breakpoints
|
||
- **Mobile First:** 320px - 768px (Hauptzielgruppe)
|
||
- **Tablet:** 768px - 1024px (Landscape-Modus)
|
||
- **Desktop:** 1024px+ (Admin-Interface)
|
||
|
||
### Gestaltungs-Prinzipien
|
||
1. **Thumb-Friendly:** Alle wichtigen Buttons in Daumen-Reichweite
|
||
2. **High Contrast:** Gute Lesbarkeit auch bei schlechtem Licht
|
||
3. **Emotional Colors:** Jede Emotion hat eigene Farb-Identität
|
||
4. **Micro-Animations:** Subtile Feedback-Animationen
|
||
5. **Progressive Disclosure:** Komplexität schrittweise aufbauen
|
||
|
||
## 📱 Komponenten-Spezifikationen
|
||
|
||
### EmotionBubble Component
|
||
```jsx
|
||
import React from 'react'
|
||
import { motion } from 'framer-motion'
|
||
|
||
const EmotionBubble = ({ emotion, onClick, isActive, className }) => {
|
||
return (
|
||
<motion.div
|
||
whileTap={{ scale: 0.95 }}
|
||
whileHover={{ scale: 1.05 }}
|
||
className={`
|
||
relative min-w-[100px] h-[100px] rounded-full
|
||
flex flex-col items-center justify-center
|
||
cursor-pointer transition-all duration-300
|
||
shadow-lg hover:shadow-xl
|
||
${isActive ? 'ring-4 ring-offset-2' : ''}
|
||
${className}
|
||
`}
|
||
style={{
|
||
backgroundColor: emotion.color,
|
||
ringColor: emotion.color
|
||
}}
|
||
onClick={() => onClick(emotion.id)}
|
||
>
|
||
{/* Emoji Icon */}
|
||
<span className="text-2xl mb-1">{emotion.icon}</span>
|
||
|
||
{/* Name */}
|
||
<span className="text-xs font-medium text-white text-center px-2">
|
||
{emotion.name}
|
||
</span>
|
||
|
||
{/* Pulse Animation für aktive Emotion */}
|
||
{isActive && (
|
||
<motion.div
|
||
className="absolute inset-0 rounded-full border-2 border-white"
|
||
animate={{ scale: [1, 1.2, 1], opacity: [0.7, 0, 0.7] }}
|
||
transition={{ repeat: Infinity, duration: 2 }}
|
||
/>
|
||
)}
|
||
</motion.div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### CameraCapture Component
|
||
```jsx
|
||
import React, { useRef, useEffect, useState } from 'react'
|
||
import { Camera, RotateCcw, Zap, ZapOff } from 'lucide-react'
|
||
|
||
const CameraCapture = React.forwardRef(({ onCapture }, ref) => {
|
||
const videoRef = useRef(null)
|
||
const canvasRef = useRef(null)
|
||
const [stream, setStream] = useState(null)
|
||
const [hasFlash, setHasFlash] = useState(false)
|
||
const [isFlashOn, setIsFlashOn] = useState(false)
|
||
const [facingMode, setFacingMode] = useState('user') // 'user' oder 'environment'
|
||
|
||
// Kamera initialisieren
|
||
useEffect(() => {
|
||
startCamera()
|
||
return () => {
|
||
stopCamera()
|
||
}
|
||
}, [facingMode])
|
||
|
||
const startCamera = async () => {
|
||
try {
|
||
const constraints = {
|
||
video: {
|
||
facingMode: facingMode,
|
||
width: { ideal: 1080 },
|
||
height: { ideal: 1920 }
|
||
}
|
||
}
|
||
|
||
const mediaStream = await navigator.mediaDevices.getUserMedia(constraints)
|
||
setStream(mediaStream)
|
||
|
||
if (videoRef.current) {
|
||
videoRef.current.srcObject = mediaStream
|
||
}
|
||
|
||
// Flash-Unterstützung prüfen
|
||
const videoTrack = mediaStream.getVideoTracks()[0]
|
||
const capabilities = videoTrack.getCapabilities()
|
||
setHasFlash(capabilities.torch === true)
|
||
|
||
} catch (error) {
|
||
console.error('Kamera-Zugriff fehlgeschlagen:', error)
|
||
alert('Kamera-Zugriff nicht möglich. Bitte Berechtigungen prüfen.')
|
||
}
|
||
}
|
||
|
||
const stopCamera = () => {
|
||
if (stream) {
|
||
stream.getTracks().forEach(track => track.stop())
|
||
setStream(null)
|
||
}
|
||
}
|
||
|
||
const toggleFlash = async () => {
|
||
if (!hasFlash || !stream) return
|
||
|
||
const videoTrack = stream.getVideoTracks()[0]
|
||
try {
|
||
await videoTrack.applyConstraints({
|
||
advanced: [{ torch: !isFlashOn }]
|
||
})
|
||
setIsFlashOn(!isFlashOn)
|
||
} catch (error) {
|
||
console.error('Flash toggle fehlgeschlagen:', error)
|
||
}
|
||
}
|
||
|
||
const switchCamera = () => {
|
||
setFacingMode(prev => prev === 'user' ? 'environment' : 'user')
|
||
}
|
||
|
||
const capturePhoto = () => {
|
||
if (!videoRef.current || !canvasRef.current) return
|
||
|
||
const video = videoRef.current
|
||
const canvas = canvasRef.current
|
||
const ctx = canvas.getContext('2d')
|
||
|
||
// Canvas-Größe an Video anpassen
|
||
canvas.width = video.videoWidth
|
||
canvas.height = video.videoHeight
|
||
|
||
// Video-Frame auf Canvas zeichnen
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||
|
||
// Canvas zu Blob konvertieren
|
||
canvas.toBlob((blob) => {
|
||
if (blob && onCapture) {
|
||
onCapture(blob)
|
||
}
|
||
}, 'image/jpeg', 0.9)
|
||
}
|
||
|
||
return (
|
||
<div className="relative w-full h-full">
|
||
{/* Video Stream */}
|
||
<video
|
||
ref={videoRef}
|
||
autoPlay
|
||
playsInline
|
||
muted
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
|
||
{/* Hidden Canvas für Capture */}
|
||
<canvas ref={canvasRef} className="hidden" />
|
||
|
||
{/* Camera Controls */}
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6">
|
||
<div className="flex items-center justify-between">
|
||
{/* Flash Toggle */}
|
||
<button
|
||
onClick={toggleFlash}
|
||
disabled={!hasFlash}
|
||
className={`
|
||
p-3 rounded-full transition-all
|
||
${hasFlash
|
||
? (isFlashOn ? 'bg-yellow-500' : 'bg-white/20')
|
||
: 'bg-gray-500/50 cursor-not-allowed'
|
||
}
|
||
`}
|
||
>
|
||
{isFlashOn ? (
|
||
<Zap className="w-6 h-6 text-white" />
|
||
) : (
|
||
<ZapOff className="w-6 h-6 text-white" />
|
||
)}
|
||
</button>
|
||
|
||
{/* Capture Button */}
|
||
<button
|
||
onClick={capturePhoto}
|
||
className="w-20 h-20 bg-white rounded-full border-4 border-pink-500
|
||
flex items-center justify-center transition-all
|
||
hover:scale-105 active:scale-95"
|
||
>
|
||
<div className="w-16 h-16 bg-pink-500 rounded-full flex items-center justify-center">
|
||
<Camera className="w-8 h-8 text-white" />
|
||
</div>
|
||
</button>
|
||
|
||
{/* Camera Switch */}
|
||
<button
|
||
onClick={switchCamera}
|
||
className="p-3 rounded-full bg-white/20 transition-all hover:bg-white/30"
|
||
>
|
||
<RotateCcw className="w-6 h-6 text-white" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
|
||
export default CameraCapture
|
||
```
|
||
|
||
### TaskOverlay Component
|
||
```jsx
|
||
import React from 'react'
|
||
import { motion } from 'framer-motion'
|
||
import { Shuffle, ArrowLeft, HelpCircle } from 'lucide-react'
|
||
|
||
const TaskOverlay = ({ task, onNewTask, onBack, onShowHelp }) => {
|
||
if (!task) return null
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -50 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="absolute top-0 left-0 right-0 z-10"
|
||
>
|
||
{/* Background Gradient */}
|
||
<div className="bg-gradient-to-b from-black/70 via-black/50 to-transparent p-6">
|
||
|
||
{/* Header Controls */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<button
|
||
onClick={onBack}
|
||
className="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-all"
|
||
>
|
||
<ArrowLeft className="w-6 h-6 text-white" />
|
||
</button>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={onShowHelp}
|
||
className="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-all"
|
||
>
|
||
<HelpCircle className="w-6 h-6 text-white" />
|
||
</button>
|
||
<button
|
||
onClick={onNewTask}
|
||
className="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-all"
|
||
>
|
||
<Shuffle className="w-6 h-6 text-white" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Task Content */}
|
||
<div className="text-center">
|
||
{/* Emotion Badge */}
|
||
<div
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-4"
|
||
style={{ backgroundColor: task.emotion?.color || '#ff6b9d' }}
|
||
>
|
||
<span className="text-xl">{task.emotion?.icon}</span>
|
||
<span className="text-white font-medium">{task.emotion?.name}</span>
|
||
</div>
|
||
|
||
{/* Task Title */}
|
||
<h2 className="text-2xl font-bold text-white mb-3 leading-tight">
|
||
{task.title}
|
||
</h2>
|
||
|
||
{/* Task Description */}
|
||
<p className="text-lg text-white/90 leading-relaxed max-w-sm mx-auto">
|
||
{task.description}
|
||
</p>
|
||
|
||
{/* Difficulty Indicator */}
|
||
<div className="flex items-center justify-center gap-1 mt-4">
|
||
{[...Array(3)].map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className={`
|
||
w-2 h-2 rounded-full
|
||
${i < getDifficultyLevel(task.difficulty)
|
||
? 'bg-yellow-400'
|
||
: 'bg-white/30'
|
||
}
|
||
`}
|
||
/>
|
||
))}
|
||
<span className="text-white/70 text-sm ml-2 capitalize">
|
||
{task.difficulty}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Example Hint */}
|
||
{task.example_text && (
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
className="mt-4 p-3 bg-white/10 rounded-lg"
|
||
>
|
||
<p className="text-sm text-white/80 italic">
|
||
💡 {task.example_text}
|
||
</p>
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)
|
||
}
|
||
|
||
const getDifficultyLevel = (difficulty) => {
|
||
switch (difficulty) {
|
||
case 'easy': return 1
|
||
case 'medium': return 2
|
||
case 'hard': return 3
|
||
default: return 1
|
||
}
|
||
}
|
||
|
||
export default TaskOverlay
|
||
```
|
||
|
||
## 🧩 Tenant-Defined Tasks & CSV Import
|
||
|
||
### Goals
|
||
- Allow tenants (customers) to create and manage their own photo tasks.
|
||
- Support CSV import for bulk task creation for both Tenant Admins and Super Admin.
|
||
- Keep global (platform) task templates that tenants can also use.
|
||
|
||
### Schema Changes (Tenant Scoping for Tasks)
|
||
```sql
|
||
-- Extend tasks to support tenant/event scoping
|
||
ALTER TABLE tasks ADD COLUMN tenant_id INTEGER NULL; -- owner tenant for custom tasks
|
||
ALTER TABLE tasks ADD COLUMN scope TEXT DEFAULT 'global' CHECK (scope IN ('global','tenant','event'));
|
||
ALTER TABLE tasks ADD COLUMN event_id INTEGER NULL; -- optional link for event-specific tasks
|
||
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_tenant_id FOREIGN KEY (tenant_id) REFERENCES tenants(id);
|
||
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_event_id FOREIGN KEY (event_id) REFERENCES events(id);
|
||
|
||
-- Prevent duplicate titles per emotion within the same tenant (optional)
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_unique_per_tenant ON tasks(tenant_id, emotion_id, title);
|
||
```
|
||
|
||
Notes:
|
||
- Use `scope='global'` for Super Admin library tasks, `tenant` for customer-defined, and `event` for per-event overrides.
|
||
- `tenant_id`/`event_id` are NULL for `global` tasks.
|
||
|
||
### CSV Format
|
||
- Encoding: UTF-8, delimiter: comma.
|
||
- Header columns:
|
||
- emotion (name or ID)
|
||
- title
|
||
- description
|
||
- difficulty (easy|medium|hard)
|
||
- example_text (optional)
|
||
- is_active (1|0, optional; default 1)
|
||
- sort_order (integer, optional)
|
||
- scope (global|tenant|event, optional; default depends on UI context)
|
||
- event_slug (required if scope=event)`n - event_type (slug, optional; defaults to current event type or chosen library type)
|
||
|
||
Example:
|
||
```csv
|
||
emotion,title,description,difficulty,example_text,is_active,sort_order,scope,event_type,event_slug
|
||
Freude,Sprung-Foto,Alle springen gleichzeitig in die Luft!,medium,,1,10,tenant,corporate,
|
||
Liebe,Kuss-Foto,Macht ein romantisches Kuss-Foto,easy,Einfach küssen und lächeln!,1,5,global,wedding,
|
||
Überraschung,Plötzlicher Jubel,Erschreckt das Brautpaar mit Jubel,hard,,1,20,event,,mueller-hochzeit-2025
|
||
```
|
||
|
||
Validation rules:
|
||
- emotion must exist (map by name → ID considering tenant visibility).
|
||
- difficulty in allowed set; length limits per model.
|
||
- If scope=event, event_slug must resolve to a tenant-owned event.
|
||
|
||
### Filament v4 — Tenant Admin Task Resource (with CSV Import)
|
||
```php
|
||
<?php
|
||
// app/Filament/Tenant/Resources/TaskResource.php
|
||
|
||
namespace App\Filament\Tenant\Resources;
|
||
|
||
use App\Models\{Task, Emotion, Event, EventType};
|
||
use Filament\Resources\Resource;
|
||
use Filament\Tables;
|
||
use Filament\Tables\Table;
|
||
use Filament\Actions;
|
||
use Filament\Forms\Form;
|
||
use Filament\Schema as Schema;
|
||
use Illuminate\Support\Facades\DB;
|
||
use League\Csv\Reader;
|
||
|
||
class TaskResource extends Resource
|
||
{
|
||
protected static ?string $model = Task::class;
|
||
protected static ?string $navigationIcon = 'heroicon-o-queue-list';
|
||
protected static ?string $navigationGroup = 'Content';
|
||
protected static ?string $label = 'Aufgaben';
|
||
|
||
public static function form(Form $form): Form
|
||
{
|
||
return $form->schema([
|
||
Schema\Components\Section::make('Aufgabe')
|
||
->schema([
|
||
Schema\Components\Select::make('emotion_id')
|
||
->label('Emotion')
|
||
->relationship('emotion', 'name')
|
||
->required(),
|
||
Schema\Components\TextInput::make('title')->required()->maxLength(255),
|
||
Schema\Components\Textarea::make('description')->required()->rows(3),
|
||
Schema\Components\Select::make('difficulty')
|
||
->options(['easy'=>'Easy','medium'=>'Medium','hard'=>'Hard'])->required(),
|
||
Schema\Components\TextInput::make('example_text'),
|
||
Schema\Components\Toggle::make('is_active')->default(true),
|
||
Schema\Components\TextInput::make('sort_order')->numeric()->default(0),
|
||
])->columns(2),
|
||
]);
|
||
}
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\Columns\TextColumn::make('emotion.name')->label('Emotion')->sortable()->searchable(),
|
||
Tables\Columns\TextColumn::make('title')->searchable(),
|
||
Tables\Columns\BadgeColumn::make('difficulty')->colors(['easy'=>'success','medium'=>'warning','hard'=>'danger']),
|
||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||
Tables\Columns\TextColumn::make('sort_order')->sortable(),
|
||
])
|
||
->filters([
|
||
Tables\Filters\SelectFilter::make('event_type_id')
|
||
->label('Event Type')
|
||
->options(fn () => \App\Models\EventType::orderBy('name')->pluck('name', 'id')->all())
|
||
->query(function ($query, $state) {
|
||
if (! $state) { return; }
|
||
$query->where(function($q) use ($state) {
|
||
$q->where('event_type_id', $state)
|
||
->orWhereExists(function($sub) use ($state) {
|
||
$sub->selectRaw(1)
|
||
->from('emotion_event_type as eet')
|
||
->whereColumn('eet.emotion_id', 'tasks.emotion_id')
|
||
->where('eet.event_type_id', $state);
|
||
});
|
||
});
|
||
}),
|
||
])
|
||
->headerActions([
|
||
Actions\CreateAction::make(),
|
||
Actions\Action::make('importCsv')
|
||
->label('CSV Import')
|
||
->icon('heroicon-o-arrow-up-tray')
|
||
->form([
|
||
Schema\Components\FileUpload::make('csv')->acceptedFileTypes(['text/csv','text/plain'])->required(),
|
||
Schema\Components\Select::make('scope')->options(['tenant'=>'Tenant','event'=>'Event'])->default('tenant')->required(),
|
||
Schema\Components\Select::make('event_id')
|
||
->label('Event (für Scope=Event)')
|
||
->relationship('event', 'name')
|
||
->searchable()
|
||
->visible(fn ($get) => $get('scope') === 'event'),
|
||
])
|
||
->action(function(array $data) {
|
||
$path = $data['csv'];
|
||
$full = storage_path('app/public/'.$path);
|
||
$csv = Reader::createFromPath($full, 'r');
|
||
$csv->setHeaderOffset(0);
|
||
DB::transaction(function() use ($csv, $data) {
|
||
foreach ($csv->getRecords() as $row) {
|
||
$emotion = Emotion::query()->where('name', $row['emotion'] ?? '')->first();
|
||
if (! $emotion) { continue; }
|
||
Task::create([
|
||
'tenant_id' => auth()->user()->tenant_id,
|
||
'event_id' => $data['scope'] === 'event' ? ($data['event_id'] ?? null) : null,
|
||
'scope' => $data['scope'],
|
||
'emotion_id' => $emotion->id,
|
||
'title' => trim($row['title'] ?? ''),
|
||
'description' => trim($row['description'] ?? ''),
|
||
'difficulty' => $row['difficulty'] ?? 'easy',
|
||
'example_text' => $row['example_text'] ?? null,
|
||
'is_active' => (int)($row['is_active'] ?? 1) === 1,
|
||
'sort_order' => (int)($row['sort_order'] ?? 0),
|
||
]);
|
||
}
|
||
});
|
||
})
|
||
->requiresConfirmation(),
|
||
])
|
||
->recordActions([
|
||
Actions\EditAction::make(),
|
||
Actions\DeleteAction::make(),
|
||
]);
|
||
}
|
||
|
||
public static function getEloquentQuery()
|
||
{
|
||
// Tenant scoping for tasks: show global + tenant-owned
|
||
return parent::getEloquentQuery()->where(function($q){
|
||
$tenantId = auth()->user()->tenant_id;
|
||
$q->whereNull('tenant_id')->where('scope','global')
|
||
->orWhere('tenant_id', $tenantId);
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
**EmotionController (filtered by event type):**
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\{Event, Emotion};
|
||
|
||
class EmotionController extends Controller
|
||
{
|
||
public function index(Event $event)
|
||
{
|
||
$typeId = $event->event_type_id;
|
||
$emotions = Emotion::query()
|
||
->where('is_active', true)
|
||
->where(function($q) use ($typeId) {
|
||
$q->whereIn('id', function($sub) use ($typeId) {
|
||
$sub->from('emotion_event_type')
|
||
->select('emotion_id')
|
||
->where('event_type_id', $typeId);
|
||
});
|
||
})
|
||
->orderBy('sort_order')
|
||
->get();
|
||
|
||
return response()->json($emotions);
|
||
}
|
||
}
|
||
```
|
||
|
||
**TaskController (respect event type and scope precedence):**
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\{Event, Task};
|
||
use Illuminate\Http\Request;
|
||
|
||
class TaskController extends Controller
|
||
{
|
||
public function getByEmotion(Request $request, Event $event, $emotionId)
|
||
{
|
||
$tenantId = optional($request->user())->tenant_id; // optional auth
|
||
$typeId = $event->event_type_id;
|
||
|
||
$query = Task::query()
|
||
->where('emotion_id', $emotionId)
|
||
->where('is_active', true)
|
||
->where(function($q) use ($event, $tenantId, $typeId) {
|
||
$q->where('scope', 'event')->where('event_id', $event->id)
|
||
->orWhere(function($q2) use ($tenantId, $typeId) {
|
||
$q2->where('scope','tenant')
|
||
->where('tenant_id', $tenantId)
|
||
->where(function($qq) use ($typeId){
|
||
$qq->whereNull('event_type_id')->orWhere('event_type_id', $typeId);
|
||
});
|
||
})
|
||
->orWhere(function($q3) use ($typeId) {
|
||
$q3->where('scope','global')
|
||
->where(function($qq) use ($typeId){
|
||
$qq->whereNull('event_type_id')->orWhere('event_type_id', $typeId);
|
||
});
|
||
});
|
||
})
|
||
->orderByRaw("CASE WHEN scope='event' THEN 1 WHEN scope='tenant' THEN 2 ELSE 3 END")
|
||
->orderBy('sort_order')
|
||
->get();
|
||
|
||
return response()->json($query);
|
||
}
|
||
|
||
public function getRandomTask(Request $request, Event $event, $emotionId)
|
||
{
|
||
$request->merge(['limit' => 1]);
|
||
$tasks = $this->getByEmotion($request, $event, $emotionId)->getData();
|
||
// pick a random from top precedence group
|
||
$collection = collect($tasks);
|
||
$topScope = $collection->min(fn($t) => $t->scope === 'event' ? 1 : ($t->scope === 'tenant' ? 2 : 3));
|
||
$pool = $collection->filter(fn($t) => ($t->scope === 'event' ? 1 : ($t->scope === 'tenant' ? 2 : 3)) === $topScope);
|
||
return response()->json($pool->random());
|
||
}
|
||
}
|
||
```
|
||
|
||
|
||
### Super Admin — Event Types Resource
|
||
```php
|
||
<?php
|
||
|
||
### Super Admin — Emotion Resource (Event Type Filter)
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Resources/EmotionResource.php
|
||
|
||
namespace App\\Filament\\SuperAdmin\\Resources;
|
||
|
||
use App\\Models\\{Emotion, EventType};
|
||
use Filament\\Resources\\Resource;
|
||
use Filament\\Tables;
|
||
use Filament\\Tables\\Table;
|
||
use Filament\\Actions;
|
||
use Filament\\Forms\\Form;
|
||
use Filament\\Schema as Schema;
|
||
|
||
class EmotionResource extends Resource
|
||
{
|
||
protected static ?string $model = Emotion::class;
|
||
protected static ?string $navigationIcon = "heroicon-o-face-smile";
|
||
protected static ?string $navigationGroup = "Content Management";
|
||
protected static ?string $label = "Emotions";
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\\Columns\\TextColumn::make("name")->searchable()->sortable(),
|
||
Tables\\Columns\\TextColumn::make("icon"),
|
||
Tables\\Columns\\TextColumn::make("color"),
|
||
])
|
||
->filters([
|
||
Tables\\Filters\\SelectFilter::make("event_type")
|
||
->label("Event Type")
|
||
->options(fn () => EventType::orderBy("name")->pluck("name","id")->all())
|
||
->query(function($query, $state){
|
||
if (! $state) { return; }
|
||
$query->whereIn("id", function($sub) use ($state) {
|
||
$sub->from("emotion_event_type")
|
||
->select("emotion_id")
|
||
->where("event_type_id", $state);
|
||
});
|
||
}),
|
||
])
|
||
->recordActions([
|
||
Actions\\EditAction::make(),
|
||
Actions\\DeleteAction::make(),
|
||
])
|
||
->headerActions([ Actions\\CreateAction::make() ]);
|
||
}
|
||
}
|
||
```
|
||
|
||
// app/Filament/SuperAdmin/Resources/EventTypeResource.php
|
||
|
||
namespace App\\Filament\\SuperAdmin\\Resources;
|
||
|
||
use App\\Models\\EventType;
|
||
use Filament\\Resources\\Resource;
|
||
use Filament\\Tables;
|
||
use Filament\\Tables\\Table;
|
||
use Filament\\Forms\\Form;
|
||
use Filament\\Schema as Schema;
|
||
use Filament\\Actions;
|
||
|
||
class EventTypeResource extends Resource
|
||
{
|
||
protected static ?string $model = EventType::class;
|
||
protected static ?string $navigationIcon = "heroicon-o-rectangle-stack";
|
||
protected static ?string $navigationGroup = "Platform Configuration";
|
||
protected static ?string $label = "Event Types";
|
||
|
||
public static function form(Form $form): Form
|
||
{
|
||
return $form->schema([
|
||
Schema\\Components\\TextInput::make("name")->required()->maxLength(100),
|
||
Schema\\Components\\TextInput::make("slug")->required()->maxLength(100)->unique(ignoreRecord: true),
|
||
Schema\\Components\\TextInput::make("icon")->maxLength(64),
|
||
Schema\\Components\\KeyValue::make("settings")->keyLabel("Key")->valueLabel("Value"),
|
||
]);
|
||
}
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\\Columns\\TextColumn::make("name")->searchable()->sortable(),
|
||
Tables\\Columns\\TextColumn::make("slug")->searchable(),
|
||
Tables\\Columns\\TextColumn::make("icon"),
|
||
])
|
||
->recordActions([
|
||
Actions\\EditAction::make(),
|
||
Actions\\DeleteAction::make(),
|
||
])
|
||
->headerActions([ Actions\\CreateAction::make() ]);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Filament v4 — Super Admin Global Task Library (with CSV Import)
|
||
```php
|
||
<?php
|
||
// app/Filament/SuperAdmin/Resources/TaskLibraryResource.php
|
||
|
||
namespace App\Filament\SuperAdmin\Resources;
|
||
|
||
use App\Models\{Task, Emotion, Tenant, Event, EventType};
|
||
use Filament\Resources\Resource;
|
||
use Filament\Tables;
|
||
use Filament\Tables\Table;
|
||
use Filament\Actions;
|
||
use Filament\Forms\Form;
|
||
use Filament\Schema as Schema;
|
||
use League\Csv\Reader;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
class TaskLibraryResource extends Resource
|
||
{
|
||
protected static ?string $model = Task::class;
|
||
protected static ?string $navigationIcon = 'heroicon-o-sparkles';
|
||
protected static ?string $navigationGroup = 'Content Management';
|
||
protected static ?string $label = 'Task Library';
|
||
|
||
public static function table(Table $table): Table
|
||
{
|
||
return $table
|
||
->columns([
|
||
Tables\Columns\TextColumn::make('scope')->badge(),
|
||
Tables\Columns\TextColumn::make('tenant.name')->label('Tenant')->toggleable(isToggledHidden: true),
|
||
Tables\Columns\TextColumn::make('emotion.name')->label('Emotion'),
|
||
Tables\Columns\TextColumn::make('title')->limit(60),
|
||
Tables\Columns\BadgeColumn::make('difficulty'),
|
||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||
] ->filters([
|
||
Tables\\Filters\\SelectFilter::make('event_type_id')
|
||
->label('Event Type')
|
||
->options(fn () => \\App\\Models\\EventType::orderBy('name')->pluck('name','id')->all())
|
||
->query(function (,){
|
||
if(! ){ return; }
|
||
->where(function() use (){
|
||
->where('event_type_id',)
|
||
->orWhereExists(function() use (){
|
||
->selectRaw(1)
|
||
->from('emotion_event_type as eet')
|
||
->whereColumn('eet.emotion_id','tasks.emotion_id')
|
||
->where('eet.event_type_id',);
|
||
});
|
||
});
|
||
}),
|
||
])
|
||
)
|
||
->headerActions([
|
||
Actions\Action::make('importCsv')
|
||
->label('CSV Import')
|
||
->icon('heroicon-o-arrow-up-tray')
|
||
->form([
|
||
Schema\Components\FileUpload::make('csv')->acceptedFileTypes(['text/csv','text/plain'])->required(),
|
||
Schema\Components\Select::make('scope')->options(['global'=>'Global','tenant'=>'Tenant','event'=>'Event'])->default('global')->required(),
|
||
Schema\Components\Select::make('tenant_id')->relationship('tenant','name')->visible(fn($get)=>$get('scope')!=='global'),
|
||
Schema\Components\Select::make('event_id')->label('Event')->relationship('event','name')->visible(fn($get)=>$get('scope')==='event'),
|
||
])
|
||
->action(function(array $data){
|
||
$path = $data['csv'];
|
||
$full = storage_path('app/public/'.$path);
|
||
$csv = Reader::createFromPath($full, 'r');
|
||
$csv->setHeaderOffset(0);
|
||
DB::transaction(function() use ($csv, $data) {
|
||
foreach ($csv->getRecords() as $row) {
|
||
$emotion = Emotion::query()->where('name', $row['emotion'] ?? '')->first();
|
||
if (! $emotion) { continue; }
|
||
Task::create([
|
||
'scope' => $data['scope'],
|
||
'tenant_id' => $data['scope']!=='global' ? ($data['tenant_id'] ?? null) : null,
|
||
'event_id' => $data['scope']==='event' ? ($data['event_id'] ?? null) : null,
|
||
'emotion_id' => $emotion->id,
|
||
'title' => trim($row['title'] ?? ''),
|
||
'description' => trim($row['description'] ?? ''),
|
||
'difficulty' => $row['difficulty'] ?? 'easy',
|
||
'example_text' => $row['example_text'] ?? null,
|
||
'is_active' => (int)($row['is_active'] ?? 1) === 1,
|
||
'sort_order' => (int)($row['sort_order'] ?? 0),
|
||
]);
|
||
}
|
||
});
|
||
})
|
||
->requiresConfirmation(),
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
Implementation notes:
|
||
- Add policies to restrict Tenant Admins from editing global tasks.
|
||
- When importing with scope=tenant/event in Super Admin, enforce that referenced tenant/event exist.
|
||
- Consider a background Job for large CSV files and a progress UI.
|
||
|
||
## 🚀 Deployment & Setup-Anweisungen
|
||
|
||
### Entwicklungs-Setup
|
||
|
||
#### 1. Laravel Backend Setup
|
||
```bash
|
||
# Neues Laravel-Projekt erstellen
|
||
composer create-project laravel/laravel:^12.0 hochzeits-fotobox
|
||
cd hochzeits-fotobox
|
||
|
||
# Abhängigkeiten installieren
|
||
composer require intervention/image
|
||
composer require pusher/pusher-php-server
|
||
composer require laravel/sanctum
|
||
composer require filament/filament:^4.0 -W
|
||
# Falls Filament v3 benoetigt wird (Kompatibilitaet):
|
||
# composer require filament/filament:^3.3 -W
|
||
composer require league/csv:^9.15 # CSV parsing for task imports
|
||
composer require spatie/laravel-translatable # JSON-based field translations (i18n)
|
||
|
||
# Environment konfigurieren
|
||
cp .env.example .env
|
||
# Datenbankverbindung auf SQLite ändern:
|
||
# DB_CONNECTION=sqlite
|
||
# DB_DATABASE=/absolute/path/to/database/database.sqlite
|
||
|
||
# SQLite-Datenbank erstellen
|
||
touch database/database.sqlite
|
||
|
||
# Migrations erstellen und ausführen
|
||
php artisan make:migration create_events_table
|
||
php artisan make:migration create_emotions_table
|
||
php artisan make:migration create_event_types_table
|
||
php artisan make:migration create_emotion_event_type_table
|
||
php artisan make:migration add_event_type_id_to_events
|
||
php artisan make:migration add_event_type_and_scoping_to_tasks
|
||
php artisan make:migration add_i18n_translatables_json --table=events
|
||
php artisan make:migration add_i18n_translatables_json_to_emotions --table=emotions
|
||
php artisan make:migration add_i18n_translatables_json_to_tasks --table=tasks
|
||
php artisan make:migration create_tasks_table
|
||
php artisan make:migration create_photos_table
|
||
php artisan make:migration create_photo_likes_table
|
||
|
||
php artisan migrate
|
||
|
||
# Models und Controller generieren
|
||
php artisan make:model Event -c
|
||
php artisan make:model Emotion -c
|
||
php artisan make:model Task -c
|
||
php artisan make:model Photo -c
|
||
php artisan make:model PhotoLike
|
||
|
||
# Seeder für Demo-Daten
|
||
php artisan make:seeder EmotionsSeeder
|
||
php artisan make:seeder TasksSeeder
|
||
php artisan make:seeder DemoEventSeeder
|
||
|
||
# Storage-Link erstellen
|
||
php artisan storage:link
|
||
|
||
# App-Key generieren
|
||
php artisan key:generate
|
||
```
|
||
|
||
#### 2. Frontend Setup
|
||
```bash
|
||
# NPM-Abhängigkeiten installieren
|
||
npm install
|
||
|
||
# React und Tools
|
||
npm install react react-dom @vitejs/plugin-react
|
||
npm install @tanstack/react-query
|
||
npm install react-router-dom
|
||
npm install framer-motion
|
||
npm install lucide-react
|
||
|
||
# Styling
|
||
npm install tailwindcss postcss autoprefixer
|
||
npm install @tailwindcss/forms @tailwindcss/aspect-ratio
|
||
|
||
# PWA
|
||
npm install vite-plugin-pwa workbox-window
|
||
|
||
# Utils
|
||
npm install date-fns
|
||
npm install clsx
|
||
npm install compressorjs
|
||
npm install react-markdown rehype-sanitize
|
||
|
||
# Tailwind initialisieren
|
||
npx tailwindcss init -p
|
||
|
||
# Verzeichnis-Struktur erstellen
|
||
mkdir -p resources/js/src/{components,pages,hooks,services,context,utils}
|
||
mkdir -p resources/js/src/components/{Camera,Emotions,Gallery,Layout,Forms}
|
||
mkdir -p public/icons
|
||
```
|
||
|
||
#### 3. Seeder-Daten
|
||
|
||
**EmotionsSeeder.php:**
|
||
```php
|
||
<?php
|
||
|
||
use Illuminate\Database\Seeder;
|
||
use App\Models\Emotion;
|
||
|
||
class EmotionsSeeder extends Seeder
|
||
{
|
||
public function run()
|
||
{
|
||
$emotions = [
|
||
[
|
||
'name' => 'Liebe',
|
||
'icon' => '💕',
|
||
'color' => '#ff6b9d',
|
||
'description' => 'Romantische und liebevolle Momente',
|
||
'sort_order' => 1
|
||
],
|
||
[
|
||
'name' => 'Freude',
|
||
'icon' => '😂',
|
||
'color' => '#ffd93d',
|
||
'description' => 'Lustige und fröhliche Augenblicke',
|
||
'sort_order' => 2
|
||
],
|
||
[
|
||
'name' => 'Rührung',
|
||
'icon' => '🥺',
|
||
'color' => '#6bcf7f',
|
||
'description' => 'Emotionale und berührende Szenen',
|
||
'sort_order' => 3
|
||
],
|
||
[
|
||
'name' => 'Nostalgie',
|
||
'icon' => '📸',
|
||
'color' => '#a78bfa',
|
||
'description' => 'Erinnerungen und nostalgische Gefühle',
|
||
'sort_order' => 4
|
||
],
|
||
[
|
||
'name' => 'Überraschung',
|
||
'icon' => '😲',
|
||
'color' => '#fb7185',
|
||
'description' => 'Unerwartete und überraschende Momente',
|
||
'sort_order' => 5
|
||
],
|
||
[
|
||
'name' => 'Stolz',
|
||
'icon' => 'ðŸ†',
|
||
'color' => '#34d399',
|
||
'description' => 'Stolze und triumphale Augenblicke',
|
||
'sort_order' => 6
|
||
]
|
||
];
|
||
|
||
foreach ($emotions as $emotionData) {
|
||
Emotion::create($emotionData);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**TasksSeeder.php:**
|
||
```php
|
||
<?php
|
||
|
||
use Illuminate\Database\Seeder;
|
||
use App\Models\{Emotion, Task};
|
||
|
||
class TasksSeeder extends Seeder
|
||
{
|
||
public function run()
|
||
{
|
||
$tasks = [
|
||
// Liebe-Tasks
|
||
[
|
||
'emotion' => 'Liebe',
|
||
'tasks' => [
|
||
[
|
||
'title' => 'Kuss-Foto',
|
||
'description' => 'Macht ein romantisches Kuss-Foto',
|
||
'difficulty' => 'easy',
|
||
'example_text' => 'Einfach küssen und lächeln!'
|
||
],
|
||
[
|
||
'title' => 'Händchen halten',
|
||
'description' => 'Zeigt eure Hände - verheiratet und verliebt',
|
||
'difficulty' => 'easy',
|
||
'example_text' => 'Ringe in Szene setzen'
|
||
],
|
||
[
|
||
'title' => 'Liebes-Pose',
|
||
'description' => 'Stellt die romantischste Szene aus einem Film nach',
|
||
'difficulty' => 'medium',
|
||
'example_text' => 'Titanic, Dirty Dancing oder eure Lieblings-Romanze'
|
||
],
|
||
[
|
||
'title' => 'Herz formen',
|
||
'description' => 'Formt mit euren Händen ein großes Herz',
|
||
'difficulty' => 'easy',
|
||
'example_text' => 'Gemeinsam über dem Kopf oder vor der Brust'
|
||
]
|
||
]
|
||
],
|
||
|
||
// Freude-Tasks
|
||
[
|
||
'emotion' => 'Freude',
|
||
'tasks' => [
|
||
[
|
||
'title' => 'Sprung-Foto',
|
||
'description' => 'Alle springen gleichzeitig in die Luft!',
|
||
'difficulty' => 'medium',
|
||
'example_text' => 'Auf drei: eins, zwei, drei - SPRUNG!'
|
||
],
|
||
[
|
||
'title' => 'Grimassen-Contest',
|
||
'description' => 'Macht die verrücktesten Gesichter',
|
||
'difficulty' => 'easy',
|
||
'example_text' => 'Je alberner, desto besser!'
|
||
],
|
||
[
|
||
'title' => 'Lach-Attacke',
|
||
'description' => 'Alle lachen so laut sie können',
|
||
'difficulty' => 'easy',
|
||
'example_text' => 'Echtes Lachen ist am schönsten'
|
||
],
|
||
[
|
||
'title' => 'Tanz-Pose',
|
||
'description' => 'Zeigt euren besten Tanz-Move',
|
||
'difficulty' => 'medium',
|
||
'example_text' => 'Disco, Hip-Hop oder klassischer Walzer'
|
||
]
|
||
]
|
||
],
|
||
|
||
// Rührung-Tasks
|
||
[
|
||
'emotion' => 'Rührung',
|
||
'tasks' => [
|
||
[
|
||
'title' => 'Tränen der Freude',
|
||
'description' => 'Zeigt eure emotionalen Gesichter',
|
||
'difficulty' => 'easy',
|
||
'example_text' => 'Echte Gefühle sind die schönsten'
|
||
],
|
||
[
|
||
'title' => 'Familien-Umarmung',
|
||
'description' => 'Große Gruppenumarmung mit der ganzen Familie',
|
||
'difficulty' => 'medium',
|
||
'example_text' => 'Alle kommen zusammen für eine warme Umarmung'
|
||
],
|
||
[
|
||
'title' => 'Stille Momente',
|
||
'description' => 'Ein ruhiges, besinnliches Foto',
|
||
'difficulty' => 'medium',
|
||
'example_text' => 'Manchmal sagt ein stiller Blick mehr als tausend Worte'
|
||
]
|
||
]
|
||
],
|
||
|
||
// Weitere Emotionen...
|
||
];
|
||
|
||
foreach ($tasks as $emotionGroup) {
|
||
$emotion = Emotion::where('name', $emotionGroup['emotion'])->first();
|
||
|
||
if ($emotion) {
|
||
foreach ($emotionGroup['tasks'] as $taskData) {
|
||
Task::create([
|
||
'emotion_id' => $emotion->id,
|
||
'title' => $taskData['title'],
|
||
'description' => $taskData['description'],
|
||
'difficulty' => $taskData['difficulty'],
|
||
'example_text' => $taskData['example_text'] ?? null
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Production Deployment
|
||
|
||
#### 1. Shared Hosting Setup
|
||
```bash
|
||
# .htaccess für Laravel in public_html
|
||
RewriteEngine On
|
||
RewriteCond %{REQUEST_URI} !^/public/
|
||
RewriteRule ^(.*)$ /public/$1 [L]
|
||
|
||
# Laravel-Optimierungen
|
||
php artisan config:cache
|
||
php artisan route:cache
|
||
php artisan view:cache
|
||
|
||
# Frontend Build
|
||
npm run build
|
||
```
|
||
|
||
#### 2. VPS/Cloud Deployment (mit Docker)
|
||
```dockerfile
|
||
# Dockerfile
|
||
FROM php:8.4-apache
|
||
|
||
# PHP Extensions
|
||
RUN docker-php-ext-install pdo pdo_sqlite gd
|
||
|
||
# Apache Konfiguration
|
||
RUN a2enmod rewrite
|
||
COPY . /var/www/html
|
||
COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf
|
||
|
||
# Permissions
|
||
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
|
||
|
||
# Frontend Build
|
||
RUN npm ci && npm run build
|
||
|
||
EXPOSE 80
|
||
```
|
||
|
||
#### 3. Produktions-Optimierungen
|
||
|
||
**Laravel Optimierungen:**
|
||
```php
|
||
// config/app.php - Produktionseinstellungen
|
||
'debug' => false,
|
||
'log_level' => 'error',
|
||
|
||
// config/cache.php - File-Cache für bessere Performance
|
||
'default' => 'file',
|
||
|
||
// config/session.php - Datei-basierte Sessions
|
||
'driver' => 'file',
|
||
'lifetime' => 720, // 12 Stunden für Hochzeitsfeier
|
||
```
|
||
|
||
**Nginx Konfiguration:**
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
server_name your-domain.com;
|
||
root /var/www/hochzeits-fotobox/public;
|
||
|
||
index index.php;
|
||
|
||
# Gzip Kompression
|
||
gzip on;
|
||
gzip_vary on;
|
||
gzip_min_length 1024;
|
||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||
|
||
# Caching für statische Assets
|
||
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
|
||
expires 1y;
|
||
add_header Cache-Control "public, immutable";
|
||
}
|
||
|
||
# PHP Processing
|
||
location ~ \.php$ {
|
||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||
fastcgi_index index.php;
|
||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||
include fastcgi_params;
|
||
}
|
||
|
||
# Laravel Routes
|
||
location / {
|
||
try_files $uri $uri/ /index.php?$query_string;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 🧪 Testing & Qualitätssicherung
|
||
|
||
### Automatisierte Tests
|
||
|
||
#### 1. Backend Tests (PHPUnit)
|
||
```php
|
||
<?php
|
||
// tests/Feature/PhotoUploadTest.php
|
||
|
||
namespace Tests\Feature;
|
||
|
||
use Tests\TestCase;
|
||
use App\Models\{Event, Emotion, Task};
|
||
use Illuminate\Http\UploadedFile;
|
||
use Illuminate\Support\Facades\Storage;
|
||
|
||
class PhotoUploadTest extends TestCase
|
||
{
|
||
public function test_photo_upload_successful()
|
||
{
|
||
Storage::fake('public');
|
||
|
||
$event = Event::factory()->create();
|
||
$emotion = Emotion::factory()->create();
|
||
$task = Task::factory()->create(['emotion_id' => $emotion->id]);
|
||
|
||
$file = UploadedFile::fake()->image('test-photo.jpg', 1200, 1600);
|
||
|
||
$response = $this->postJson("/api/events/{$event->slug}/photos", [
|
||
'photo' => $file,
|
||
'emotion_id' => $emotion->id,
|
||
'task_id' => $task->id,
|
||
'guest_name' => 'Test Gast'
|
||
]);
|
||
|
||
$response->assertStatus(201)
|
||
->assertJsonStructure([
|
||
'success',
|
||
'data' => [
|
||
'id',
|
||
'file_path',
|
||
'thumbnail_path',
|
||
'guest_name',
|
||
'emotion',
|
||
'task'
|
||
]
|
||
]);
|
||
|
||
// Dateien wurden erstellt
|
||
Storage::disk('public')->assertExists('photos/' . $file->hashName());
|
||
Storage::disk('public')->assertExists('thumbnails/' . $file->hashName());
|
||
|
||
// Datenbank-Eintrag
|
||
$this->assertDatabaseHas('photos', [
|
||
'event_id' => $event->id,
|
||
'guest_name' => 'Test Gast',
|
||
'emotion_id' => $emotion->id
|
||
]);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 2. Frontend Tests (Jest + React Testing Library)
|
||
```javascript
|
||
// src/__tests__/EmotionBubble.test.jsx
|
||
|
||
import React from 'react'
|
||
import { render, fireEvent, screen } from '@testing-library/react'
|
||
import EmotionBubble from '../components/Emotions/EmotionBubble'
|
||
|
||
const mockEmotion = {
|
||
id: 1,
|
||
name: 'Freude',
|
||
icon: '😂',
|
||
color: '#ffd93d'
|
||
}
|
||
|
||
describe('EmotionBubble', () => {
|
||
test('renders emotion correctly', () => {
|
||
render(
|
||
<EmotionBubble emotion={mockEmotion} onClick={jest.fn()} />
|
||
)
|
||
|
||
expect(screen.getByText('😂')).toBeInTheDocument()
|
||
expect(screen.getByText('Freude')).toBeInTheDocument()
|
||
})
|
||
|
||
test('calls onClick when clicked', () => {
|
||
const handleClick = jest.fn()
|
||
|
||
render(
|
||
<EmotionBubble emotion={mockEmotion} onClick={handleClick} />
|
||
)
|
||
|
||
fireEvent.click(screen.getByRole('button'))
|
||
expect(handleClick).toHaveBeenCalledWith(1)
|
||
})
|
||
|
||
test('applies correct styling when active', () => {
|
||
render(
|
||
<EmotionBubble
|
||
emotion={mockEmotion}
|
||
onClick={jest.fn()}
|
||
isActive={true}
|
||
/>
|
||
)
|
||
|
||
const bubble = screen.getByRole('button')
|
||
expect(bubble).toHaveClass('ring-4')
|
||
})
|
||
})
|
||
```
|
||
|
||
### Performance-Monitoring
|
||
|
||
#### 1. Laravel Telescope (Development)
|
||
```bash
|
||
composer require laravel/telescope --dev
|
||
php artisan telescope:install
|
||
php artisan migrate
|
||
```
|
||
|
||
#### 2. Frontend Performance (Web Vitals)
|
||
```javascript
|
||
// src/utils/analytics.js
|
||
|
||
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
|
||
|
||
function sendToAnalytics(metric) {
|
||
// Production Analytics Service
|
||
if (process.env.NODE_ENV === 'production') {
|
||
console.log('Web Vital:', metric)
|
||
|
||
// Beispiel: Sende zu Google Analytics
|
||
// gtag('event', 'web_vitals', {
|
||
// event_category: 'Web Vitals',
|
||
// event_action: metric.name,
|
||
// event_value: Math.round(metric.value),
|
||
// non_interaction: true,
|
||
// })
|
||
}
|
||
}
|
||
|
||
export function initializeWebVitals() {
|
||
getCLS(sendToAnalytics)
|
||
getFID(sendToAnalytics)
|
||
getFCP(sendToAnalytics)
|
||
getLCP(sendToAnalytics)
|
||
getTTFB(sendToAnalytics)
|
||
}
|
||
```
|
||
|
||
## 📊 Analytics & Monitoring
|
||
|
||
### Event-Tracking
|
||
```javascript
|
||
// src/services/eventTracking.js
|
||
|
||
class EventTracker {
|
||
constructor(eventSlug) {
|
||
this.eventSlug = eventSlug
|
||
this.events = []
|
||
}
|
||
|
||
track(action, data = {}) {
|
||
const event = {
|
||
timestamp: new Date().toISOString(),
|
||
action,
|
||
data,
|
||
eventSlug: this.eventSlug,
|
||
sessionId: this.getSessionId(),
|
||
userAgent: navigator.userAgent
|
||
}
|
||
|
||
this.events.push(event)
|
||
this.sendToServer(event)
|
||
}
|
||
|
||
// Wichtige Events
|
||
trackPhotoCapture(emotionId, taskId) {
|
||
this.track('photo_capture', { emotionId, taskId })
|
||
}
|
||
|
||
trackPhotoUpload(photoId, success) {
|
||
this.track('photo_upload', { photoId, success })
|
||
}
|
||
|
||
trackEmotionSelect(emotionId) {
|
||
this.track('emotion_select', { emotionId })
|
||
}
|
||
|
||
trackTaskSkip(taskId) {
|
||
this.track('task_skip', { taskId })
|
||
}
|
||
|
||
sendToServer(event) {
|
||
// API-Endpoint für Analytics
|
||
fetch(`/api/events/${this.eventSlug}/analytics`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(event)
|
||
|
||
## Frontend PWA — 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.
|
||
|