tenant admin startseite schicker gestaltet und super-admin und tenant admin (filament) aufgesplittet.

Es gibt nun task collections und vordefinierte tasks für alle. Onboarding verfeinert und webseite-carousel gefixt (logging später entfernen!)
This commit is contained in:
Codex Agent
2025-10-14 15:17:52 +02:00
parent 64a5411fb9
commit 1a4bdb1fe1
92 changed files with 6027 additions and 515 deletions

View File

@@ -3,45 +3,46 @@
namespace App\Filament\Resources;
use App\Filament\Resources\EventResource\Pages;
use App\Support\JoinTokenLayoutRegistry;
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\EventType;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use App\Models\Tenant;
use App\Support\JoinTokenLayoutRegistry;
use BackedEnum;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
use BackedEnum;
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
class EventResource extends Resource
{
protected static ?string $model = Event::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar';
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 20;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
protected static ?int $navigationSort = 20;
public static function form(Schema $form): Schema
{
return $form->schema([
Select::make('tenant_id')
->label(__('admin.events.fields.tenant'))
->options(Tenant::all()->pluck('name', 'id'))
->options(Tenant::query()->pluck('name', 'id'))
->searchable()
->required(),
TextInput::make('name')
@@ -58,11 +59,11 @@ class EventResource extends Resource
->required(),
Select::make('event_type_id')
->label(__('admin.events.fields.type'))
->options(EventType::all()->pluck('name', 'id'))
->options(EventType::query()->pluck('name', 'id'))
->searchable(),
Select::make('package_id')
->label(__('admin.events.fields.package'))
->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id'))
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
->searchable()
->preload()
->required(),
@@ -85,7 +86,7 @@ class EventResource extends Resource
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('tenant_id')->label(__('admin.events.table.tenant'))->sortable(),
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
Tables\Columns\TextColumn::make('name')->limit(30),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
@@ -106,9 +107,9 @@ class EventResource extends Resource
Tables\Columns\TextColumn::make('primary_join_token')
->label(__('admin.events.table.join'))
->getStateUsing(function ($record) {
$token = $record->joinTokens()->orderByDesc('created_at')->first();
$token = $record->joinTokens()->latest()->first();
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
return $token ? url('/e/' . $token->token) : __('admin.events.table.no_join_tokens');
})
->description(function ($record) {
$total = $record->joinTokens()->count();
@@ -127,7 +128,7 @@ class EventResource extends Resource
Actions\Action::make('toggle')
->label(__('admin.events.actions.toggle_active'))
->icon('heroicon-o-power')
->action(fn($record) => $record->update(['is_active' => !$record->is_active])),
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])),
Actions\Action::make('join_tokens')
->label(__('admin.events.actions.join_link_qr'))
->icon('heroicon-o-qr-code')
@@ -152,7 +153,7 @@ class EventResource extends Resource
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/'.$token->token),
'url' => url('/e/' . $token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
@@ -178,19 +179,20 @@ class EventResource extends Resource
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListEvents::route('/'),
'view' => Pages\ViewEvent::route('/{record}'),
'edit' => Pages\EditEvent::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
EventPackagesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListEvents::route('/'),
'create' => Pages\CreateEvent::route('/create'),
'view' => Pages\ViewEvent::route('/{record}'),
'edit' => Pages\EditEvent::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource;
use Filament\Resources\Pages\CreateRecord;
class CreateEvent extends CreateRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -2,26 +2,24 @@
namespace App\Filament\Resources\EventResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use App\Models\EventPackage;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use App\Models\EventPackage;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\TextColumn;
use Filament\Schemas\Schema;
class EventPackagesRelationManager extends RelationManager
{
@@ -52,6 +50,9 @@ class EventPackagesRelationManager extends RelationManager
->numeric()
->default(0)
->readOnly(),
DateTimePicker::make('expires_at')
->label('Ablauf')
->required(),
]);
}
@@ -90,9 +91,7 @@ class EventPackagesRelationManager extends RelationManager
->money('EUR')
->sortable(),
])
->filters([
//
])
->filters([])
->headerActions([
CreateAction::make(),
])
@@ -121,9 +120,8 @@ class EventPackagesRelationManager extends RelationManager
return __('admin.events.relation_managers.event_packages.title');
}
public function getTableQuery(): Builder | Relation
public function getTableQuery(): Builder|Relation
{
return parent::getTableQuery()
->with('package');
return parent::getTableQuery()->with('package');
}
}

View File

@@ -3,46 +3,47 @@
namespace App\Filament\Resources;
use App\Filament\Resources\PhotoResource\Pages;
use App\Models\Photo;
use App\Models\Event;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use App\Models\Photo;
use BackedEnum;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
use BackedEnum;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-photo';
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 30;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.content');
}
protected static ?int $navigationSort = 30;
public static function form(Schema $form): Schema
{
return $form->schema([
Select::make('event_id')
->label(__('admin.photos.fields.event'))
->options(Event::all()->pluck('name', 'id'))
->options(Event::query()->orderBy('name')->pluck('name', 'id'))
->searchable()
->required(),
FileUpload::make('file_path')
->label(__('admin.photos.fields.photo'))
->image() // enable FilePond image preview
->image()
->disk('public')
->directory('photos')
->visibility('public')
@@ -61,9 +62,14 @@ class PhotoResource extends Resource
{
return $table
->columns([
Tables\Columns\ImageColumn::make('file_path')->label(__('admin.photos.table.photo'))->disk('public')->visibility('public'),
Tables\Columns\ImageColumn::make('file_path')
->label(__('admin.photos.table.photo'))
->disk('public')
->visibility('public'),
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('event_id')->label(__('admin.photos.table.event')),
Tables\Columns\TextColumn::make('event.name')
->label(__('admin.photos.table.event'))
->searchable(),
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.photos.table.likes')),
Tables\Columns\IconColumn::make('is_featured')->boolean(),
Tables\Columns\TextColumn::make('created_at')->since(),
@@ -73,13 +79,13 @@ class PhotoResource extends Resource
Actions\EditAction::make(),
Actions\Action::make('feature')
->label(__('admin.photos.actions.feature'))
->visible(fn($record) => !$record->is_featured)
->action(fn($record) => $record->update(['is_featured' => true]))
->visible(fn (Photo $record) => ! $record->is_featured)
->action(fn (Photo $record) => $record->update(['is_featured' => true]))
->icon('heroicon-o-star'),
Actions\Action::make('unfeature')
->label(__('admin.photos.actions.unfeature'))
->visible(fn($record) => $record->is_featured)
->action(fn($record) => $record->update(['is_featured' => false]))
->visible(fn (Photo $record) => $record->is_featured)
->action(fn (Photo $record) => $record->update(['is_featured' => false]))
->icon('heroicon-o-star'),
Actions\DeleteAction::make(),
])
@@ -87,11 +93,11 @@ class PhotoResource extends Resource
Actions\BulkAction::make('feature')
->label(__('admin.photos.actions.feature_selected'))
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => true])),
->action(fn ($records) => $records->each->update(['is_featured' => true])),
Actions\BulkAction::make('unfeature')
->label(__('admin.photos.actions.unfeature_selected'))
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => false])),
->action(fn ($records) => $records->each->update(['is_featured' => false])),
Actions\DeleteBulkAction::make(),
]);
}

View File

@@ -4,28 +4,25 @@ namespace App\Filament\Resources;
use App\Filament\Resources\TenantPackageResource\Pages;
use App\Models\TenantPackage;
use Filament\Schemas\Schema;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Icons\Icon;
use Filament\Resources\Resource;
use Filament\Tables;
use BackedEnum;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
use BackedEnum;
class TenantPackageResource extends Resource
{
@@ -33,31 +30,23 @@ class TenantPackageResource extends Resource
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shopping-bag';
protected static ?string $navigationLabel = 'Packages';
protected static ?string $slug = 'tenant-packages';
public static function form(Schema $form): Schema
{
return $form
->schema([
Section::make('Package Details')
->schema([
Select::make('tenant_id')
->relationship('tenant', 'name')
->required()
->searchable(),
Select::make('package_id')
->relationship('package', 'name')
->required()
->searchable(),
Select::make('tenant_id')
->relationship('tenant', 'name')
->required()
->default(fn () => Auth::user()->tenant_id)
->disabled(),
DateTimePicker::make('expires_at')
->required(),
Toggle::make('is_active')
->default(true),
])
->columns(1),
DateTimePicker::make('purchased_at'),
DateTimePicker::make('expires_at'),
Toggle::make('active')->default(true),
]);
}
@@ -65,26 +54,13 @@ class TenantPackageResource extends Resource
{
return $table
->columns([
TextColumn::make('package.name')
->searchable()
->sortable(),
TextColumn::make('tenant.name')
->badge()
->color('success'),
TextColumn::make('expires_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
IconColumn::make('is_active')
->boolean(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
TextColumn::make('tenant.name')->searchable()->sortable(),
TextColumn::make('package.name')->badge()->color('success'),
TextColumn::make('purchased_at')->dateTime()->sortable(),
TextColumn::make('expires_at')->dateTime()->sortable(),
IconColumn::make('active')->boolean(),
])
->filters([])
->actions([
ActionGroup::make([
ViewAction::make(),
@@ -96,15 +72,12 @@ class TenantPackageResource extends Resource
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id));
]);
}
public static function getRelations(): array
{
return [
//
];
return [];
}
public static function getPages(): array

View File

@@ -3,23 +3,9 @@
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
class CreateTenantPackage extends CreateRecord
{
protected static string $resource = TenantPackageResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['tenant_id'] = Auth::user()->tenant_id;
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -17,9 +17,4 @@ class EditTenantPackage extends EditRecord
Actions\DeleteAction::make(),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -5,8 +5,6 @@ namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
class ListTenantPackages extends ListRecords
{
@@ -18,9 +16,4 @@ class ListTenantPackages extends ListRecords
Actions\CreateAction::make(),
];
}
protected function getTableQuery(): Builder
{
return parent::getTableQuery()->where('tenant_id', Auth::user()->tenant_id);
}
}

View File

@@ -4,36 +4,30 @@ namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Schemas\Schema;
use Filament\Forms\Form;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Icons\Icon;
use Filament\Resources\Resource;
use Filament\Tables;
use BackedEnum;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Request;
use BackedEnum;
use UnitEnum;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-user-circle';
protected static ?string $navigationLabel = 'Users';
protected static ?string $slug = 'users';
public static function form(Schema $form): Schema
@@ -76,8 +70,7 @@ class UserResource extends Resource
->required(fn (string $operation): bool => $operation === 'create')
->dehydrated(false),
])
->columns(1)
->visible(fn (): bool => Auth::user()?->id === Request::route('record')),
->columns(1),
]);
}
@@ -85,22 +78,18 @@ class UserResource extends Resource
{
return $table
->columns([
TextColumn::make('fullName')
->searchable(),
TextColumn::make('email')
->searchable(),
TextColumn::make('username')
->searchable(),
TextColumn::make('phone'),
TextColumn::make('fullName')->sortable()->searchable(),
TextColumn::make('email')->searchable(),
TextColumn::make('username')->searchable(),
TextColumn::make('tenant.name')
->label('Tenant'),
TextColumn::make('email_verified_at')
->dateTime()
->sortable(),
])
->filters([
//
->label(__('admin.common.tenant'))
->badge(),
TextColumn::make('phone'),
IconColumn::make('email_verified_at')
->label(__('admin.users.fields.verified'))
->boolean(),
])
->filters([])
->actions([
ActionGroup::make([
ViewAction::make(),
@@ -111,23 +100,20 @@ class UserResource extends Resource
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id));
]);
}
public static function getRelations(): array
{
return [
//
];
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Hash;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return $data;
}
}

View File

@@ -26,9 +26,4 @@ class EditUser extends EditRecord
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -5,8 +5,6 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables;
use Filament\Tables\Table;
class ListUsers extends ListRecords
{
@@ -18,11 +16,4 @@ class ListUsers extends ListRecords
Actions\CreateAction::make(),
];
}
public function table(Table $table): Table
{
return $table
->recordClasses(fn (User $record) => $record->id === auth()->id() ? 'border-2 border-blue-500' : '')
->poll('30s');
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantOnboardingState;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use BackedEnum;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
class InviteStudio extends Page
{
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-qr-code';
protected string $view = 'filament.tenant.pages.invite-studio';
protected static ?string $navigationLabel = 'Einladungen & QR';
protected static ?string $slug = 'invite-studio';
protected static ?string $title = 'Einladungen & QR-Codes';
protected static ?int $navigationSort = 50;
public ?int $selectedEventId = null;
public string $tokenLabel = '';
public array $tokens = [];
public array $layouts = [];
protected static bool $shouldRegisterNavigation = true;
public function mount(): void
{
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
if (! TenantOnboardingState::completed($tenant)) {
$this->redirect(TenantOnboarding::getUrl());
return;
}
$firstEventId = $tenant->events()->orderBy('date')->value('id');
$this->selectedEventId = $firstEventId;
$this->layouts = $this->buildLayouts();
if ($this->selectedEventId) {
$this->loadEventContext();
}
}
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public function updatedSelectedEventId(): void
{
$this->loadEventContext();
}
public function createInvite(EventJoinTokenService $service): void
{
$this->validate([
'selectedEventId' => ['required', 'exists:events,id'],
'tokenLabel' => ['nullable', 'string', 'max:120'],
]);
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
if (! $event) {
Notification::make()
->title('Event konnte nicht gefunden werden')
->danger()
->send();
return;
}
$label = $this->tokenLabel ?: 'Einladung ' . now()->format('d.m.');
$layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout');
$service->createToken($event, [
'label' => $label,
'metadata' => [
'preferred_layout' => $layoutPreference,
],
'created_by' => auth()->id(),
]);
$this->tokenLabel = '';
$this->loadEventContext();
Notification::make()
->title('Neuer Einladungslink erstellt')
->success()
->send();
}
protected function loadEventContext(): void
{
$tenant = TenantOnboardingState::tenant();
if (! $tenant || ! $this->selectedEventId) {
$this->tokens = [];
return;
}
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
if (! $event) {
$this->tokens = [];
return;
}
$this->tokens = $event->joinTokens()
->orderByDesc('created_at')
->get()
->map(fn (EventJoinToken $token) => $this->mapToken($event, $token))
->toArray();
}
protected function mapToken(Event $event, EventJoinToken $token): array
{
$downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $event->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
return [
'id' => $token->getKey(),
'label' => $token->label ?? 'Einladungslink',
'url' => URL::to('/e/' . $token->token),
'created_at' => optional($token->created_at)->format('d.m.Y H:i'),
'usage_count' => $token->usage_count,
'usage_limit' => $token->usage_limit,
'active' => $token->isActive(),
'downloads' => $downloadUrls,
];
}
protected function buildLayouts(): array
{
return collect(JoinTokenLayoutRegistry::all())
->map(fn (array $layout) => [
'id' => $layout['id'],
'name' => $layout['name'],
'subtitle' => $layout['subtitle'] ?? '',
'description' => $layout['description'] ?? '',
])
->toArray();
}
public function getEventsProperty(): Collection
{
$tenant = TenantOnboardingState::tenant();
if (! $tenant) {
return collect();
}
return $tenant->events()->orderBy('date')->get();
}
}

View File

@@ -0,0 +1,311 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Filament\Tenant\Resources\EventResource;
use App\Models\Event;
use App\Models\EventType;
use App\Models\TaskCollection;
use App\Services\EventJoinTokenService;
use App\Services\Tenant\TaskCollectionImportService;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantOnboardingState;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use BackedEnum;
use UnitEnum;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class TenantOnboarding extends Page
{
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
protected string $view = 'filament.tenant.pages.onboarding';
protected static ?string $navigationLabel = 'Willkommen';
protected static ?string $slug = 'willkommen';
protected static ?string $title = 'Euer Start mit Fotospiel';
protected static UnitEnum|string|null $navigationGroup = null;
public string $step = 'intro';
public array $status = [];
public array $inviteDownloads = [];
public array $selectedPackages = [];
public string $eventName = '';
public ?string $eventDate = null;
public ?int $eventTypeId = null;
public ?string $palette = null;
public ?string $inviteLayout = null;
public bool $isProcessing = false;
protected static bool $shouldRegisterNavigation = true;
public function mount(): void
{
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
$this->status = TenantOnboardingState::status($tenant);
if (TenantOnboardingState::completed($tenant)) {
$this->redirect(EventResource::getUrl());
return;
}
$this->eventDate = Carbon::now()->addWeeks(2)->format('Y-m-d');
$this->eventTypeId = $this->getDefaultEventTypeId();
}
public static function shouldRegisterNavigation(): bool
{
$tenant = TenantOnboardingState::tenant();
return ! TenantOnboardingState::completed($tenant);
}
public function start(): void
{
$this->step = 'packages';
}
public function savePackages(): void
{
$this->validate([
'selectedPackages' => ['required', 'array', 'min:1'],
'selectedPackages.*' => ['integer', 'exists:task_collections,id'],
], [
'selectedPackages.required' => 'Bitte wählt mindestens ein Aufgabenpaket aus.',
]);
$this->step = 'event';
}
public function saveEvent(): void
{
$this->validate([
'eventName' => ['required', 'string', 'max:255'],
'eventDate' => ['required', 'date'],
'eventTypeId' => ['required', 'exists:event_types,id'],
]);
$this->step = 'palette';
}
public function savePalette(): void
{
$this->validate([
'palette' => ['required', 'string'],
]);
$this->step = 'invite';
}
public function finish(
TaskCollectionImportService $importService,
EventJoinTokenService $joinTokenService
): void {
$this->validate([
'inviteLayout' => ['required', 'string'],
], [
'inviteLayout.required' => 'Bitte wählt ein Layout aus.',
]);
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
$this->isProcessing = true;
try {
DB::transaction(function () use ($tenant, $importService, $joinTokenService) {
$event = $this->createEvent($tenant);
$this->importPackages($importService, $this->selectedPackages, $event);
$token = $joinTokenService->createToken($event, [
'label' => 'Fotospiel Einladung',
'metadata' => [
'preferred_layout' => $this->inviteLayout,
],
]);
$settings = $tenant->settings ?? [];
Arr::set($settings, 'branding.palette', $this->palette);
Arr::set($settings, 'branding.primary_event_id', $event->id);
Arr::set($settings, 'branding.preferred_invite_layout', $this->inviteLayout);
$tenant->forceFill(['settings' => $settings])->save();
TenantOnboardingState::markCompleted($tenant, [
'primary_event_id' => $event->id,
'selected_packages' => $this->selectedPackages,
'qr_layout' => $this->inviteLayout,
]);
$this->inviteDownloads = $this->buildInviteDownloads($event, $token);
$this->status = TenantOnboardingState::status($tenant);
Notification::make()
->title('Euer Setup ist bereit!')
->body('Wir haben euer Event erstellt, Aufgaben importiert und euren Einladungslink vorbereitet.')
->success()
->send();
$this->redirect(EventResource::getUrl('view', ['record' => $event]));
});
} catch (Throwable $exception) {
report($exception);
Notification::make()
->title('Setup konnte nicht abgeschlossen werden')
->body('Bitte prüft eure Eingaben oder versucht es später erneut.')
->danger()
->send();
} finally {
$this->isProcessing = false;
}
}
protected function createEvent($tenant): Event
{
$slugBase = Str::slug($this->eventName) ?: 'event';
do {
$slug = Str::of($slugBase)->append('-', Str::random(6))->lower();
} while (Event::where('slug', $slug)->exists());
return Event::create([
'tenant_id' => $tenant->id,
'name' => [
app()->getLocale() => $this->eventName,
'de' => $this->eventName,
],
'description' => null,
'date' => $this->eventDate,
'slug' => (string) $slug,
'event_type_id' => $this->eventTypeId,
'is_active' => true,
'default_locale' => app()->getLocale(),
'status' => 'draft',
'settings' => [
'appearance' => [
'palette' => $this->palette,
],
],
]);
}
protected function importPackages(
TaskCollectionImportService $importService,
array $packageIds,
Event $event
): void {
if (empty($packageIds)) {
return;
}
/** @var EloquentCollection<TaskCollection> $collections */
$collections = TaskCollection::query()
->whereIn('id', $packageIds)
->get();
$collections->each(function (TaskCollection $collection) use ($importService, $event) {
$importService->import($collection, $event);
});
}
protected function buildInviteDownloads(Event $event, $token): array
{
return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $event->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
}
public function getPackageListProperty(): array
{
return TaskCollection::query()
->whereNull('tenant_id')
->orderBy('position')
->get()
->map(fn (TaskCollection $collection) => [
'id' => $collection->getKey(),
'name' => $collection->name,
'description' => $collection->description,
])
->toArray();
}
public function getEventTypeOptionsProperty(): array
{
return EventType::query()
->orderBy('name->' . app()->getLocale())
->get()
->mapWithKeys(function (EventType $type) {
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name);
return [$type->getKey() => $name];
})
->toArray();
}
public function getPaletteOptionsProperty(): array
{
return [
'romance' => [
'label' => 'Rosé & Gold',
'description' => 'Warme Rosé-Töne mit goldenen Akzenten romantisch und elegant.',
],
'sunset' => [
'label' => 'Sonnenuntergang',
'description' => 'Leuchtende Orange- und Pink-Verläufe für lebhafte Partys.',
],
'evergreen' => [
'label' => 'Evergreen',
'description' => 'Sanfte Grüntöne und Naturakzente für Boho- & Outdoor-Events.',
],
'midnight' => [
'label' => 'Midnight',
'description' => 'Tiefes Navy und Flieder perfekt für elegante Abendveranstaltungen.',
],
];
}
public function getLayoutOptionsProperty(): array
{
return collect(JoinTokenLayoutRegistry::all())
->map(fn ($layout) => [
'id' => $layout['id'],
'name' => $layout['name'],
'subtitle' => $layout['subtitle'] ?? '',
'description' => $layout['description'] ?? '',
])
->toArray();
}
protected function getDefaultEventTypeId(): ?int
{
return EventType::query()->orderBy('name->' . app()->getLocale())->value('id');
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\EventResource\Pages;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantOnboardingState;
use App\Models\Event;
use App\Models\EventType;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Hidden;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
use BackedEnum;
use App\Filament\Tenant\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
class EventResource extends Resource
{
protected static ?string $model = Event::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar';
protected static UnitEnum|string|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
protected static ?int $navigationSort = 20;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function form(Schema $form): Schema
{
$tenantId = Auth::user()?->tenant_id;
return $form->schema([
Hidden::make('tenant_id')
->default($tenantId)
->dehydrated(),
TextInput::make('name')
->label(__('admin.events.fields.name'))
->required()
->maxLength(255),
TextInput::make('slug')
->label(__('admin.events.fields.slug'))
->required()
->unique(ignoreRecord: true)
->maxLength(255),
DatePicker::make('date')
->label(__('admin.events.fields.date'))
->required(),
Select::make('event_type_id')
->label(__('admin.events.fields.type'))
->options(EventType::all()->pluck('name', 'id'))
->searchable(),
Select::make('package_id')
->label(__('admin.events.fields.package'))
->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id'))
->searchable()
->preload()
->required(),
TextInput::make('default_locale')
->label(__('admin.events.fields.default_locale'))
->default('de')
->maxLength(5),
Toggle::make('is_active')
->label(__('admin.events.fields.is_active'))
->default(true),
KeyValue::make('settings')
->label(__('admin.events.fields.settings'))
->keyLabel(__('admin.common.key'))
->valueLabel(__('admin.common.value')),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('eventPackage.package.name')
->label(__('admin.events.table.package'))
->badge()
->color('success'),
Tables\Columns\TextColumn::make('name')->limit(30),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('default_locale'),
Tables\Columns\TextColumn::make('eventPackage.used_photos')
->label(__('admin.events.table.used_photos'))
->badge(),
Tables\Columns\TextColumn::make('eventPackage.remaining_photos')
->label(__('admin.events.table.remaining_photos'))
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
Tables\Columns\TextColumn::make('primary_join_token')
->label(__('admin.events.table.join'))
->getStateUsing(function ($record) {
$token = $record->joinTokens()->orderByDesc('created_at')->first();
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
})
->description(function ($record) {
$total = $record->joinTokens()->count();
return $total > 0
? __('admin.events.table.join_tokens_total', ['count' => $total])
: __('admin.events.table.join_tokens_missing');
})
->copyable()
->copyMessage(__('admin.events.messages.join_link_copied')),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->modifyQueryUsing(function (Builder $query) {
if ($tenantId = Auth::user()?->tenant_id) {
$query->where('tenant_id', $tenantId);
}
})
->filters([])
->actions([
Actions\EditAction::make(),
Actions\Action::make('toggle')
->label(__('admin.events.actions.toggle_active'))
->icon('heroicon-o-power')
->action(fn($record) => $record->update(['is_active' => !$record->is_active])),
Actions\Action::make('join_tokens')
->label(__('admin.events.actions.join_link_qr'))
->icon('heroicon-o-qr-code')
->modalHeading(__('admin.events.modal.join_link_heading'))
->modalSubmitActionLabel(__('admin.common.close'))
->modalWidth('xl')
->modalContent(function ($record) {
$tokens = $record->joinTokens()
->orderByDesc('created_at')
->get()
->map(function ($token) use ($record) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
return route('tenant.events.join-tokens.layouts.download', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
return [
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/'.$token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('tenant.events.join-tokens.layouts.index', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
]),
];
});
return view('filament.events.join-link', [
'event' => $record,
'tokens' => $tokens,
]);
}),
])
->bulkActions([
Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListEvents::route('/'),
'create' => Pages\CreateEvent::route('/create'),
'view' => Pages\ViewEvent::route('/{record}'),
'edit' => Pages\EditEvent::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
EventPackagesRelationManager::class,
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\CreateRecord;
class CreateEvent extends CreateRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\EditRecord;
class EditEvent extends EditRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\ListRecords;
class ListEvents extends ListRecords
{
protected static string $resource = EventResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\ViewRecord;
class ViewEvent extends ViewRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use App\Models\EventPackage;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\TextColumn;
use Filament\Schemas\Schema;
class EventPackagesRelationManager extends RelationManager
{
protected static string $relationship = 'eventPackages';
public function form(Schema $schema): Schema
{
return $schema->schema([
Select::make('package_id')
->label('Package')
->relationship('package', 'name')
->searchable()
->preload()
->required(),
TextInput::make('purchased_price')
->label('Kaufpreis')
->prefix('€')
->numeric()
->step(0.01)
->required(),
TextInput::make('used_photos')
->label('Verwendete Fotos')
->numeric()
->default(0)
->readOnly(),
TextInput::make('used_guests')
->label('Verwendete Gäste')
->numeric()
->default(0)
->readOnly(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('package.name')
->columns([
TextColumn::make('package.name')
->label('Package')
->badge()
->color('success'),
TextColumn::make('used_photos')
->label('Verwendete Fotos')
->badge(),
TextColumn::make('remaining_photos')
->label('Verbleibende Fotos')
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (EventPackage $record) => $record->remaining_photos),
TextColumn::make('used_guests')
->label('Verwendete Gäste')
->badge(),
TextColumn::make('remaining_guests')
->label('Verbleibende Gäste')
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (EventPackage $record) => $record->remaining_guests),
TextColumn::make('expires_at')
->label('Ablauf')
->dateTime()
->badge()
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'),
TextColumn::make('price')
->label('Preis')
->money('EUR')
->sortable(),
])
->filters([
//
])
->headerActions([
CreateAction::make(),
])
->actions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
public function getRelationExistenceQuery(
Builder $query,
string $relationshipName,
?string $ownerKeyName,
mixed $ownerKeyValue,
): Builder {
return $query;
}
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return __('admin.events.relation_managers.event_packages.title');
}
public function getTableQuery(): Builder | Relation
{
return parent::getTableQuery()
->with('package');
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Models\Photo;
use App\Models\Event;
use App\Support\TenantOnboardingState;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\KeyValue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
use BackedEnum;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-photo';
protected static UnitEnum|string|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.content');
}
protected static ?int $navigationSort = 30;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function form(Schema $form): Schema
{
$tenantId = Auth::user()?->tenant_id;
return $form->schema([
Select::make('event_id')
->label(__('admin.photos.fields.event'))
->options(
Event::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->pluck('name', 'id')
)
->searchable()
->required(),
FileUpload::make('file_path')
->label(__('admin.photos.fields.photo'))
->image() // enable FilePond image preview
->disk('public')
->directory('photos')
->visibility('public')
->required(),
Toggle::make('is_featured')
->label(__('admin.photos.fields.is_featured'))
->default(false),
KeyValue::make('metadata')
->label(__('admin.photos.fields.metadata'))
->keyLabel(__('admin.common.key'))
->valueLabel(__('admin.common.value')),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('file_path')->label(__('admin.photos.table.photo'))->disk('public')->visibility('public'),
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('event.name')->label(__('admin.photos.table.event'))->searchable(),
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.photos.table.likes')),
Tables\Columns\IconColumn::make('is_featured')->boolean(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->modifyQueryUsing(function (Builder $query) {
if ($tenantId = Auth::user()?->tenant_id) {
$query->whereHas('event', fn (Builder $eventQuery) => $eventQuery->where('tenant_id', $tenantId));
}
})
->filters([])
->actions([
Actions\EditAction::make(),
Actions\Action::make('feature')
->label(__('admin.photos.actions.feature'))
->visible(fn($record) => !$record->is_featured)
->action(fn($record) => $record->update(['is_featured' => true]))
->icon('heroicon-o-star'),
Actions\Action::make('unfeature')
->label(__('admin.photos.actions.unfeature'))
->visible(fn($record) => $record->is_featured)
->action(fn($record) => $record->update(['is_featured' => false]))
->icon('heroicon-o-star'),
Actions\DeleteAction::make(),
])
->bulkActions([
Actions\BulkAction::make('feature')
->label(__('admin.photos.actions.feature_selected'))
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => true])),
Actions\BulkAction::make('unfeature')
->label(__('admin.photos.actions.unfeature_selected'))
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => false])),
Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPhotos::route('/'),
'view' => Pages\ViewPhoto::route('/{record}'),
'edit' => Pages\EditPhoto::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Filament\Tenant\Resources\PhotoResource;
use Filament\Resources\Pages\EditRecord;
class EditPhoto extends EditRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Filament\Tenant\Resources\PhotoResource;
use Filament\Resources\Pages\ListRecords;
class ListPhotos extends ListRecords
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Filament\Tenant\Resources\PhotoResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPhoto extends ViewRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Models\Event;
use App\Models\EventType;
use App\Models\TaskCollection;
use App\Services\Tenant\TaskCollectionImportService;
use Filament\Facades\Filament;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Actions;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Notifications\Notification;
use Illuminate\Support\Str;
use BackedEnum;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use App\Support\TenantOnboardingState;
class TaskCollectionResource extends Resource
{
protected static ?string $model = TaskCollection::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-folder';
protected static ?int $navigationSort = 50;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function getNavigationGroup(): string
{
return __('admin.nav.library');
}
public static function form(Schema $schema): Schema
{
$tenantId = auth()->user()?->tenant_id;
return $schema->components([
Section::make(__('Task Collection Details'))
->schema([
TextInput::make('name_translations.de')
->label(__('Name (DE)'))
->required()
->maxLength(255)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
TextInput::make('name_translations.en')
->label(__('Name (EN)'))
->maxLength(255)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
Select::make('event_type_id')
->label(__('Event Type'))
->options(fn () => EventType::orderBy('name->' . app()->getLocale())
->get()
->mapWithKeys(function (EventType $type) {
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? reset($type->name);
return [$type->id => $name];
})->toArray())
->searchable()
->required()
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
Textarea::make('description_translations.de')
->label(__('Description (DE)'))
->rows(3)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
Textarea::make('description_translations.en')
->label(__('Description (EN)'))
->rows(3)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable(['name_translations->de', 'name_translations->en'])
->sortable(),
BadgeColumn::make('eventType.name')
->label(__('Event Type'))
->color('info'),
IconColumn::make('tenant_id')
->label(__('Scope'))
->boolean()
->trueIcon('heroicon-o-user-group')
->falseIcon('heroicon-o-globe-alt')
->state(fn (TaskCollection $record) => $record->tenant_id !== null)
->tooltip(fn (TaskCollection $record) => $record->tenant_id ? __('Tenant-only') : __('Global template')),
TextColumn::make('tasks_count')
->label(__('Tasks'))
->counts('tasks')
->sortable(),
])
->filters([
SelectFilter::make('event_type_id')
->label(__('Event Type'))
->relationship('eventType', 'name->' . app()->getLocale()),
SelectFilter::make('scope')
->options([
'global' => __('Global template'),
'tenant' => __('Tenant-owned'),
])
->query(function ($query, $value) {
$tenantId = auth()->user()?->tenant_id;
if ($value === 'global') {
$query->whereNull('tenant_id');
}
if ($value === 'tenant') {
$query->where('tenant_id', $tenantId);
}
}),
])
->actions([
\Filament\Actions\Action::make('import')
->label(__('Import to Event'))
->icon('heroicon-o-cloud-arrow-down')
->form([
Select::make('event_slug')
->label(__('Select Event'))
->options(function () {
$tenantId = auth()->user()?->tenant_id;
return Event::where('tenant_id', $tenantId)
->orderBy('date', 'desc')
->get()
->mapWithKeys(function (Event $event) {
$name = $event->name[app()->getLocale()] ?? $event->name['de'] ?? reset($event->name);
return [
$event->slug => sprintf('%s (%s)', $name, $event->date?->format('d.m.Y')),
];
})->toArray();
})
->required()
->searchable(),
])
->action(function (TaskCollection $record, array $data) {
$event = Event::where('slug', $data['event_slug'])
->where('tenant_id', auth()->user()?->tenant_id)
->firstOrFail();
/** @var TaskCollectionImportService $service */
$service = app(TaskCollectionImportService::class);
$service->import($record, $event);
Notification::make()
->title(__('Task collection imported'))
->body(__('The collection :name has been imported.', ['name' => $record->name]))
->success()
->send();
}),
Actions\EditAction::make()
->label(__('Edit'))
->visible(fn (TaskCollection $record) => $record->tenant_id === auth()->user()?->tenant_id),
])
->headerActions([
Actions\CreateAction::make()
->label(__('Create Task Collection'))
->mutateFormDataUsing(function (array $data) {
$tenantId = auth()->user()?->tenant_id;
$data['tenant_id'] = $tenantId;
$data['slug'] = static::generateSlug($data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection', $tenantId);
return $data;
}),
])
->bulkActions([
Actions\DeleteBulkAction::make()
->visible(fn () => false),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTaskCollections::route('/'),
'create' => Pages\CreateTaskCollection::route('/create'),
'edit' => Pages\EditTaskCollection::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
$tenantId = auth()->user()?->tenant_id;
return parent::getEloquentQuery()
->forTenant($tenantId)
->with('eventType')
->withCount('tasks');
}
public static function getGloballySearchableAttributes(): array
{
return ['name_translations->de', 'name_translations->en'];
}
public static function generateSlug(string $base, int $tenantId): string
{
$slugBase = Str::slug($base) ?: 'collection';
do {
$candidate = $slugBase . '-' . $tenantId . '-' . Str::random(4);
} while (TaskCollection::where('slug', $candidate)->exists());
return $candidate;
}
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
{
$tenant ??= Filament::getTenant();
if (! $tenant) {
return $query;
}
return $query->where(function (Builder $innerQuery) use ($tenant) {
$innerQuery->whereNull('tenant_id')
->orWhere('tenant_id', $tenant->getKey());
});
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Filament\Tenant\Resources\TaskCollectionResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class CreateTaskCollection extends CreateRecord
{
protected static string $resource = TaskCollectionResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$tenantId = Auth::user()?->tenant_id;
$data['tenant_id'] = $tenantId;
$data['slug'] = TaskCollectionResource::generateSlug(
$data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection',
$tenantId
);
return $data;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Filament\Tenant\Resources\TaskCollectionResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Auth;
class EditTaskCollection extends EditRecord
{
protected static string $resource = TaskCollectionResource::class;
protected function authorizeAccess(): void
{
parent::authorizeAccess();
$record = $this->getRecord();
if ($record->tenant_id !== Auth::user()?->tenant_id) {
abort(403);
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Filament\Tenant\Resources\TaskCollectionResource;
use Filament\Resources\Pages\ListRecords;
class ListTaskCollections extends ListRecords
{
protected static string $resource = TaskCollectionResource::class;
}

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\TaskResource\Pages;
use App\Models\Event;
use App\Models\Task;
use App\Support\TenantOnboardingState;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static ?int $navigationSort = 40;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.library');
}
public static function form(Schema $form): Schema
{
$tenantId = Auth::user()?->tenant_id;
return $form->schema([
Select::make('emotion_id')
->relationship('emotion', 'name')
->required()
->searchable()
->preload(),
Select::make('event_type_id')
->relationship('eventType', 'name')
->searchable()
->preload()
->label(__('admin.tasks.fields.event_type_optional')),
SchemaTabs::make('content_tabs')
->label(__('admin.tasks.fields.content_localization'))
->tabs([
SchemaTab::make(__('admin.common.german'))
->icon('heroicon-o-language')
->schema([
TextInput::make('title.de')
->label(__('admin.tasks.fields.title_de'))
->required(),
MarkdownEditor::make('description.de')
->label(__('admin.tasks.fields.description_de'))
->columnSpanFull(),
MarkdownEditor::make('example_text.de')
->label(__('admin.tasks.fields.example_de'))
->columnSpanFull(),
]),
SchemaTab::make(__('admin.common.english'))
->icon('heroicon-o-language')
->schema([
TextInput::make('title.en')
->label(__('admin.tasks.fields.title_en'))
->required(),
MarkdownEditor::make('description.en')
->label(__('admin.tasks.fields.description_en'))
->columnSpanFull(),
MarkdownEditor::make('example_text.en')
->label(__('admin.tasks.fields.example_en'))
->columnSpanFull(),
]),
])
->columnSpanFull(),
Select::make('difficulty')
->label(__('admin.tasks.fields.difficulty.label'))
->options([
'easy' => __('admin.tasks.fields.difficulty.easy'),
'medium' => __('admin.tasks.fields.difficulty.medium'),
'hard' => __('admin.tasks.fields.difficulty.hard'),
])
->default('easy'),
TextInput::make('sort_order')
->numeric()
->default(0),
Toggle::make('is_active')
->default(true),
Select::make('assigned_events')
->label(__('admin.tasks.fields.events'))
->multiple()
->relationship(
'assignedEvents',
'name',
fn (Builder $query) => $tenantId
? $query->where('tenant_id', $tenantId)
: $query
)
->searchable()
->preload()
->getOptionLabelFromRecordUsing(fn (Event $record) => $record->name)
->helperText(__('admin.tasks.fields.events_helper')),
])->columns(2);
}
public static function table(Table $table): Table
{
$tenantId = Auth::user()?->tenant_id;
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('#')
->sortable(),
Tables\Columns\TextColumn::make('title')
->label(__('admin.tasks.table.title'))
->getStateUsing(function ($record) {
$value = $record->title;
if (is_array($value)) {
$loc = app()->getLocale();
return $value[$loc] ?? ($value['de'] ?? ($value['en'] ?? ''));
}
return (string) $value;
})
->limit(60)
->searchable(['title->de', 'title->en']),
Tables\Columns\TextColumn::make('emotion.name')
->label(__('admin.tasks.fields.emotion'))
->toggleable(),
Tables\Columns\TextColumn::make('eventType.name')
->label(__('admin.tasks.fields.event_type'))
->toggleable(),
Tables\Columns\TextColumn::make('assignedEvents.name')
->label(__('admin.tasks.table.events'))
->badge()
->separator(', ')
->limitList(2),
Tables\Columns\TextColumn::make('difficulty')
->label(__('admin.tasks.fields.difficulty.label'))
->badge(),
Tables\Columns\IconColumn::make('is_active')
->label(__('admin.tasks.table.is_active'))
->boolean(),
Tables\Columns\TextColumn::make('sort_order')
->label(__('admin.tasks.table.sort_order'))
->sortable(),
])
->filters([])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->bulkActions([
Actions\DeleteBulkAction::make(),
])
->modifyQueryUsing(function (Builder $query) use ($tenantId) {
if (! $tenantId) {
return $query;
}
$query->forTenant($tenantId);
return $query;
});
}
public static function getPages(): array
{
return [
'index' => Pages\ListTasks::route('/'),
'create' => Pages\CreateTask::route('/create'),
'edit' => Pages\EditTask::route('/{record}/edit'),
];
}
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
{
$tenant ??= Filament::getTenant();
if (! $tenant) {
return $query;
}
return $query->where(function (Builder $innerQuery) use ($tenant) {
$innerQuery->whereNull('tenant_id')
->orWhere('tenant_id', $tenant->getKey());
});
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
use App\Filament\Tenant\Resources\TaskResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTask extends CreateRecord
{
protected static string $resource = TaskResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
use App\Filament\Tenant\Resources\TaskResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTask extends EditRecord
{
protected static string $resource = TaskResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
use App\Filament\Tenant\Resources\TaskResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTasks extends ListRecords
{
protected static string $resource = TaskResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Filament\Widgets;
namespace App\Filament\Tenant\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Filament\Widgets;
namespace App\Filament\Tenant\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Filament\Widgets;
namespace App\Filament\Tenant\Widgets;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EmotionStoreRequest;
use App\Http\Requests\Tenant\EmotionUpdateRequest;
use App\Http\Resources\Tenant\EmotionResource;
use App\Models\Emotion;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
class EmotionController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$tenantId = $request->tenant->id;
$query = Emotion::query()
->whereNull('tenant_id')
->orWhere('tenant_id', $tenantId)
->with('eventTypes');
if ($request->boolean('only_tenant')) {
$query->where('tenant_id', $tenantId);
}
if ($request->boolean('only_global')) {
$query->whereNull('tenant_id');
}
$query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id');
$emotions = $query->paginate($request->integer('per_page', 25));
return EmotionResource::collection($emotions);
}
public function store(EmotionStoreRequest $request): JsonResponse
{
$data = $request->validated();
$payload = [
'tenant_id' => $request->tenant->id,
'name' => $this->localizeValue($data['name']),
'description' => $this->localizeValue($data['description'] ?? null, allowNull: true),
'icon' => $data['icon'] ?? 'lucide-smile',
'color' => $this->normalizeColor($data['color'] ?? '#6366f1'),
'sort_order' => $data['sort_order'] ?? 0,
'is_active' => $data['is_active'] ?? true,
];
$emotion = null;
DB::transaction(function () use (&$emotion, $payload, $data) {
$emotion = Emotion::create($payload);
if (! empty($data['event_type_ids'])) {
$emotion->eventTypes()->sync($data['event_type_ids']);
}
});
return response()->json([
'message' => __('Emotion erfolgreich erstellt.'),
'data' => new EmotionResource($emotion->fresh('eventTypes')),
], 201);
}
public function update(EmotionUpdateRequest $request, Emotion $emotion): JsonResponse
{
if ($emotion->tenant_id && $emotion->tenant_id !== $request->tenant->id) {
abort(403, 'Emotion gehört nicht zu diesem Tenant.');
}
if (is_null($emotion->tenant_id) && $request->hasAny(['name', 'description', 'icon', 'color', 'sort_order'])) {
abort(403, 'Globale Emotions können nicht bearbeitet werden.');
}
$data = $request->validated();
DB::transaction(function () use ($emotion, $data) {
$update = [];
if (array_key_exists('name', $data)) {
$update['name'] = $this->localizeValue($data['name'], allowNull: false, fallback: $emotion->name);
}
if (array_key_exists('description', $data)) {
$update['description'] = $this->localizeValue($data['description'], allowNull: true, fallback: $emotion->description);
}
if (array_key_exists('icon', $data)) {
$update['icon'] = $data['icon'] ?? $emotion->icon;
}
if (array_key_exists('color', $data)) {
$update['color'] = $this->normalizeColor($data['color'] ?? $emotion->color);
}
if (array_key_exists('sort_order', $data)) {
$update['sort_order'] = $data['sort_order'] ?? 0;
}
if (array_key_exists('is_active', $data)) {
$update['is_active'] = $data['is_active'];
}
if (! empty($update)) {
$emotion->update($update);
}
if (array_key_exists('event_type_ids', $data)) {
$emotion->eventTypes()->sync($data['event_type_ids'] ?? []);
}
});
return response()->json([
'message' => __('Emotion aktualisiert.'),
'data' => new EmotionResource($emotion->fresh('eventTypes')),
]);
}
protected function localizeValue(mixed $value, bool $allowNull = false, ?array $fallback = null): ?array
{
if ($allowNull && ($value === null || $value === '')) {
return null;
}
if (is_array($value)) {
$filtered = array_filter($value, static fn ($text) => is_string($text) && $text !== '');
if (! empty($filtered)) {
return $filtered;
}
return $allowNull ? null : ($fallback ?? []);
}
if (is_string($value) && $value !== '') {
$locale = app()->getLocale() ?: 'de';
return [$locale => $value];
}
return $allowNull ? null : $fallback;
}
protected function normalizeColor(string $color): string
{
$normalized = ltrim($color, '#');
if (strlen($normalized) === 6) {
return '#' . strtolower($normalized);
}
return '#6366f1';
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Resources\Tenant\TaskCollectionResource;
use App\Models\Event;
use App\Models\TaskCollection;
use App\Services\Tenant\TaskCollectionImportService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\Rule;
class TaskCollectionController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$tenantId = $request->tenant->id;
$query = TaskCollection::query()
->forTenant($tenantId)
->with('eventType')
->withCount('tasks')
->orderBy('position')
->orderBy('id');
if ($search = $request->query('search')) {
$query->where(function ($inner) use ($search) {
$inner->where('name_translations->de', 'like', "%{$search}%")
->orWhere('name_translations->en', 'like', "%{$search}%");
});
}
if ($eventTypeSlug = $request->query('event_type')) {
$query->whereHas('eventType', fn ($q) => $q->where('slug', $eventTypeSlug));
}
if ($request->boolean('only_global')) {
$query->whereNull('tenant_id');
}
if ($request->boolean('only_tenant')) {
$query->where('tenant_id', $tenantId);
}
$perPage = $request->integer('per_page', 15);
return TaskCollectionResource::collection(
$query->paginate($perPage)
);
}
public function show(Request $request, TaskCollection $collection): JsonResponse
{
$this->authorizeAccess($request, $collection);
$collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')]);
return response()->json(new TaskCollectionResource($collection));
}
public function activate(
Request $request,
TaskCollection $collection,
TaskCollectionImportService $importService
): JsonResponse {
$this->authorizeAccess($request, $collection);
$data = $request->validate([
'event_slug' => ['required', 'string', Rule::exists('events', 'slug')->where('tenant_id', $request->tenant->id)],
]);
$event = Event::where('slug', $data['event_slug'])
->where('tenant_id', $request->tenant->id)
->firstOrFail();
$result = $importService->import($collection, $event);
return response()->json([
'message' => __('Task-Collection erfolgreich importiert.'),
'collection' => new TaskCollectionResource($result['collection']->load('eventType')->loadCount('tasks')),
'created_task_ids' => $result['created_task_ids'],
'attached_task_ids' => $result['attached_task_ids'],
]);
}
protected function authorizeAccess(Request $request, TaskCollection $collection): void
{
if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) {
abort(404);
}
}
}

View File

@@ -23,14 +23,27 @@ class TaskController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = Task::where('tenant_id', $request->tenant->id)
$tenantId = $request->tenant->id;
$query = Task::query()
->where(function ($inner) use ($tenantId) {
$inner->whereNull('tenant_id')
->orWhere('tenant_id', $tenantId);
})
->with(['taskCollection', 'assignedEvents'])
->orderByRaw('tenant_id is null desc')
->orderBy('sort_order')
->orderBy('created_at', 'desc');
// Search and filters
if ($search = $request->get('search')) {
$query->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
$query->where(function ($inner) use ($search) {
$like = '%' . $search . '%';
$inner->where('title->de', 'like', $like)
->orWhere('title->en', 'like', $like)
->orWhere('description->de', 'like', $like)
->orWhere('description->en', 'like', $like);
});
}
if ($collectionId = $request->get('collection_id')) {
@@ -55,15 +68,19 @@ class TaskController extends Controller
*/
public function store(TaskStoreRequest $request): JsonResponse
{
$task = Task::create(array_merge($request->validated(), [
'tenant_id' => $request->tenant->id,
]));
$collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
if ($collectionId = $request->input('collection_id')) {
$task->taskCollection()->associate(TaskCollection::findOrFail($collectionId));
$task->save();
$payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id);
$payload['tenant_id'] = $request->tenant->id;
if ($collection) {
$payload['collection_id'] = $collection->id;
$payload['source_collection_id'] = $collection->source_collection_id ?? $collection->id;
}
$task = Task::create($payload);
$task->load(['taskCollection', 'assignedEvents']);
return response()->json([
@@ -81,7 +98,7 @@ class TaskController extends Controller
*/
public function show(Request $request, Task $task): JsonResponse
{
if ($task->tenant_id !== $request->tenant->id) {
if ($task->tenant_id && $task->tenant_id !== $request->tenant->id) {
abort(404, 'Task nicht gefunden.');
}
@@ -103,13 +120,18 @@ class TaskController extends Controller
abort(404, 'Task nicht gefunden.');
}
$task->update($request->validated());
$collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
if ($collectionId = $request->input('collection_id')) {
$task->taskCollection()->associate(TaskCollection::findOrFail($collectionId));
$task->save();
$payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id, $task);
if ($collection) {
$payload['collection_id'] = $collection->id;
$payload['source_collection_id'] = $collection->source_collection_id ?? $collection->id;
}
$task->update($payload);
$task->load(['taskCollection', 'assignedEvents']);
return response()->json([
@@ -228,7 +250,7 @@ class TaskController extends Controller
*/
public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection
{
if ($collection->tenant_id !== $request->tenant->id) {
if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) {
abort(404);
}
@@ -239,4 +261,98 @@ class TaskController extends Controller
return TaskResource::collection($tasks);
}
protected function resolveAccessibleCollection(Request $request, int|string $collectionId): TaskCollection
{
return TaskCollection::where('id', $collectionId)
->where(function ($query) use ($request) {
$query->whereNull('tenant_id');
if ($request->tenant?->id) {
$query->orWhere('tenant_id', $request->tenant->id);
}
})
->firstOrFail();
}
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
{
if (array_key_exists('title', $data)) {
$data['title'] = $this->normalizeTranslations($data['title'], $original?->title);
} elseif (array_key_exists('title_translations', $data)) {
$data['title'] = $this->normalizeTranslations($data['title_translations'], $original?->title);
}
if (array_key_exists('description', $data)) {
$data['description'] = $this->normalizeTranslations($data['description'], $original?->description, true);
} elseif (array_key_exists('description_translations', $data)) {
$data['description'] = $this->normalizeTranslations(
$data['description_translations'],
$original?->description,
true
);
}
if (array_key_exists('example_text', $data)) {
$data['example_text'] = $this->normalizeTranslations($data['example_text'], $original?->example_text, true);
} elseif (array_key_exists('example_text_translations', $data)) {
$data['example_text'] = $this->normalizeTranslations(
$data['example_text_translations'],
$original?->example_text,
true
);
}
unset(
$data['title_translations'],
$data['description_translations'],
$data['example_text_translations']
);
if (! array_key_exists('difficulty', $data) || $data['difficulty'] === null) {
$data['difficulty'] = $original?->difficulty ?? 'easy';
}
if (! array_key_exists('priority', $data) || $data['priority'] === null) {
$data['priority'] = $original?->priority ?? 'medium';
}
return $data;
}
/**
* @param mixed $value
* @param array<string, string>|null $fallback
*
* @return array<string, string>|null
*/
protected function normalizeTranslations(mixed $value, ?array $fallback = null, bool $allowNull = false): ?array
{
if ($allowNull && ($value === null || $value === '')) {
return null;
}
if (is_array($value)) {
$filtered = array_filter(
$value,
static fn ($text) => is_string($text) && $text !== ''
);
if (! empty($filtered)) {
return $filtered;
}
return $allowNull ? null : ($fallback ?? []);
}
if (is_string($value) && $value !== '') {
$locale = app()->getLocale() ?: 'de';
return [
$locale => $value,
];
}
return $allowNull ? null : $fallback;
}
}

View File

@@ -22,7 +22,13 @@ use App\Models\TenantPackage;
use App\Models\PackagePurchase;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class MarketingController extends Controller
{
@@ -429,7 +435,15 @@ class MarketingController extends Controller
// Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
$converter = new CommonMarkConverter();
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new StrikethroughExtension());
$environment->addExtension(new TaskListExtension());
$converter = new MarkdownConverter($environment);
$contentHtml = (string) $converter->convert($markdown);
// Debug log for content_html

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class EmotionStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'icon' => ['nullable', 'string', 'max:50'],
'color' => ['nullable', 'string', 'regex:/^#?[0-9a-fA-F]{6}$/'],
'sort_order' => ['nullable', 'integer'],
'is_active' => ['nullable', 'boolean'],
'event_type_ids' => ['nullable', 'array'],
'event_type_ids.*' => ['integer', 'exists:event_types,id'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
class EmotionUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'nullable', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string'],
'icon' => ['sometimes', 'nullable', 'string', 'max:50'],
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9a-fA-F]{6}$/'],
'sort_order' => ['sometimes', 'nullable', 'integer'],
'is_active' => ['sometimes', 'boolean'],
'event_type_ids' => ['sometimes', 'array'],
'event_type_ids.*' => ['integer', 'exists:event_types,id'],
];
}
}

View File

@@ -27,7 +27,18 @@ class TaskStoreRequest extends FormRequest
'description' => ['nullable', 'string'],
'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
$tenantId = request()->tenant?->id;
if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$accessible = \App\Models\TaskCollection::where('id', $value)
->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id');
if ($tenantId) {
$query->orWhere('tenant_id', $tenantId);
}
})
->exists();
if (! $accessible) {
$fail('Die TaskCollection gehört nicht zu diesem Tenant.');
}
}],

View File

@@ -27,7 +27,18 @@ class TaskUpdateRequest extends FormRequest
'description' => ['sometimes', 'nullable', 'string'],
'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) {
$tenantId = request()->tenant?->id;
if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) {
$accessible = \App\Models\TaskCollection::where('id', $value)
->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id');
if ($tenantId) {
$query->orWhere('tenant_id', $tenantId);
}
})
->exists();
if (! $accessible) {
$fail('Die TaskCollection gehört nicht zu diesem Tenant.');
}
}],

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EmotionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_id' => $this->tenant_id,
'name' => $this->translatedText($this->name, 'Emotion'),
'name_translations' => (array) $this->name,
'description' => $this->description ? $this->translatedText($this->description, '') : null,
'description_translations' => $this->description ? (array) $this->description : [],
'icon' => $this->icon,
'color' => $this->color,
'sort_order' => $this->sort_order,
'is_active' => (bool) $this->is_active,
'is_global' => $this->tenant_id === null,
'event_types' => $this->whenLoaded('eventTypes', function () {
return $this->eventTypes->map(fn ($eventType) => [
'id' => $eventType->id,
'slug' => $eventType->slug,
'name' => $this->translatedText($eventType->name, $eventType->slug ?? ''),
'name_translations' => (array) $eventType->name,
]);
}),
'created_at' => optional($this->created_at)->toISOString(),
'updated_at' => optional($this->updated_at)->toISOString(),
];
}
protected function translatedText(mixed $value, string $fallback): string
{
if (is_string($value) && $value !== '') {
return $value;
}
if (! is_array($value)) {
return $fallback;
}
$locale = app()->getLocale();
$locales = array_filter([
$locale,
$locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null,
'de',
'en',
]);
foreach ($locales as $code) {
if ($code && isset($value[$code]) && $value[$code] !== '') {
return $value[$code];
}
}
$first = reset($value);
return $first !== false ? (string) $first : $fallback;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskCollectionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'name' => $this->name,
'name_translations' => $this->name_translations,
'description' => $this->description,
'description_translations' => $this->description_translations,
'tenant_id' => $this->tenant_id,
'is_global' => $this->tenant_id === null,
'event_type' => $this->whenLoaded('eventType', function () {
return [
'id' => $this->eventType->id,
'slug' => $this->eventType->slug,
'name' => $this->eventType->name,
'icon' => $this->eventType->icon,
];
}),
'tasks_count' => $this->whenCounted('tasks'),
'is_default' => (bool) ($this->is_default ?? false),
'position' => $this->position,
'source_collection_id' => $this->source_collection_id,
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
}

View File

@@ -18,15 +18,27 @@ class TaskResource extends JsonResource
? $this->assignedEvents->count()
: $this->assignedEvents()->count();
$titleTranslations = $this->normalizeTranslations($this->title);
$descriptionTranslations = $this->normalizeTranslations($this->description, allowNull: true);
$exampleTranslations = $this->normalizeTranslations($this->example_text, allowNull: true);
return [
'id' => $this->id,
'tenant_id' => $this->tenant_id,
'title' => $this->title,
'description' => $this->description,
'slug' => $this->slug,
'title' => $this->translatedText($titleTranslations, 'Untitled task'),
'title_translations' => $titleTranslations,
'description' => $descriptionTranslations ? $this->translatedText($descriptionTranslations, '') : null,
'description_translations' => $descriptionTranslations ?? [],
'example_text' => $exampleTranslations ? $this->translatedText($exampleTranslations, '') : null,
'example_text_translations' => $exampleTranslations ?? [],
'priority' => $this->priority,
'difficulty' => $this->difficulty,
'due_date' => $this->due_date?->toISOString(),
'is_completed' => (bool) $this->is_completed,
'collection_id' => $this->collection_id,
'source_task_id' => $this->source_task_id,
'source_collection_id' => $this->source_collection_id,
'assigned_events_count' => $assignedEventsCount,
'assigned_events' => $this->whenLoaded(
'assignedEvents',
@@ -36,5 +48,60 @@ class TaskResource extends JsonResource
'updated_at' => $this->updated_at?->toISOString(),
];
}
/**
* @return array<string, string>|null
*/
protected function normalizeTranslations(mixed $value, bool $allowNull = false): ?array
{
if ($allowNull && ($value === null || $value === '')) {
return null;
}
if (is_array($value)) {
$filtered = array_filter(
$value,
static fn ($text) => is_string($text) && $text !== ''
);
return ! empty($filtered)
? $filtered
: ($allowNull ? null : []);
}
if (is_string($value) && $value !== '') {
$locale = app()->getLocale() ?: 'de';
return [
$locale => $value,
];
}
return $allowNull ? null : [];
}
/**
* @param array<string, string> $translations
*/
protected function translatedText(array $translations, string $fallback): string
{
$locale = app()->getLocale();
$locales = array_filter([
$locale,
$locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null,
'de',
'en',
]);
foreach ($locales as $code) {
if ($code && isset($translations[$code]) && $translations[$code] !== '') {
return $translations[$code];
}
}
$first = reset($translations);
return $first !== false ? $first : $fallback;
}
}

View File

@@ -10,7 +10,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Spatie\Translatable\HasTranslations;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class BlogPost extends Model
{
@@ -54,7 +60,15 @@ class BlogPost extends Model
{
return Attribute::get(function () {
$markdown = $this->getTranslation('content', app()->getLocale());
$converter = new CommonMarkConverter();
$environment = new Environment();
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new StrikethroughExtension());
$environment->addExtension(new TaskListExtension());
$converter = new MarkdownConverter($environment);
return $converter->convert($markdown);
});
}

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -18,6 +19,11 @@ class Emotion extends Model
'description' => 'array',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function eventTypes(): BelongsToMany
{
return $this->belongsToMany(EventType::class, 'emotion_event_type', 'emotion_id', 'event_type_id');

View File

@@ -6,10 +6,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation;
use Znck\Eloquent\Traits\BelongsToThrough;
class Photo extends Model
{
use HasFactory;
use BelongsToThrough;
protected $table = 'photos';
protected $guarded = [];
@@ -47,5 +50,12 @@ class Photo extends Model
{
return $this->hasMany(PhotoLike::class);
}
}
public function tenant(): BelongsToThroughRelation
{
return $this->belongsToThrough(
Tenant::class,
Event::class
);
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Task extends Model
{
@@ -38,6 +41,59 @@ class Task extends Model
return $this->belongsTo(TaskCollection::class, 'collection_id');
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function sourceTask(): BelongsTo
{
return $this->belongsTo(Task::class, 'source_task_id');
}
public function derivedTasks(): HasMany
{
return $this->hasMany(Task::class, 'source_task_id');
}
public function sourceCollection(): BelongsTo
{
return $this->belongsTo(TaskCollection::class, 'source_collection_id');
}
public function scopeForTenant(Builder $query, ?int $tenantId): Builder
{
return $query->where(function (Builder $innerQuery) use ($tenantId) {
$innerQuery->whereNull('tenant_id');
if ($tenantId) {
$innerQuery->orWhere('tenant_id', $tenantId);
}
});
}
protected static function booted(): void
{
static::creating(function (Task $task) {
if (! $task->slug) {
$task->slug = static::generateSlug(
$task->title['en'] ?? $task->title['de'] ?? 'task'
);
}
});
}
protected static function generateSlug(string $base): string
{
$slugBase = Str::slug($base) ?: 'task';
do {
$slug = $slugBase . '-' . Str::random(6);
} while (static::where('slug', $slug)->exists());
return $slug;
}
public function assignedEvents(): BelongsToMany
{
return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id')

View File

@@ -4,7 +4,10 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Arr;
class TaskCollection extends Model
{
@@ -14,10 +17,40 @@ class TaskCollection extends Model
protected $fillable = [
'tenant_id',
'name',
'description',
'slug',
'name_translations',
'description_translations',
'event_type_id',
'source_collection_id',
'is_default',
'position',
];
protected $casts = [
'name_translations' => 'array',
'description_translations' => 'array',
];
public function eventType(): BelongsTo
{
return $this->belongsTo(EventType::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function sourceCollection(): BelongsTo
{
return $this->belongsTo(TaskCollection::class, 'source_collection_id');
}
public function derivedCollections(): HasMany
{
return $this->hasMany(TaskCollection::class, 'source_collection_id');
}
public function tasks(): BelongsToMany
{
return $this->belongsToMany(
@@ -25,7 +58,7 @@ class TaskCollection extends Model
'task_collection_task',
'task_collection_id',
'task_id'
);
)->withPivot(['sort_order']);
}
public function events(): BelongsToMany
@@ -35,7 +68,49 @@ class TaskCollection extends Model
'event_task_collection',
'task_collection_id',
'event_id'
);
)->withPivot(['sort_order'])->withTimestamps();
}
public function scopeGlobal($query)
{
return $query->whereNull('tenant_id');
}
public function scopeForTenant($query, ?int $tenantId)
{
return $query->where(function ($inner) use ($tenantId) {
$inner->whereNull('tenant_id');
if ($tenantId) {
$inner->orWhere('tenant_id', $tenantId);
}
});
}
public function getNameAttribute(): string
{
return $this->resolveTranslation('name_translations');
}
public function getDescriptionAttribute(): ?string
{
$value = $this->resolveTranslation('description_translations');
return $value ?: null;
}
protected function resolveTranslation(string $attribute, ?string $locale = null): string
{
$translations = $this->{$attribute} ?? [];
if (is_string($translations)) {
$translations = json_decode($translations, true) ?: [];
}
$locale = $locale ?? app()->getLocale();
return $translations[$locale]
?? Arr::first($translations)
?? '';
}
}

View File

@@ -116,7 +116,11 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament
return false;
}
return in_array($this->role, ['tenant_admin', 'super_admin'], true);
return match ($panel->getId()) {
'superadmin' => $this->role === 'super_admin',
'admin' => $this->role === 'tenant_admin',
default => false,
};
}
public function canAccessTenant(Model $tenant): bool

View File

@@ -17,34 +17,26 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Stephenjude\FilamentBlog\Filament\Resources\CategoryResource;
use Stephenjude\FilamentBlog\Filament\Resources\PostResource;
use Stephenjude\FilamentBlog\Filament\Resources\TagResource;
use App\Models\BlogCategory;
use App\Models\BlogPost;
use App\Models\BlogTag;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->brandName('Fotospiel Studio')
->login(\App\Filament\Pages\Auth\Login::class)
->colors([
'primary' => Color::Pink,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages')
->pages([
Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->homeUrl(fn () => \App\Filament\Tenant\Pages\TenantOnboarding::getUrl())
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
->pages([])
->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets')
->widgets([
Widgets\AccountWidget::class,
Widgets\FilamentInfoWidget::class,
])
->middleware([
EncryptCookies::class,
@@ -60,10 +52,6 @@ class AdminPanelProvider extends PanelProvider
->authMiddleware([
Authenticate::class,
])
->resources([
\App\Filament\Resources\UserResource::class,
\App\Filament\Resources\TenantPackageResource::class,
])
->tenant(\App\Models\Tenant::class)
// Remove blog models as they are global and handled in SuperAdmin
;

View File

@@ -10,6 +10,7 @@ use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use App\Filament\Resources\LegalPageResource;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@@ -17,21 +18,12 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use App\Filament\Resources\LegalPageResource;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use App\Models\Tenant;
use App\Models\BlogPost;
use App\Models\BlogCategory;
use App\Models\BlogTag;
use App\Filament\Widgets\PlatformStatsWidget;
use App\Filament\Widgets\TopTenantsByUploads;
use App\Filament\Blog\Resources\PostResource;
use App\Filament\Blog\Resources\CategoryResource;
use App\Filament\Blog\Resources\AuthorResource;
use Illuminate\Support\Facades\Log;
class SuperAdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
@@ -49,7 +41,7 @@ class SuperAdminPanelProvider extends PanelProvider
->pages([
Pages\Dashboard::class,
])
->login(\App\Filament\SuperAdmin\Pages\Auth\Login::class)
->login(\App\Filament\Pages\Auth\Login::class)
/*->plugin(
BlogPlugin::make()
)*/
@@ -76,7 +68,11 @@ class SuperAdminPanelProvider extends PanelProvider
Authenticate::class,
])
->resources([
\App\Filament\Resources\EventResource::class,
\App\Filament\Resources\PhotoResource::class,
\App\Filament\Resources\UserResource::class,
\App\Filament\Resources\TenantPackageResource::class,
\App\Filament\Resources\TaskResource::class,
PostResource::class,
CategoryResource::class,
LegalPageResource::class,

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Services\Tenant;
use App\Models\Event;
use App\Models\Task;
use App\Models\TaskCollection;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use RuntimeException;
class TaskCollectionImportService
{
public function __construct(private readonly DatabaseManager $db)
{
}
/**
* @return array{collection: TaskCollection, created_task_ids: array<int>, attached_task_ids: array<int>}
*/
public function import(TaskCollection $collection, Event $event): array
{
if ($collection->tenant_id && $collection->tenant_id !== $event->tenant_id) {
throw new RuntimeException('Task collection is not accessible for this tenant.');
}
$collection->loadMissing('tasks');
return $this->db->transaction(function () use ($collection, $event) {
$tenantId = $event->tenant_id;
$targetCollection = $this->resolveTenantCollection($collection, $tenantId);
$createdTaskIds = [];
$attachedTaskIds = [];
foreach ($collection->tasks as $task) {
$tenantTask = $this->resolveTenantTask($task, $targetCollection, $tenantId);
if ($tenantTask->wasRecentlyCreated) {
$createdTaskIds[] = $tenantTask->id;
}
if (! $tenantTask->assignedEvents()->where('event_id', $event->id)->exists()) {
$tenantTask->assignedEvents()->attach($event->id);
$attachedTaskIds[] = $tenantTask->id;
}
}
$event->taskCollections()->syncWithoutDetaching([
$targetCollection->id => ['sort_order' => $targetCollection->position ?? 0],
]);
return [
'collection' => $targetCollection->fresh(),
'created_task_ids' => $createdTaskIds,
'attached_task_ids' => $attachedTaskIds,
];
});
}
protected function resolveTenantCollection(TaskCollection $collection, int $tenantId): TaskCollection
{
if ($collection->tenant_id === $tenantId) {
return $collection;
}
$existing = TaskCollection::query()
->where('tenant_id', $tenantId)
->where('source_collection_id', $collection->id)
->first();
if ($existing) {
return $existing;
}
return TaskCollection::create([
'tenant_id' => $tenantId,
'source_collection_id' => $collection->id,
'event_type_id' => $collection->event_type_id,
'slug' => $this->buildCollectionSlug($collection->slug, $tenantId),
'name_translations' => $collection->name_translations,
'description_translations' => $collection->description_translations,
'is_default' => false,
'position' => $collection->position,
]);
}
protected function resolveTenantTask(Task $templateTask, TaskCollection $targetCollection, int $tenantId): Task
{
if ($templateTask->tenant_id === $tenantId) {
if ($templateTask->collection_id !== $targetCollection->id) {
$templateTask->update(['collection_id' => $targetCollection->id]);
}
return $templateTask;
}
$sourceId = $templateTask->source_task_id ?: $templateTask->id;
$existing = Task::query()
->where('tenant_id', $tenantId)
->where('source_task_id', $sourceId)
->first();
if ($existing) {
$existing->update([
'collection_id' => $targetCollection->id,
'source_collection_id' => $templateTask->source_collection_id ?: $targetCollection->source_collection_id ?: $targetCollection->id,
]);
return tap($existing)->refresh();
}
$slugBase = $templateTask->slug ?: ($templateTask->title['en'] ?? $templateTask->title['de'] ?? 'task');
$slug = $this->buildTaskSlug($slugBase);
$cloned = Task::create([
'tenant_id' => $tenantId,
'slug' => $slug,
'emotion_id' => $templateTask->emotion_id,
'event_type_id' => $templateTask->event_type_id,
'title' => $templateTask->title,
'description' => $templateTask->description,
'example_text' => $templateTask->example_text,
'due_date' => null,
'is_completed' => false,
'priority' => $templateTask->priority,
'collection_id' => $targetCollection->id,
'difficulty' => $templateTask->difficulty,
'sort_order' => $templateTask->sort_order,
'is_active' => true,
'source_task_id' => $sourceId,
'source_collection_id' => $templateTask->source_collection_id ?: $templateTask->collection_id,
]);
return $cloned;
}
protected function buildCollectionSlug(?string $slug, int $tenantId): string
{
$base = Str::slug(($slug ?: 'collection') . '-' . $tenantId);
do {
$candidate = $base . '-' . Str::random(4);
} while (TaskCollection::where('slug', $candidate)->exists());
return $candidate;
}
protected function buildTaskSlug(string $base): string
{
$slugBase = Str::slug($base) ?: 'task';
do {
$candidate = $slugBase . '-' . Str::random(6);
} while (Task::where('slug', $candidate)->exists());
return $candidate;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Support;
use App\Models\EventJoinToken;
use App\Models\TaskCollection;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
class TenantOnboardingState
{
public static function tenant(?Tenant $tenant = null): ?Tenant
{
if ($tenant) {
return $tenant;
}
/** @var Tenant|null $tenant */
$tenant = Filament::getTenant();
return $tenant;
}
public static function status(?Tenant $tenant = null): array
{
$tenant = self::tenant($tenant);
if (! $tenant) {
return [
'packages' => false,
'event' => false,
'palette' => false,
'invite' => false,
];
}
$hasCustomCollections = TaskCollection::query()
->where('tenant_id', $tenant->id)
->exists();
$hasEvent = $tenant->events()->exists();
$palette = Arr::get($tenant->settings ?? [], 'branding.palette');
$hasInvite = EventJoinToken::query()
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->id))
->exists();
return [
'packages' => $hasCustomCollections,
'event' => $hasEvent,
'palette' => filled($palette),
'invite' => $hasInvite,
];
}
public static function completed(?Tenant $tenant = null): bool
{
$status = self::status($tenant);
return collect($status)->every(fn ($done) => $done === true)
|| Arr::has(self::tenant($tenant)?->settings ?? [], 'onboarding.completed_at');
}
public static function markCompleted(Tenant $tenant, array $data = []): void
{
$settings = $tenant->settings ?? [];
Arr::set($settings, 'onboarding.completed_at', Carbon::now()->toIso8601String());
if (Arr::has($data, 'primary_event_id')) {
Arr::set($settings, 'onboarding.primary_event_id', Arr::get($data, 'primary_event_id'));
}
if (Arr::has($data, 'selected_packages')) {
Arr::set($settings, 'onboarding.selected_packages', Arr::get($data, 'selected_packages'));
}
if (Arr::has($data, 'qr_layout')) {
Arr::set($settings, 'onboarding.qr_layout', Arr::get($data, 'qr_layout'));
}
$tenant->forceFill(['settings' => $settings])->save();
}
}

View File

@@ -19,6 +19,7 @@
"paypal/paypal-server-sdk": "^1.1",
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11",
"staudenmeir/belongs-to-through": "^2.17",
"stripe/stripe-php": "*"
},
"require-dev": {

69
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2852435257a5672486892b814ff57bbf",
"content-hash": "7f7cd01c532ad63b7539234881b1169b",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -6373,6 +6373,73 @@
],
"time": "2025-02-21T14:16:57+00:00"
},
{
"name": "staudenmeir/belongs-to-through",
"version": "v2.17",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/belongs-to-through.git",
"reference": "e45460f8eecd882e5daea2af8f948d7596c20ba0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/belongs-to-through/zipball/e45460f8eecd882e5daea2af8f948d7596c20ba0",
"reference": "e45460f8eecd882e5daea2af8f948d7596c20ba0",
"shasum": ""
},
"require": {
"illuminate/database": "^12.0",
"php": "^8.2"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.0",
"larastan/larastan": "^3.0",
"laravel/framework": "^12.0",
"mockery/mockery": "^1.5.1",
"orchestra/testbench-core": "^10.0",
"phpunit/phpunit": "^11.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Staudenmeir\\BelongsToThrough\\IdeHelperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Znck\\Eloquent\\": "src/",
"Staudenmeir\\BelongsToThrough\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Rahul Kadyan",
"email": "hi@znck.me"
},
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Laravel Eloquent BelongsToThrough relationships",
"support": {
"issues": "https://github.com/staudenmeir/belongs-to-through/issues",
"source": "https://github.com/staudenmeir/belongs-to-through/tree/v2.17"
},
"funding": [
{
"url": "https://paypal.me/JonasStaudenmeir",
"type": "custom"
}
],
"time": "2025-02-20T19:24:03+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v18.0.0",

View File

@@ -43,4 +43,27 @@ return [
'sandbox' => env('PAYPAL_SANDBOX', true),
],
'oauth' => [
'tenant_admin' => [
'id' => env('VITE_OAUTH_CLIENT_ID', 'tenant-admin-app'),
'redirects' => (function (): array {
$redirects = [];
$devServer = env('VITE_DEV_SERVER_URL');
$redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/') . '/event-admin/auth/callback';
$appUrl = env('APP_URL');
if ($appUrl) {
$redirects[] = rtrim($appUrl, '/') . '/event-admin/auth/callback';
} else {
$redirects[] = 'http://localhost:8000/event-admin/auth/callback';
}
$extra = array_filter(array_map('trim', explode(',', (string) env('TENANT_ADMIN_OAUTH_REDIRECTS', ''))));
return array_values(array_unique(array_filter(array_merge($redirects, $extra))));
})(),
],
],
];

View File

@@ -2,9 +2,12 @@
namespace Database\Factories;
use App\Models\EventType;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class TaskCollectionFactory extends Factory
{
@@ -12,13 +15,21 @@ class TaskCollectionFactory extends Factory
public function definition(): array
{
$categories = ['Allgemein', 'Vorbereitung', 'Event-Tag', 'Aufräumen', 'Follow-up'];
$colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'];
$label = ucfirst($this->faker->unique()->words(2, true));
$description = $this->faker->sentence(12);
return [
'tenant_id' => Tenant::factory(),
'name' => $this->faker->randomElement($categories),
'description' => $this->faker->sentence(),
'event_type_id' => EventType::factory(),
'slug' => Str::slug($label . '-' . $this->faker->unique()->numberBetween(1, 9999)),
'name_translations' => [
'de' => $label,
'en' => $label,
],
'description_translations' => [
'de' => $description,
'en' => $description,
],
'is_default' => $this->faker->boolean(20),
'position' => $this->faker->numberBetween(1, 10),
];
@@ -28,7 +39,10 @@ class TaskCollectionFactory extends Factory
{
return $this->afterCreating(function (TaskCollection $collection) use ($count) {
\App\Models\Task::factory($count)
->create(['tenant_id' => $collection->tenant_id])
->create([
'tenant_id' => $collection->tenant_id,
'event_type_id' => $collection->event_type_id,
])
->each(function ($task) use ($collection) {
$task->taskCollection()->associate($collection);
$task->save();

View File

@@ -6,6 +6,7 @@ use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class TaskFactory extends Factory
{
@@ -13,10 +14,24 @@ class TaskFactory extends Factory
public function definition(): array
{
$title = ucfirst($this->faker->unique()->words(4, true));
$description = $this->faker->paragraph(2);
return [
'tenant_id' => Tenant::factory(),
'title' => $this->faker->sentence(4),
'description' => $this->faker->paragraph(),
'slug' => Str::slug($title . '-' . $this->faker->unique()->numberBetween(1, 9999)),
'title' => [
'de' => $title,
'en' => $title,
],
'description' => [
'de' => $description,
'en' => $description,
],
'example_text' => [
'de' => $this->faker->sentence(),
'en' => $this->faker->sentence(),
],
'due_date' => $this->faker->dateTimeBetween('now', '+1 month'),
'is_completed' => $this->faker->boolean(20), // 20% chance completed
'collection_id' => null,

View File

@@ -0,0 +1,278 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('task_collections')) {
if (Schema::hasColumn('task_collections', 'tenant_id')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
});
Schema::table('task_collections', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->change();
});
Schema::table('task_collections', function (Blueprint $table) {
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->nullOnDelete();
});
}
Schema::table('task_collections', function (Blueprint $table) {
if (! Schema::hasColumn('task_collections', 'slug')) {
$table->string('slug')->nullable()->after('tenant_id');
}
if (! Schema::hasColumn('task_collections', 'name_translations')) {
$table->json('name_translations')->nullable()->after('slug');
}
if (! Schema::hasColumn('task_collections', 'description_translations')) {
$table->json('description_translations')->nullable()->after('name_translations');
}
if (! Schema::hasColumn('task_collections', 'event_type_id')) {
$table->foreignId('event_type_id')
->nullable()
->after('description_translations')
->constrained()
->nullOnDelete();
}
});
if (Schema::hasColumn('task_collections', 'name')) {
DB::table('task_collections')
->select('id', 'name', 'description', 'slug')
->orderBy('id')
->chunk(100, function ($rows) {
foreach ($rows as $row) {
$name = $row->name;
$description = $row->description;
$translations = [
'de' => $name,
];
$descriptionTranslations = $description
? [
'de' => $description,
]
: null;
$slugBase = Str::slug($name ?: ('collection-' . $row->id));
if (empty($slugBase)) {
$slugBase = 'collection-' . $row->id;
}
$slug = $row->slug ?: ($slugBase . '-' . $row->id);
DB::table('task_collections')
->where('id', $row->id)
->update([
'name_translations' => json_encode($translations, JSON_UNESCAPED_UNICODE),
'description_translations' => $descriptionTranslations
? json_encode($descriptionTranslations, JSON_UNESCAPED_UNICODE)
: null,
'slug' => $slug,
]);
}
});
Schema::table('task_collections', function (Blueprint $table) {
$table->dropColumn(['name', 'description']);
});
Schema::table('task_collections', function (Blueprint $table) {
$table->unique('slug');
});
}
}
if (Schema::hasTable('tasks')) {
if (Schema::hasColumn('tasks', 'tenant_id')) {
Schema::table('tasks', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
});
Schema::table('tasks', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable()->change();
});
Schema::table('tasks', function (Blueprint $table) {
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->nullOnDelete();
});
}
Schema::table('tasks', function (Blueprint $table) {
if (! Schema::hasColumn('tasks', 'slug')) {
$table->string('slug')->nullable()->after('id');
}
});
if (! Schema::hasColumn('tasks', 'slug')) {
return;
}
DB::table('tasks')
->select('id', 'slug', 'title')
->orderBy('id')
->chunk(100, function ($rows) {
foreach ($rows as $row) {
if (! empty($row->slug)) {
continue;
}
$titleData = $row->title;
if (is_string($titleData)) {
$json = json_decode($titleData, true);
} else {
$json = $titleData;
}
$base = $json['de']
?? $json['en']
?? ('task-' . $row->id);
$slug = Str::slug($base);
if (empty($slug)) {
$slug = 'task-' . $row->id;
}
DB::table('tasks')
->where('id', $row->id)
->update([
'slug' => $slug . '-' . $row->id,
]);
}
});
Schema::table('tasks', function (Blueprint $table) {
$table->unique('slug');
});
}
}
public function down(): void
{
if (Schema::hasTable('tasks')) {
$fallbackTenantId = DB::table('tenants')->orderBy('id')->value('id');
if ($fallbackTenantId) {
DB::table('tasks')->whereNull('tenant_id')->update(['tenant_id' => $fallbackTenantId]);
}
if (Schema::hasColumn('tasks', 'slug')) {
Schema::table('tasks', function (Blueprint $table) {
$table->dropUnique(['slug']);
$table->dropColumn('slug');
});
}
if (Schema::hasColumn('tasks', 'tenant_id')) {
Schema::table('tasks', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
});
Schema::table('tasks', function (Blueprint $table) {
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
});
Schema::table('tasks', function (Blueprint $table) {
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->cascadeOnDelete();
});
}
}
if (Schema::hasTable('task_collections')) {
$fallbackTenantId = DB::table('tenants')->orderBy('id')->value('id');
if ($fallbackTenantId) {
DB::table('task_collections')->whereNull('tenant_id')->update(['tenant_id' => $fallbackTenantId]);
}
if (Schema::hasColumn('task_collections', 'name_translations') &&
! Schema::hasColumn('task_collections', 'name')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->string('name')->default('')->after('tenant_id');
$table->text('description')->nullable()->after('name');
});
DB::table('task_collections')
->select('id', 'name_translations', 'description_translations')
->orderBy('id')
->chunk(100, function ($rows) {
foreach ($rows as $row) {
$names = is_string($row->name_translations)
? json_decode($row->name_translations, true) ?: []
: ($row->name_translations ?? []);
$descriptions = is_string($row->description_translations)
? json_decode($row->description_translations, true) ?: []
: ($row->description_translations ?? []);
DB::table('task_collections')
->where('id', $row->id)
->update([
'name' => $names['de'] ?? $names['en'] ?? 'Collection ' . $row->id,
'description' => $descriptions['de'] ?? $descriptions['en'] ?? null,
]);
}
});
Schema::table('task_collections', function (Blueprint $table) {
if (Schema::hasColumn('task_collections', 'description_translations')) {
$table->dropColumn('description_translations');
}
if (Schema::hasColumn('task_collections', 'name_translations')) {
$table->dropColumn('name_translations');
}
});
}
if (Schema::hasColumn('task_collections', 'event_type_id')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->dropForeign(['event_type_id']);
$table->dropColumn('event_type_id');
});
}
if (Schema::hasColumn('task_collections', 'slug')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->dropUnique(['slug']);
$table->dropColumn('slug');
});
}
if (Schema::hasColumn('task_collections', 'tenant_id')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->unsignedBigInteger('tenant_id')->nullable(false)->change();
$table->foreign('tenant_id')
->references('id')
->on('tenants')
->cascadeOnDelete();
});
}
}
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('tasks')) {
Schema::table('tasks', function (Blueprint $table) {
if (! Schema::hasColumn('tasks', 'source_task_id')) {
$table->foreignId('source_task_id')->nullable()->after('tenant_id')->constrained('tasks')->nullOnDelete();
}
if (! Schema::hasColumn('tasks', 'source_collection_id')) {
$table->foreignId('source_collection_id')->nullable()->after('collection_id')->constrained('task_collections')->nullOnDelete();
}
});
}
if (Schema::hasTable('task_collections') && ! Schema::hasColumn('task_collections', 'source_collection_id')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->foreignId('source_collection_id')->nullable()->after('event_type_id')->constrained('task_collections')->nullOnDelete();
});
}
if (Schema::hasTable('emotions') && ! Schema::hasColumn('emotions', 'tenant_id')) {
Schema::table('emotions', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->nullOnDelete();
$table->index('tenant_id');
});
}
}
public function down(): void
{
if (Schema::hasTable('tasks')) {
Schema::table('tasks', function (Blueprint $table) {
if (Schema::hasColumn('tasks', 'source_task_id')) {
$table->dropForeign(['source_task_id']);
$table->dropColumn('source_task_id');
}
if (Schema::hasColumn('tasks', 'source_collection_id')) {
$table->dropForeign(['source_collection_id']);
$table->dropColumn('source_collection_id');
}
});
}
if (Schema::hasTable('task_collections') && Schema::hasColumn('task_collections', 'source_collection_id')) {
Schema::table('task_collections', function (Blueprint $table) {
$table->dropForeign(['source_collection_id']);
$table->dropColumn('source_collection_id');
});
}
if (Schema::hasTable('emotions') && Schema::hasColumn('emotions', 'tenant_id')) {
Schema::table('emotions', function (Blueprint $table) {
$table->dropForeign(['tenant_id']);
$table->dropIndex(['tenant_id']);
$table->dropColumn('tenant_id');
});
}
}
};

View File

@@ -43,7 +43,8 @@ return new class extends Migration
if (!Schema::hasTable('tasks')) {
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
$table->string('slug')->nullable()->unique();
$table->unsignedBigInteger('emotion_id')->nullable();
$table->unsignedBigInteger('event_type_id')->nullable();
$table->json('title');
@@ -75,9 +76,11 @@ return new class extends Migration
if (!Schema::hasTable('task_collections')) {
Schema::create('task_collections', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
$table->string('slug')->nullable()->unique();
$table->json('name_translations');
$table->json('description_translations')->nullable();
$table->foreignId('event_type_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('is_default')->default(false);
$table->integer('position')->default(0);
$table->timestamps();

View File

@@ -30,7 +30,7 @@ return new class extends Migration
});
// Seed standard packages if empty
if (DB::table('packages')->count() == 0) {
/*if (DB::table('packages')->count() == 0) {
DB::table('packages')->insert([
[
'name' => 'Free/Test',
@@ -82,7 +82,7 @@ return new class extends Migration
],
// Add more as needed
]);
}
}*/
}
// Event Packages

View File

@@ -0,0 +1,150 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
$this->ensureCollectionSlugs();
$this->ensureTaskSlugs();
}
public function down(): void
{
$this->rollbackCollectionSlugs();
$this->rollbackTaskSlugs();
}
protected function ensureCollectionSlugs(): void
{
if (! Schema::hasTable('task_collections') || Schema::hasColumn('task_collections', 'slug')) {
return;
}
Schema::table('task_collections', function (Blueprint $table) {
$table->string('slug')->nullable()->after('tenant_id');
});
DB::table('task_collections')
->select('id', 'slug', 'name_translations')
->orderBy('id')
->chunk(200, function ($rows) {
foreach ($rows as $row) {
if (! empty($row->slug)) {
continue;
}
$translations = $this->decodeTranslations($row->name_translations);
$base = $translations['en'] ?? $translations['de'] ?? reset($translations) ?? ('collection-' . $row->id);
$slug = $this->buildUniqueSlug($base, 'collection-', function ($candidate) {
return DB::table('task_collections')->where('slug', $candidate)->exists();
});
DB::table('task_collections')
->where('id', $row->id)
->update(['slug' => $slug]);
}
});
Schema::table('task_collections', function (Blueprint $table) {
$table->unique('slug');
});
}
protected function ensureTaskSlugs(): void
{
if (! Schema::hasTable('tasks') || Schema::hasColumn('tasks', 'slug')) {
return;
}
Schema::table('tasks', function (Blueprint $table) {
$table->string('slug')->nullable()->after('id');
});
DB::table('tasks')
->select('id', 'slug', 'title')
->orderBy('id')
->chunk(200, function ($rows) {
foreach ($rows as $row) {
if (! empty($row->slug)) {
continue;
}
$translations = $this->decodeTranslations($row->title);
$base = $translations['en'] ?? $translations['de'] ?? reset($translations) ?? ('task-' . $row->id);
$slug = $this->buildUniqueSlug($base, 'task-', function ($candidate) {
return DB::table('tasks')->where('slug', $candidate)->exists();
});
DB::table('tasks')
->where('id', $row->id)
->update(['slug' => $slug]);
}
});
Schema::table('tasks', function (Blueprint $table) {
$table->unique('slug');
});
}
protected function rollbackCollectionSlugs(): void
{
if (! Schema::hasTable('task_collections') || ! Schema::hasColumn('task_collections', 'slug')) {
return;
}
Schema::table('task_collections', function (Blueprint $table) {
$table->dropUnique('task_collections_slug_unique');
$table->dropColumn('slug');
});
}
protected function rollbackTaskSlugs(): void
{
if (! Schema::hasTable('tasks') || ! Schema::hasColumn('tasks', 'slug')) {
return;
}
Schema::table('tasks', function (Blueprint $table) {
$table->dropUnique('tasks_slug_unique');
$table->dropColumn('slug');
});
}
/**
* @return array<string, string>
*/
protected function decodeTranslations(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return $decoded;
}
return ['de' => $value];
}
return [];
}
protected function buildUniqueSlug(string $base, string $prefix, callable $exists): string
{
$slugBase = Str::slug($base) ?: ($prefix . Str::random(4));
do {
$candidate = $slugBase . '-' . Str::random(4);
} while ($exists($candidate));
return $candidate;
}
};

View File

@@ -5,6 +5,7 @@ namespace Database\Seeders;
use App\Models\OAuthClient;
use App\Models\Tenant;
use Illuminate\Database\Seeder;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class OAuthClientSeeder extends Seeder
@@ -14,14 +15,19 @@ class OAuthClientSeeder extends Seeder
*/
public function run(): void
{
$clientId = 'tenant-admin-app';
$serviceConfig = config('services.oauth.tenant_admin', []);
$clientId = $serviceConfig['id'] ?? 'tenant-admin-app';
$tenantId = Tenant::where('slug', 'demo')->value('id')
?? Tenant::query()->orderBy('id')->value('id');
$redirectUris = Arr::wrap($serviceConfig['redirects'] ?? []);
if (empty($redirectUris)) {
$redirectUris = [
'http://localhost:5174/auth/callback',
'http://localhost:8000/auth/callback',
'http://localhost:5173/event-admin/auth/callback',
'http://localhost:8000/event-admin/auth/callback',
];
}
$scopes = [
'tenant:read',

View File

@@ -2,79 +2,293 @@
namespace Database\Seeders;
use App\Models\Emotion;
use App\Models\EventType;
use App\Models\Task;
use App\Models\TaskCollection;
use Illuminate\Database\Seeder;
use App\Models\{Event, Task, TaskCollection, Tenant};
use Illuminate\Support\Facades\DB;
class TaskCollectionsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Get demo tenant
$demoTenant = Tenant::where('slug', 'demo')->first();
if (!$demoTenant) {
$this->command->info('Demo tenant not found, skipping task collections seeding');
return;
}
// Get demo event ID
$demoEvent = Event::where('slug', 'demo-wedding-2025')->first();
if (!$demoEvent) {
$this->command->info('Demo event not found, skipping task collections seeding');
return;
}
// Get some task IDs for demo (assuming TasksSeeder was run)
$taskIds = Task::where('tenant_id', $demoTenant->id)->limit(6)->get('id')->pluck('id')->toArray();
if (empty($taskIds)) {
$this->command->info('No tasks found, skipping task collections seeding');
return;
}
// Create Wedding Task Collection using Eloquent
$weddingCollection = TaskCollection::create([
'tenant_id' => $demoTenant->id,
$collections = [
[
'slug' => 'wedding-classics',
'event_type' => [
'slug' => 'wedding',
'name' => [
'de' => 'Hochzeitsaufgaben',
'en' => 'Wedding Tasks'
'de' => 'Hochzeit',
'en' => 'Wedding',
],
'icon' => 'lucide-heart',
],
'name' => [
'de' => 'Hochzeitsklassiker',
'en' => 'Wedding Classics',
],
'description' => [
'de' => 'Spezielle Aufgaben für Hochzeitsgäste',
'en' => 'Special tasks for wedding guests'
'de' => 'Kuratierte Aufgaben rund um Trauung, Emotionen und besondere Momente.',
'en' => 'Curated prompts for vows, emotions, and memorable wedding highlights.',
],
]);
// Assign first 4 tasks to wedding collection using Eloquent
$weddingTasks = collect($taskIds)->take(4);
$weddingCollection->tasks()->attach($weddingTasks);
// Link wedding collection to demo event using Eloquent
$demoEvent->taskCollections()->attach($weddingCollection, ['sort_order' => 1]);
// Create General Fun Tasks Collection (fallback) using Eloquent
$funCollection = TaskCollection::create([
'tenant_id' => $demoTenant->id,
'name' => [
'de' => 'Spaß-Aufgaben',
'en' => 'Fun Tasks'
'is_default' => true,
'position' => 10,
'tasks' => [
[
'slug' => 'wedding-first-look',
'title' => [
'de' => 'Erster Blick des Brautpaares festhalten',
'en' => 'Capture the couples first look',
],
'description' => [
'de' => 'Allgemeine unterhaltsame Aufgaben',
'en' => 'General entertaining tasks'
'de' => 'Halte den Moment fest, in dem sich Braut und Bräutigam zum ersten Mal sehen.',
'en' => 'Capture the moment when the bride and groom see each other for the first time.',
],
'example' => [
'de' => 'Fotografiere die Reaktionen aus verschiedenen Blickwinkeln.',
'en' => 'Photograph their reactions from different angles.',
],
'emotion' => [
'name' => [
'de' => 'Romantik',
'en' => 'Romance',
],
'icon' => 'lucide-heart',
'color' => '#ec4899',
'sort_order' => 10,
],
'difficulty' => 'easy',
'sort_order' => 10,
],
[
'slug' => 'wedding-family-hug',
'title' => [
'de' => 'Familienumarmung organisieren',
'en' => 'Organise a family group hug',
],
'description' => [
'de' => 'Bitte die wichtigsten Menschen, das Paar gleichzeitig zu umarmen.',
'en' => 'Ask the closest friends and family to hug the couple at the same time.',
],
'example' => [
'de' => 'Kombiniere die Umarmung mit einem Toast.',
'en' => 'Combine the hug with a heartfelt toast.',
],
'emotion' => [
'name' => [
'de' => 'Freude',
'en' => 'Joy',
],
'icon' => 'lucide-smile',
'color' => '#f59e0b',
'sort_order' => 20,
],
'difficulty' => 'medium',
'sort_order' => 20,
],
[
'slug' => 'wedding-midnight-sparkler',
'title' => [
'de' => 'Mitternachtsfunkeln mit Wunderkerzen',
'en' => 'Midnight sparkler moment',
],
'description' => [
'de' => 'Verteile Wunderkerzen und schafft ein leuchtendes Spalier für das Paar.',
'en' => 'Hand out sparklers and form a glowing aisle for the couple.',
],
'example' => [
'de' => 'Koordiniere die Musik und kündige den Countdown an.',
'en' => 'Coordinate music and announce a countdown.',
],
'emotion' => [
'name' => [
'de' => 'Ekstase',
'en' => 'Euphoria',
],
'icon' => 'lucide-stars',
'color' => '#6366f1',
'sort_order' => 30,
],
'difficulty' => 'medium',
'sort_order' => 30,
],
],
],
[
'slug' => 'birthday-celebration',
'event_type' => [
'slug' => 'birthday',
'name' => [
'de' => 'Geburtstag',
'en' => 'Birthday',
],
'icon' => 'lucide-cake',
],
'name' => [
'de' => 'Geburtstags-Highlights',
'en' => 'Birthday Highlights',
],
'description' => [
'de' => 'Aufgaben für Überraschungen, Gratulationen und gemeinsames Feiern.',
'en' => 'Prompts covering surprises, wishes, and shared celebrations.',
],
'is_default' => false,
'position' => 20,
'tasks' => [
[
'slug' => 'birthday-surprise-wall',
'title' => [
'de' => 'Überraschungswand mit Polaroids gestalten',
'en' => 'Create a surprise wall filled with instant photos',
],
'description' => [
'de' => 'Sammle Schnappschüsse der Gäste und befestige sie als Fotowand.',
'en' => 'Collect snapshots from guests and mount them on a photo wall.',
],
'example' => [
'de' => 'Schreibe zu jedem Bild einen kurzen Gruß.',
'en' => 'Add a short message to each picture.',
],
'emotion' => [
'name' => [
'de' => 'Nostalgie',
'en' => 'Nostalgia',
],
'icon' => 'lucide-images',
'color' => '#f97316',
'sort_order' => 40,
],
'difficulty' => 'easy',
'sort_order' => 10,
],
[
'slug' => 'birthday-toast-circle',
'title' => [
'de' => 'Gratulationskreis mit kurzen Toasts',
'en' => 'Circle of toasts',
],
'description' => [
'de' => 'Bildet einen Kreis und bittet jede Person um einen 10-Sekunden-Toast.',
'en' => 'Form a circle and ask everyone for a 10-second toast.',
],
'example' => [
'de' => 'Nimm die Reaktionen als Video auf.',
'en' => 'Record the reactions on video.',
],
'emotion' => [
'name' => [
'de' => 'Dankbarkeit',
'en' => 'Gratitude',
],
'icon' => 'lucide-hands',
'color' => '#22c55e',
'sort_order' => 50,
],
'difficulty' => 'easy',
'sort_order' => 20,
],
],
],
];
DB::transaction(function () use ($collections) {
foreach ($collections as $definition) {
$eventType = $this->ensureEventType($definition['event_type']);
$collection = TaskCollection::updateOrCreate(
['slug' => $definition['slug']],
[
'tenant_id' => null,
'event_type_id' => $eventType->id,
'name_translations' => $definition['name'],
'description_translations' => $definition['description'],
'is_default' => $definition['is_default'] ?? false,
'position' => $definition['position'] ?? 0,
]
);
$syncPayload = [];
foreach ($definition['tasks'] as $taskDefinition) {
$emotion = $this->ensureEmotion($taskDefinition['emotion'] ?? [], $eventType->id);
$task = Task::updateOrCreate(
['slug' => $taskDefinition['slug']],
[
'tenant_id' => null,
'event_type_id' => $eventType->id,
'collection_id' => $collection->id,
'emotion_id' => $emotion?->id,
'title' => $taskDefinition['title'],
'description' => $taskDefinition['description'] ?? null,
'example_text' => $taskDefinition['example'] ?? null,
'difficulty' => $taskDefinition['difficulty'] ?? 'easy',
'priority' => 'medium',
'sort_order' => $taskDefinition['sort_order'] ?? 0,
'is_active' => true,
'is_completed' => false,
]
);
$syncPayload[$task->id] = ['sort_order' => $taskDefinition['sort_order'] ?? 0];
}
if (! empty($syncPayload)) {
$collection->tasks()->sync($syncPayload);
}
}
});
}
protected function ensureEventType(array $definition): EventType
{
$payload = [
'name' => $definition['name'],
'icon' => $definition['icon'] ?? null,
];
return EventType::updateOrCreate(
['slug' => $definition['slug']],
$payload
);
}
protected function ensureEmotion(array $definition, ?int $eventTypeId): ?Emotion
{
if (empty($definition)) {
return null;
}
$query = Emotion::query();
$name = $definition['name'] ?? [];
if (isset($name['en'])) {
$query->orWhere('name->en', $name['en']);
}
if (isset($name['de'])) {
$query->orWhere('name->de', $name['de']);
}
$emotion = $query->first();
if (! $emotion) {
$emotion = Emotion::create([
'name' => $name,
'icon' => $definition['icon'] ?? 'lucide-smile',
'color' => $definition['color'] ?? '#6366f1',
'description' => $definition['description'] ?? null,
'sort_order' => $definition['sort_order'] ?? 0,
'is_active' => true,
]);
}
// Assign remaining tasks to fun collection using Eloquent
$funTasks = collect($taskIds)->slice(4);
$funCollection->tasks()->attach($funTasks);
if ($eventTypeId && ! $emotion->eventTypes()->where('event_type_id', $eventTypeId)->exists()) {
$emotion->eventTypes()->attach($eventTypeId);
}
// Link fun collection to demo event as fallback using Eloquent
$demoEvent->taskCollections()->attach($funCollection, ['sort_order' => 2]);
$this->command->info("✅ Created 2 task collections with " . count($taskIds) . " tasks for demo event");
$this->command->info("Wedding Collection ID: {$weddingCollection->id}");
$this->command->info("Fun Collection ID: {$funCollection->id}");
return $emotion;
}
}

View File

@@ -3,6 +3,7 @@
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
use App\Models\{Emotion, Task, EventType};
class TasksSeeder extends Seeder
@@ -43,10 +44,11 @@ class TasksSeeder extends Seeder
$emotion = Emotion::where('name->de', $emotionNameDe)->first();
if (!$emotion) continue;
foreach ($tasks as $t) {
$slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']);
$slug = $slugBase ? $slugBase . '-' . $emotion->id : Str::uuid()->toString();
Task::updateOrCreate([
'emotion_id' => $emotion->id,
'title->de' => $t['title']['de'],
'tenant_id' => $demoTenant->id
'slug' => $slug,
], [
'tenant_id' => $demoTenant->id,
'emotion_id' => $emotion->id,
@@ -55,6 +57,7 @@ class TasksSeeder extends Seeder
'description' => $t['description'],
'difficulty' => $t['difficulty'],
'is_active' => true,
'sort_order' => $t['sort_order'] ?? 0,
]);
}
}

View File

@@ -308,6 +308,8 @@ VITE_API_URL=https://api.fotospiel.com
VITE_OAUTH_CLIENT_ID=tenant-admin-app
```
> **Hinweis:** Der Wert von `VITE_OAUTH_CLIENT_ID` dient jetzt als alleinige Quelle der Wahrheit für den Tenant-Admin-OAuth-Client. Der Seeder `OAuthClientSeeder` greift auf `config/services.php` zu, das wiederum diesen Env-Wert ausliest und passende Redirect-URIs generiert (`/event-admin/auth/callback` für DEV und APP_URL). Stimmt der Wert im Frontend nicht mit dem Seeder überein, schlägt der PKCE-Login mit `invalid_client` fehl.
## Error Handling
### Common Error Responses

View File

@@ -258,7 +258,7 @@ curl -H "Authorization: Bearer {token}" \
### Environment-Variablen
- **VITE_API_URL**: Backend-API-URL (Pflicht)
- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht)
- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht, muss mit `config/services.php` übereinstimmen der Seeder legt damit den Client in `oauth_clients` an)
- **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat)
### Build & Deploy

View File

@@ -40,5 +40,13 @@ Owner: Codex (handoff)
- Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen.
- Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships.
## Priority: Immediate (Tenant admin refresh 2025-10-18)
- [x] Replace the `/event-admin/login` landing with a public welcome screen that explains Fotospiel for non-technical couples, keeps the login button, and updates `resources/js/admin/router.tsx`, `constants.ts`, and new `WelcomeTeaserPage`.
- [x] Align OAuth setup by reading `VITE_OAUTH_CLIENT_ID` in `OAuthClientSeeder`, updating redirect URIs to `/event-admin/auth/callback`, reseeding, and documenting the env expectation in `docs/prp/tenant-app-specs/api-usage.md` / `13-backend-authentication.md`.
- [x] Rebrand the Filament tenant panel away from “Admin” by adjusting `AdminPanelProvider` (brand name, home URL, navigation visibility) and registering a new onboarding home page.
- [x] Build the Filament onboarding wizard (welcome → task package selection → event name → color palette → QR layout) with persisted progress on the tenant record and guards that hide legacy resource menus until completion.
- [x] Expose QR invite generation in Filament via a dedicated page/component that reuses the join-token flow from `EventDetailPage.tsx`, ensuring tokens stay in sync between PWA and Filament.
- [ ] Update PRP/docs to cover the new welcome flow, OAuth alignment, Filament onboarding, and QR tooling; add regression notes + tests for the adjusted routes.

View File

@@ -1,4 +1,5 @@
import { authorizedFetch } from './auth/tokens';
import i18n from './i18n';
type JsonValue = Record<string, any>;
@@ -108,25 +109,93 @@ export type CreditLedgerEntry = {
export type TenantTask = {
id: number;
slug: string;
title: string;
title_translations: Record<string, string>;
description: string | null;
description_translations: Record<string, string | null>;
example_text: string | null;
example_text_translations: Record<string, string>;
priority: 'low' | 'medium' | 'high' | 'urgent' | null;
difficulty: 'easy' | 'medium' | 'hard' | null;
due_date: string | null;
is_completed: boolean;
collection_id: number | null;
source_task_id: number | null;
source_collection_id: number | null;
assigned_events_count: number;
assigned_events?: TenantEvent[];
created_at: string | null;
updated_at: string | null;
};
export type TenantTaskCollection = {
id: number;
slug: string;
name: string;
name_translations: Record<string, string>;
description: string | null;
description_translations: Record<string, string | null>;
tenant_id: number | null;
is_global: boolean;
event_type?: {
id: number;
slug: string;
name: string;
name_translations: Record<string, string>;
icon: string | null;
} | null;
tasks_count: number;
position: number | null;
source_collection_id: number | null;
created_at: string | null;
updated_at: string | null;
};
export type TenantEmotion = {
id: number;
name: string;
name_translations: Record<string, string>;
description: string | null;
description_translations: Record<string, string | null>;
icon: string;
color: string;
sort_order: number;
is_active: boolean;
is_global: boolean;
tenant_id: number | null;
event_types: Array<{
id: number;
slug: string;
name: string;
name_translations: Record<string, string>;
}>;
created_at: string | null;
updated_at: string | null;
};
export type TaskPayload = Partial<{
title: string;
title_translations: Record<string, string>;
description: string | null;
description_translations: Record<string, string | null>;
example_text: string | null;
example_text_translations: Record<string, string | null>;
collection_id: number | null;
priority: 'low' | 'medium' | 'high' | 'urgent';
due_date: string | null;
is_completed: boolean;
difficulty: 'easy' | 'medium' | 'hard';
}>;
export type EmotionPayload = Partial<{
name: string;
description: string | null;
icon: string;
color: string;
sort_order: number;
is_active: boolean;
event_type_ids: number[];
}>;
export type EventMember = {
@@ -197,6 +266,62 @@ function buildPagination(payload: JsonValue | null, defaultCount: number): Pagin
};
}
function translationLocales(): string[] {
const locale = i18n.language;
const base = locale?.includes('-') ? locale.split('-')[0] : locale;
const fallback = ['de', 'en'];
return [locale, base, ...fallback].filter(
(value, index, self): value is string => Boolean(value) && self.indexOf(value) === index
);
}
function normalizeTranslationMap(value: unknown, fallback?: string, allowEmpty = false): Record<string, string> {
if (typeof value === 'string') {
const map: Record<string, string> = {};
for (const locale of translationLocales()) {
map[locale] = value;
}
return map;
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const entries: Record<string, string> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (typeof entry === 'string') {
entries[key] = entry;
}
}
if (Object.keys(entries).length > 0) {
return entries;
}
}
if (fallback) {
const locales = translationLocales();
return locales.reduce<Record<string, string>>((acc, locale) => {
acc[locale] = fallback;
return acc;
}, {});
}
return allowEmpty ? {} : {};
}
function pickTranslatedText(translations: Record<string, string>, fallback: string): string {
const locales = translationLocales();
for (const locale of locales) {
if (translations[locale]) {
return translations[locale]!;
}
}
const first = Object.values(translations)[0];
if (first) {
return first;
}
return fallback;
}
function normalizeEvent(event: TenantEvent): TenantEvent {
return {
...event,
@@ -260,14 +385,29 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
}
function normalizeTask(task: JsonValue): TenantTask {
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
const exampleTranslations = normalizeTranslationMap(task.example_text ?? {});
return {
id: Number(task.id ?? 0),
title: String(task.title ?? 'Ohne Titel'),
description: task.description ?? null,
tenant_id: task.tenant_id ?? null,
slug: String(task.slug ?? `task-${task.id ?? ''}`),
title: pickTranslatedText(titleTranslations, 'Ohne Titel'),
title_translations: titleTranslations,
description: Object.keys(descriptionTranslations).length
? pickTranslatedText(descriptionTranslations, '')
: null,
description_translations: Object.keys(descriptionTranslations).length ? descriptionTranslations : {},
example_text: Object.keys(exampleTranslations).length ? pickTranslatedText(exampleTranslations, '') : null,
example_text_translations: Object.keys(exampleTranslations).length ? exampleTranslations : {},
priority: (task.priority ?? null) as TenantTask['priority'],
difficulty: (task.difficulty ?? null) as TenantTask['difficulty'],
due_date: task.due_date ?? null,
is_completed: Boolean(task.is_completed ?? false),
collection_id: task.collection_id ?? null,
source_task_id: task.source_task_id ?? null,
source_collection_id: task.source_collection_id ?? null,
assigned_events_count: Number(task.assigned_events_count ?? 0),
assigned_events: Array.isArray(task.assigned_events) ? task.assigned_events.map(normalizeEvent) : undefined,
created_at: task.created_at ?? null,
@@ -275,6 +415,75 @@ function normalizeTask(task: JsonValue): TenantTask {
};
}
function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection {
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true);
const eventTypeRaw = raw.event_type ?? raw.eventType ?? null;
let eventType: TenantTaskCollection['event_type'] = null;
if (eventTypeRaw && typeof eventTypeRaw === 'object') {
const eventNameTranslations = normalizeTranslationMap(eventTypeRaw.name ?? {});
eventType = {
id: Number(eventTypeRaw.id ?? 0),
slug: String(eventTypeRaw.slug ?? ''),
name: pickTranslatedText(eventNameTranslations, String(eventTypeRaw.slug ?? '')),
name_translations: eventNameTranslations,
icon: eventTypeRaw.icon ?? null,
};
}
return {
id: Number(raw.id ?? 0),
slug: String(raw.slug ?? `collection-${raw.id ?? ''}`),
name: pickTranslatedText(nameTranslations, 'Unbenannte Sammlung'),
name_translations: nameTranslations,
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
description_translations: descriptionTranslations ?? {},
tenant_id: raw.tenant_id ?? null,
is_global: !raw.tenant_id,
event_type: eventType,
tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0),
position: raw.position !== undefined ? Number(raw.position) : null,
source_collection_id: raw.source_collection_id ?? null,
created_at: raw.created_at ?? null,
updated_at: raw.updated_at ?? null,
};
}
function normalizeEmotion(raw: JsonValue): TenantEmotion {
const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {});
const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true);
const eventTypes = Array.isArray(raw.event_types ?? raw.eventTypes)
? (raw.event_types ?? raw.eventTypes)
: [];
return {
id: Number(raw.id ?? 0),
name: pickTranslatedText(nameTranslations, 'Emotion'),
name_translations: nameTranslations,
description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null,
description_translations: descriptionTranslations ?? {},
icon: String(raw.icon ?? 'lucide-smile'),
color: String(raw.color ?? '#6366f1'),
sort_order: Number(raw.sort_order ?? 0),
is_active: Boolean(raw.is_active ?? true),
is_global: !raw.tenant_id,
tenant_id: raw.tenant_id ?? null,
event_types: (eventTypes as JsonValue[]).map((eventType) => {
const translations = normalizeTranslationMap(eventType.name ?? {});
return {
id: Number(eventType.id ?? 0),
slug: String(eventType.slug ?? ''),
name: pickTranslatedText(translations, String(eventType.slug ?? '')),
name_translations: translations,
};
}),
created_at: raw.created_at ?? null,
updated_at: raw.updated_at ?? null,
};
}
function normalizeMember(member: JsonValue): EventMember {
return {
id: Number(member.id ?? 0),
@@ -479,6 +688,8 @@ type LedgerResponse = {
type TaskCollectionResponse = {
data?: JsonValue[];
collection?: JsonValue;
message?: string;
meta?: Partial<PaginationMeta>;
current_page?: number;
last_page?: number;
@@ -685,6 +896,102 @@ export async function syncCreditBalance(payload: {
return jsonOrThrow(response, 'Failed to sync credit balance');
}
export async function getTaskCollections(params: {
page?: number;
per_page?: number;
search?: string;
event_type?: string;
scope?: 'global' | 'tenant';
} = {}): Promise<PaginatedResult<TenantTaskCollection>> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page));
if (params.per_page) searchParams.set('per_page', String(params.per_page));
if (params.search) searchParams.set('search', params.search);
if (params.event_type) searchParams.set('event_type', params.event_type);
if (params.scope) searchParams.set('scope', params.scope);
const queryString = searchParams.toString();
const response = await authorizedFetch(
`/api/v1/tenant/task-collections${queryString ? `?${queryString}` : ''}`
);
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to load task collections', response.status, payload);
throw new Error('Failed to load task collections');
}
const json = (await response.json()) as TaskCollectionResponse;
const collections = Array.isArray(json.data) ? json.data.map(normalizeTaskCollection) : [];
return {
data: collections,
meta: buildPagination(json as JsonValue, collections.length),
};
}
export async function getTaskCollection(collectionId: number): Promise<TenantTaskCollection> {
const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}`);
const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to load task collection');
return normalizeTaskCollection(json.data);
}
export async function importTaskCollection(
collectionId: number,
eventSlug: string
): Promise<TenantTaskCollection> {
const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}/activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event_slug: eventSlug }),
});
const json = await jsonOrThrow<TaskCollectionResponse>(response, 'Failed to import task collection');
if (json.collection) {
return normalizeTaskCollection(json.collection);
}
if (json.data && json.data.length === 1) {
return normalizeTaskCollection(json.data[0]!);
}
throw new Error('Missing collection payload');
}
export async function getEmotions(): Promise<TenantEmotion[]> {
const response = await authorizedFetch('/api/v1/tenant/emotions');
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to load emotions', response.status, payload);
throw new Error('Failed to load emotions');
}
const json = (await response.json()) as { data?: JsonValue[] };
return Array.isArray(json.data) ? json.data.map(normalizeEmotion) : [];
}
export async function createEmotion(payload: EmotionPayload): Promise<TenantEmotion> {
const response = await authorizedFetch('/api/v1/tenant/emotions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create emotion');
return normalizeEmotion(json.data);
}
export async function updateEmotion(emotionId: number, payload: EmotionPayload): Promise<TenantEmotion> {
const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update emotion');
return normalizeEmotion(json.data);
}
export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise<PaginatedResult<TenantTask>> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set('page', String(params.page));

View File

@@ -8,6 +8,8 @@ import {
ADMIN_SETTINGS_PATH,
ADMIN_TASKS_PATH,
ADMIN_BILLING_PATH,
ADMIN_TASK_COLLECTIONS_PATH,
ADMIN_EMOTIONS_PATH,
} from '../constants';
import { LanguageSwitcher } from './LanguageSwitcher';
@@ -15,6 +17,8 @@ const navItems = [
{ to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true },
{ to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' },
{ to: ADMIN_TASKS_PATH, labelKey: 'navigation.tasks' },
{ to: ADMIN_TASK_COLLECTIONS_PATH, labelKey: 'navigation.collections' },
{ to: ADMIN_EMOTIONS_PATH, labelKey: 'navigation.emotions' },
{ to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' },
{ to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' },
];

View File

@@ -2,12 +2,15 @@ export const ADMIN_BASE_PATH = '/event-admin';
export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`;
export const ADMIN_HOME_PATH = ADMIN_BASE_PATH;
export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH;
export const ADMIN_HOME_PATH = adminPath('/dashboard');
export const ADMIN_LOGIN_PATH = adminPath('/login');
export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/events');
export const ADMIN_SETTINGS_PATH = adminPath('/settings');
export const ADMIN_TASKS_PATH = adminPath('/tasks');
export const ADMIN_TASK_COLLECTIONS_PATH = adminPath('/task-collections');
export const ADMIN_EMOTIONS_PATH = adminPath('/emotions');
export const ADMIN_BILLING_PATH = adminPath('/billing');
export const ADMIN_PHOTOS_PATH = adminPath('/photos');
export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome');

View File

@@ -7,6 +7,8 @@
"dashboard": "Dashboard",
"events": "Events",
"tasks": "Aufgaben",
"collections": "Aufgabenvorlagen",
"emotions": "Emotionen",
"billing": "Abrechnung",
"settings": "Einstellungen"
},

View File

@@ -146,5 +146,100 @@
"urgent": "Dringend"
}
}
,
"collections": {
"title": "Aufgabenvorlagen",
"subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.",
"actions": {
"import": "Importieren",
"create": "Vorlage erstellen",
"openTasks": "Task-Bibliothek öffnen"
},
"filters": {
"search": "Nach Vorlagen suchen",
"scope": "Bereich",
"allScopes": "Alle Bereiche",
"eventType": "Event-Typ",
"allEventTypes": "Alle Event-Typen",
"globalOnly": "Nur globale Vorlagen",
"tenantOnly": "Nur eigene Vorlagen"
},
"scope": {
"global": "Globale Vorlage",
"tenant": "Eigene Vorlage"
},
"empty": {
"title": "Noch keine Vorlagen",
"description": "Importiere eine Fotospiel-Kollektion oder erstelle dein eigenes Aufgabenpaket."
},
"dialogs": {
"importTitle": "Vorlage importieren",
"collectionLabel": "Vorlage",
"selectEvent": "Event auswählen",
"submit": "Importieren",
"cancel": "Abbrechen"
},
"notifications": {
"imported": "Vorlage erfolgreich importiert",
"error": "Vorlage konnte nicht importiert werden"
},
"errors": {
"eventsLoad": "Events konnten nicht geladen werden.",
"selectEvent": "Bitte wähle ein Event aus.",
"noEvents": "Noch keine Events lege eines an, um die Vorlage zu aktivieren."
},
"labels": {
"taskCount": "{{count}} Tasks",
"updated": "Aktualisiert: {{date}}"
},
"pagination": {
"prev": "Zurück",
"next": "Weiter",
"page": "Seite {{current}} von {{total}}"
}
},
"emotions": {
"title": "Emotionen",
"subtitle": "Verwalte Stimmungen und Icons, die deine Events begleiten.",
"actions": {
"create": "Neue Emotion",
"enable": "Aktivieren",
"disable": "Deaktivieren"
},
"scope": {
"global": "Global",
"tenant": "Eigen"
},
"labels": {
"updated": "Aktualisiert: {{date}}",
"noEventType": "Alle Event-Typen"
},
"status": {
"active": "Aktiv",
"inactive": "Inaktiv"
},
"errors": {
"genericTitle": "Aktion fehlgeschlagen",
"load": "Emotionen konnten nicht geladen werden.",
"create": "Emotion konnte nicht erstellt werden.",
"toggle": "Status konnte nicht aktualisiert werden.",
"nameRequired": "Bitte gib einen Namen ein."
},
"empty": {
"title": "Noch keine Emotionen",
"description": "Erstelle eine eigene Emotion oder verwende die Fotospiel-Vorlagen."
},
"dialogs": {
"createTitle": "Eigene Emotion hinzufügen",
"name": "Name",
"description": "Beschreibung",
"icon": "Icon",
"color": "Farbe",
"activeLabel": "Aktiv",
"activeDescription": "In Task-Listen sichtbar",
"cancel": "Abbrechen",
"submit": "Emotion speichern"
}
}
}

View File

@@ -7,6 +7,8 @@
"dashboard": "Dashboard",
"events": "Events",
"tasks": "Tasks",
"collections": "Collections",
"emotions": "Emotions",
"billing": "Billing",
"settings": "Settings"
},

View File

@@ -146,4 +146,99 @@
"urgent": "Urgent"
}
}
,
"collections": {
"title": "Task collections",
"subtitle": "Browse curated task bundles or activate them for your events.",
"actions": {
"import": "Import",
"create": "Create collection",
"openTasks": "Open task library"
},
"filters": {
"search": "Search collections",
"scope": "Scope",
"allScopes": "All scopes",
"eventType": "Event type",
"allEventTypes": "All event types",
"globalOnly": "Global templates",
"tenantOnly": "Tenant collections"
},
"scope": {
"global": "Global template",
"tenant": "Tenant-owned"
},
"empty": {
"title": "No collections yet",
"description": "Import one of Fotospiels curated templates or create your own bundle to get started."
},
"dialogs": {
"importTitle": "Import collection",
"collectionLabel": "Collection",
"selectEvent": "Select event",
"submit": "Import",
"cancel": "Cancel"
},
"notifications": {
"imported": "Collection imported successfully",
"error": "Collection could not be imported"
},
"errors": {
"eventsLoad": "Events could not be loaded.",
"selectEvent": "Please select an event.",
"noEvents": "No events yet create one to activate this collection."
},
"labels": {
"taskCount": "{{count}} tasks",
"updated": "Updated: {{date}}"
},
"pagination": {
"prev": "Previous",
"next": "Next",
"page": "Page {{current}} of {{total}}"
}
},
"emotions": {
"title": "Emotions",
"subtitle": "Manage the emotional tone available for your events.",
"actions": {
"create": "Add emotion",
"enable": "Enable",
"disable": "Disable"
},
"scope": {
"global": "Global",
"tenant": "Tenant"
},
"labels": {
"updated": "Updated: {{date}}",
"noEventType": "All event types"
},
"status": {
"active": "Active",
"inactive": "Inactive"
},
"errors": {
"genericTitle": "Action failed",
"load": "Emotions could not be loaded.",
"create": "Emotion could not be created.",
"toggle": "Emotion status could not be updated.",
"nameRequired": "Please provide a name."
},
"empty": {
"title": "No emotions yet",
"description": "Create your own emotion or use the Fotospiel defaults."
},
"dialogs": {
"createTitle": "Add custom emotion",
"name": "Name",
"description": "Description",
"icon": "Icon",
"color": "Color",
"activeLabel": "Active",
"activeDescription": "Visible in the task library",
"cancel": "Cancel",
"submit": "Save emotion"
}
}
}

View File

@@ -0,0 +1,384 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
import type { Locale } from 'date-fns';
import { Palette, Plus, Power, Smile } from 'lucide-react';
import { AdminLayout } from '../components/AdminLayout';
import {
getEmotions,
createEmotion,
updateEmotion,
TenantEmotion,
EmotionPayload,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
const DEFAULT_COLOR = '#6366f1';
type EmotionFormState = {
name: string;
description: string;
icon: string;
color: string;
is_active: boolean;
sort_order: number;
};
const INITIAL_FORM_STATE: EmotionFormState = {
name: '',
description: '',
icon: 'lucide-smile',
color: DEFAULT_COLOR,
is_active: true,
sort_order: 0,
};
export default function EmotionsPage(): JSX.Element {
const { t, i18n } = useTranslation('management');
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [form, setForm] = React.useState<EmotionFormState>(INITIAL_FORM_STATE);
React.useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const data = await getEmotions();
if (!cancelled) {
setEmotions(data);
}
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.load'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [t]);
function openCreateDialog() {
setForm(INITIAL_FORM_STATE);
setDialogOpen(true);
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!form.name.trim()) {
setError(t('emotions.errors.nameRequired'));
return;
}
setSaving(true);
setError(null);
const payload: EmotionPayload = {
name: form.name.trim(),
description: form.description.trim() || null,
icon: form.icon.trim() || 'lucide-smile',
color: form.color.trim() || DEFAULT_COLOR,
is_active: form.is_active,
sort_order: form.sort_order,
};
try {
const created = await createEmotion(payload);
setEmotions((prev) => [created, ...prev]);
setDialogOpen(false);
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.create'));
}
} finally {
setSaving(false);
}
}
async function toggleEmotion(emotion: TenantEmotion) {
try {
const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active });
setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
} catch (err) {
if (!isAuthError(err)) {
setError(t('emotions.errors.toggle'));
}
}
}
const locale = i18n.language.startsWith('en') ? enGB : de;
return (
<AdminLayout
title={t('emotions.title') ?? 'Emotions'}
subtitle={t('emotions.subtitle') ?? ''}
actions={
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreateDialog}
>
<Plus className="h-4 w-4" />
{t('emotions.actions.create')}
</Button>
}
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('emotions.errors.genericTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{t('emotions.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">{t('emotions.subtitle')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{loading ? (
<EmotionSkeleton />
) : emotions.length === 0 ? (
<EmptyEmotionsState />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{emotions.map((emotion) => (
<EmotionCard
key={emotion.id}
emotion={emotion}
onToggle={() => toggleEmotion(emotion)}
locale={locale}
canToggle={!emotion.is_global}
/>
))}
</div>
)}
</CardContent>
</Card>
<EmotionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
form={form}
setForm={setForm}
saving={saving}
onSubmit={handleCreate}
/>
</AdminLayout>
);
}
function EmotionCard({
emotion,
onToggle,
locale,
canToggle,
}: {
emotion: TenantEmotion;
onToggle: () => void;
locale: Locale;
canToggle: boolean;
}) {
const { t } = useTranslation('management');
const updatedLabel = emotion.updated_at
? format(new Date(emotion.updated_at), 'PP', { locale })
: null;
return (
<Card className="border border-slate-100 bg-white/90 shadow-sm">
<CardHeader className="space-y-3">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={emotion.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
>
{emotion.is_global ? t('emotions.scope.global') : t('emotions.scope.tenant')}
</Badge>
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
{emotion.event_types.map((type) => type.name).join(', ') || t('emotions.labels.noEventType')}
</Badge>
</div>
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
<Smile className="h-4 w-4" />
{emotion.name}
</CardTitle>
{emotion.description && (
<CardDescription className="text-sm text-slate-600">{emotion.description}</CardDescription>
)}
</CardHeader>
<CardContent className="flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4" />
<span>{emotion.color}</span>
</div>
{updatedLabel && <span>{t('emotions.labels.updated', { date: updatedLabel })}</span>}
</CardContent>
<CardFooter className="flex items-center justify-between border-t border-slate-100 bg-slate-50/60 px-4 py-3">
<span className="text-xs text-slate-500">
{emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')}
</span>
<Button
variant="outline"
size="sm"
onClick={onToggle}
disabled={!canToggle}
className={!canToggle ? 'pointer-events-none opacity-60' : ''}
>
<Power className="mr-2 h-4 w-4" />
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
</Button>
</CardFooter>
</Card>
);
}
function EmptyEmotionsState() {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-indigo-200 bg-indigo-50/40 p-12 text-center">
<div className="rounded-full bg-white p-4 shadow-inner">
<Smile className="h-8 w-8 text-indigo-500" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-800">{t('emotions.empty.title')}</h3>
<p className="text-sm text-slate-600">{t('emotions.empty.description')}</p>
</div>
</div>
);
}
function EmotionSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
<div className="h-16 rounded bg-slate-100" />
</div>
))}
</div>
);
}
function EmotionDialog({
open,
onOpenChange,
form,
setForm,
saving,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
form: EmotionFormState;
setForm: React.Dispatch<React.SetStateAction<EmotionFormState>>;
saving: boolean;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
}) {
const { t } = useTranslation('management');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('emotions.dialogs.createTitle')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="emotion-name">{t('emotions.dialogs.name')}</Label>
<Input
id="emotion-name"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="emotion-description">{t('emotions.dialogs.description')}</Label>
<textarea
id="emotion-description"
value={form.description}
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-200"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="emotion-icon">{t('emotions.dialogs.icon')}</Label>
<Input
id="emotion-icon"
value={form.icon}
onChange={(event) => setForm((prev) => ({ ...prev, icon: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="emotion-color">{t('emotions.dialogs.color')}</Label>
<Input
id="emotion-color"
value={form.color}
onChange={(event) => setForm((prev) => ({ ...prev, color: event.target.value }))}
placeholder="#6366f1"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-indigo-100 bg-indigo-50/60 p-3">
<div>
<Label htmlFor="emotion-active" className="text-sm font-medium text-slate-800">
{t('emotions.dialogs.activeLabel')}
</Label>
<p className="text-xs text-slate-500">{t('emotions.dialogs.activeDescription')}</p>
</div>
<Switch
id="emotion-active"
checked={form.is_active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: Boolean(checked) }))}
/>
</div>
<DialogFooter className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('emotions.dialogs.cancel')}
</Button>
<Button type="submit" disabled={saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('emotions.dialogs.submit')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,492 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import { de, enGB } from 'date-fns/locale';
import { Layers, Library, Loader2, Plus } from 'lucide-react';
import { AdminLayout } from '../components/AdminLayout';
import {
getTaskCollections,
importTaskCollection,
getEvents,
PaginationMeta,
TenantEvent,
TenantTaskCollection,
} from '../api';
import { ADMIN_TASKS_PATH } from '../constants';
import { isAuthError } from '../auth/tokens';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const DEFAULT_PAGE_SIZE = 12;
type ScopeFilter = 'all' | 'global' | 'tenant';
type CollectionsState = {
items: TenantTaskCollection[];
meta: PaginationMeta | null;
};
export default function TaskCollectionsPage(): JSX.Element {
const navigate = useNavigate();
const { t, i18n } = useTranslation('management');
const [collectionsState, setCollectionsState] = React.useState<CollectionsState>({ items: [], meta: null });
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState('');
const [scope, setScope] = React.useState<ScopeFilter>('all');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [selectedEventSlug, setSelectedEventSlug] = React.useState('');
const [importing, setImporting] = React.useState(false);
const [eventsLoading, setEventsLoading] = React.useState(false);
const [eventError, setEventError] = React.useState<string | null>(null);
const [reloadToken, setReloadToken] = React.useState(0);
const scopeParam = React.useMemo(() => {
if (scope === 'global') return 'global';
if (scope === 'tenant') return 'tenant';
return undefined;
}, [scope]);
React.useEffect(() => {
let cancelled = false;
async function loadCollections() {
setLoading(true);
setError(null);
try {
const result = await getTaskCollections({
page,
per_page: DEFAULT_PAGE_SIZE,
search: search.trim() || undefined,
scope: scopeParam,
});
if (cancelled) return;
setCollectionsState({ items: result.data, meta: result.meta });
} catch (err) {
if (cancelled) return;
if (!isAuthError(err)) {
setError(t('collections.notifications.error'));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadCollections();
return () => {
cancelled = true;
};
}, [page, search, scopeParam, reloadToken, t]);
React.useEffect(() => {
if (successMessage) {
const timeout = setTimeout(() => setSuccessMessage(null), 4000);
return () => clearTimeout(timeout);
}
return undefined;
}, [successMessage]);
async function ensureEventsLoaded() {
if (events.length > 0 || eventsLoading) {
return;
}
setEventsLoading(true);
setEventError(null);
try {
const result = await getEvents();
setEvents(result);
} catch (err) {
if (!isAuthError(err)) {
setEventError(t('collections.errors.eventsLoad'));
}
} finally {
setEventsLoading(false);
}
}
function openImportDialog(collection: TenantTaskCollection) {
setSelectedCollection(collection);
setSelectedEventSlug('');
setDialogOpen(true);
void ensureEventsLoaded();
}
async function handleImport(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedCollection || !selectedEventSlug) {
setEventError(t('collections.errors.selectEvent'));
return;
}
setImporting(true);
setEventError(null);
try {
await importTaskCollection(selectedCollection.id, selectedEventSlug);
setSuccessMessage(t('collections.notifications.imported'));
setDialogOpen(false);
setReloadToken((token) => token + 1);
} catch (err) {
if (!isAuthError(err)) {
setEventError(t('collections.notifications.error'));
}
} finally {
setImporting(false);
}
}
const showEmpty = !loading && collectionsState.items.length === 0;
return (
<AdminLayout
title={t('collections.title') ?? 'Task Collections'}
subtitle={t('collections.subtitle') ?? ''}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_TASKS_PATH)}>
<Library className="mr-2 h-4 w-4" />
{t('collections.actions.openTasks')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={() => navigate(ADMIN_TASKS_PATH)}
>
<Plus className="mr-2 h-4 w-4" />
{t('collections.actions.create')}
</Button>
</div>
}
>
{error && (
<Alert variant="destructive">
<AlertTitle>{t('collections.notifications.error')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert className="border-l-4 border-green-500 bg-green-50 text-sm text-green-900">
<AlertTitle>{t('collections.notifications.imported')}</AlertTitle>
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader>
<CardTitle className="text-xl text-slate-900">{t('collections.title')}</CardTitle>
<CardDescription className="text-sm text-slate-600">{t('collections.subtitle')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<Input
value={search}
onChange={(event) => {
setPage(1);
setSearch(event.target.value);
}}
placeholder={t('collections.filters.search') ?? 'Search collections'}
className="w-full lg:max-w-md"
/>
<div className="flex flex-wrap gap-3">
<Select
value={scope}
onValueChange={(value) => {
setScope(value as ScopeFilter);
setPage(1);
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder={t('collections.filters.scope')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('collections.filters.allScopes')}</SelectItem>
<SelectItem value="global">{t('collections.filters.globalOnly')}</SelectItem>
<SelectItem value="tenant">{t('collections.filters.tenantOnly')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{loading ? (
<CollectionSkeleton />
) : showEmpty ? (
<EmptyCollectionsState onCreate={() => navigate(ADMIN_TASKS_PATH)} />
) : (
<div className="grid gap-4 sm:grid-cols-2">
{collectionsState.items.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
onImport={() => openImportDialog(collection)}
/>
))}
</div>
)}
{collectionsState.meta && collectionsState.meta.last_page > 1 && (
<div className="flex items-center justify-between">
<Button
variant="outline"
disabled={page <= 1}
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
>
{t('collections.pagination.prev')}
</Button>
<span className="text-xs text-slate-500">
{t('collections.pagination.page', {
current: collectionsState.meta.current_page,
total: collectionsState.meta.last_page,
})}
</span>
<Button
variant="outline"
disabled={page >= collectionsState.meta.last_page}
onClick={() => setPage((prev) => Math.min(prev + 1, collectionsState.meta!.last_page))}
>
{t('collections.pagination.next')}
</Button>
</div>
)}
</CardContent>
</Card>
<ImportDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
collection={selectedCollection}
events={events}
eventError={eventError}
eventsLoading={eventsLoading}
selectedEventSlug={selectedEventSlug}
onEventChange={setSelectedEventSlug}
onSubmit={handleImport}
importing={importing}
/>
</AdminLayout>
);
}
function CollectionCard({
collection,
onImport,
}: {
collection: TenantTaskCollection;
onImport: () => void;
}) {
const { t, i18n } = useTranslation('management');
const locale = i18n.language.startsWith('en') ? enGB : de;
const updatedLabel = collection.updated_at
? format(new Date(collection.updated_at), 'PP', { locale })
: null;
const scopeLabel = collection.is_global ? t('collections.scope.global') : t('collections.scope.tenant');
const eventTypeLabel = collection.event_type?.name ?? t('collections.filters.allEventTypes');
return (
<Card className="border border-slate-100 bg-white/90 shadow-sm">
<CardHeader className="space-y-3">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={collection.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
>
{scopeLabel}
</Badge>
{eventTypeLabel && (
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
{eventTypeLabel}
</Badge>
)}
</div>
<CardTitle className="text-lg text-slate-900">{collection.name}</CardTitle>
{collection.description && (
<CardDescription className="text-sm text-slate-600">
{collection.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex items-center justify-between text-sm text-slate-500">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4" />
<span>{t('collections.labels.taskCount', { count: collection.tasks_count })}</span>
</div>
{updatedLabel && <span>{t('collections.labels.updated', { date: updatedLabel })}</span>}
</CardContent>
<CardFooter className="flex justify-end">
<Button variant="outline" onClick={onImport}>
{t('collections.actions.import')}
</Button>
</CardFooter>
</Card>
);
}
function EmptyCollectionsState({ onCreate }: { onCreate: () => void }) {
const { t } = useTranslation('management');
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-pink-200 bg-pink-50/40 p-12 text-center">
<div className="rounded-full bg-white p-4 shadow-inner">
<Layers className="h-8 w-8 text-pink-500" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-800">{t('collections.empty.title')}</h3>
<p className="text-sm text-slate-600">{t('collections.empty.description')}</p>
</div>
<div className="flex gap-2">
<Button onClick={onCreate}>
<Plus className="mr-2 h-4 w-4" />
{t('collections.actions.create')}
</Button>
</div>
</div>
);
}
function CollectionSkeleton() {
return (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
<div className="h-16 rounded bg-slate-100" />
</div>
))}
</div>
);
}
function ImportDialog({
open,
onOpenChange,
collection,
events,
eventsLoading,
eventError,
selectedEventSlug,
onEventChange,
onSubmit,
importing,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
collection: TenantTaskCollection | null;
events: TenantEvent[];
eventsLoading: boolean;
eventError: string | null;
selectedEventSlug: string;
onEventChange: (value: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
importing: boolean;
}) {
const { t, i18n } = useTranslation('management');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('collections.dialogs.importTitle')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="collection-name">{t('collections.dialogs.collectionLabel')}</Label>
<Input id="collection-name" value={collection?.name ?? ''} readOnly disabled className="bg-slate-100" />
</div>
<div className="space-y-2">
<Label htmlFor="collection-event">{t('collections.dialogs.selectEvent')}</Label>
<Select
value={selectedEventSlug}
onValueChange={onEventChange}
disabled={eventsLoading || events.length === 0}
>
<SelectTrigger id="collection-event">
<SelectValue placeholder={t('collections.dialogs.selectEvent') ?? 'Event auswählen'} />
</SelectTrigger>
<SelectContent>
{events.map((event) => (
<SelectItem key={event.slug} value={event.slug}>
{formatEventLabel(event, i18n.language)}
</SelectItem>
))}
</SelectContent>
</Select>
{events.length === 0 && !eventsLoading && (
<p className="text-xs text-slate-500">
{t('collections.errors.noEvents') ?? 'Noch keine Events vorhanden.'}
</p>
)}
{eventError && <p className="text-xs text-red-500">{eventError}</p>}
</div>
<DialogFooter className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('collections.dialogs.cancel')}
</Button>
<Button type="submit" disabled={importing || !selectedEventSlug}>
{importing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('collections.dialogs.submit')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function formatEventLabel(event: TenantEvent, language: string): string {
const locales = [language, language?.split('-')[0], 'de', 'en'].filter(Boolean) as string[];
let name: string | undefined;
if (typeof event.name === 'string') {
name = event.name;
} else if (event.name && typeof event.name === 'object') {
for (const locale of locales) {
const value = (event.name as Record<string, string>)[locale!];
if (value) {
name = value;
break;
}
}
if (!name) {
const first = Object.values(event.name as Record<string, string>)[0];
if (first) {
name = first;
}
}
}
const eventDate = event.event_date ? new Date(event.event_date) : null;
if (!eventDate) {
return name ?? event.slug;
}
const locale = language.startsWith('en') ? enGB : de;
return `${name ?? event.slug} (${format(eventDate, 'PP', { locale })})`;
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, Circle, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
@@ -23,7 +24,7 @@ import {
updateTask,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { ADMIN_EVENTS_PATH } from '../constants';
import { ADMIN_EVENTS_PATH, ADMIN_TASK_COLLECTIONS_PATH } from '../constants';
type TaskFormState = {
title: string;
@@ -43,6 +44,7 @@ const INITIAL_FORM: TaskFormState = {
export default function TasksPage() {
const navigate = useNavigate();
const { t } = useTranslation('common');
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
const [page, setPage] = React.useState(1);
@@ -150,6 +152,9 @@ export default function TasksPage() {
}
async function toggleCompletion(task: TenantTask) {
if (task.tenant_id === null) {
return;
}
try {
const updated = await updateTask(task.id, { is_completed: !task.is_completed });
setTasks((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)));
@@ -165,13 +170,18 @@ export default function TasksPage() {
title="Task Bibliothek"
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate(ADMIN_TASK_COLLECTIONS_PATH)}>
{t('navigation.collections')}
</Button>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={openCreate}
>
<Plus className="h-4 w-4" />
Neuer Task
Neu
</Button>
</div>
}
>
{error && (
@@ -269,25 +279,34 @@ function TaskRow({
}) {
const assignedCount = task.assigned_events_count ?? task.assigned_events?.length ?? 0;
const completed = task.is_completed;
const isGlobal = task.tenant_id === null;
return (
<div className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<button
type="button"
onClick={onToggle}
className="rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition hover:bg-pink-50"
onClick={isGlobal ? undefined : onToggle}
aria-disabled={isGlobal}
className={`rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition ${
isGlobal ? 'opacity-50 cursor-not-allowed' : 'hover:bg-pink-50'
}`}
>
{completed ? <CheckCircle2 className="h-5 w-5" /> : <Circle className="h-5 w-5" />}
</button>
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<p className={`text-sm font-semibold ${completed ? 'text-slate-500 line-through' : 'text-slate-900'}`}>
{task.title}
</p>
<Badge variant="outline" className="border-pink-200 text-pink-600">
{mapPriority(task.priority)}
</Badge>
{isGlobal && (
<Badge variant="secondary" className="bg-slate-100 text-slate-600">
Global
</Badge>
)}
</div>
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
@@ -297,10 +316,16 @@ function TaskRow({
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Button variant="outline" size="sm" onClick={onEdit} disabled={isGlobal} className={isGlobal ? 'opacity-50' : ''}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={onDelete} className="text-rose-600">
<Button
variant="outline"
size="sm"
onClick={onDelete}
className={`text-rose-600 ${isGlobal ? 'opacity-50' : ''}`}
disabled={isGlobal}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ADMIN_HOME_PATH } from '../constants';
import { useAuth } from '../auth/context';
const highlights = [
{
icon: Sparkles,
title: 'Momente lenken, nicht das Handy',
description:
'Fotospiel liefert euch spielerische Aufgaben, damit eure Gäste das Fest genießen und gleichzeitig emotionale Motive festhalten.',
},
{
icon: Users,
title: 'Alle Gäste auf einer Reise',
description:
'Einladungslinks und QR-Codes führen direkt in eure Event-Galerie. Kein Technik-Know-how nötig nur teilen und loslegen.',
},
{
icon: Camera,
title: 'Live-Galerie und Moderation',
description:
'Sammelt Bilder in Echtzeit, markiert Highlights und entscheidet gemeinsam, welche Erinnerungen groß rauskommen.',
},
];
export default function WelcomeTeaserPage() {
const navigate = useNavigate();
const { login } = useAuth();
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-800">
<header className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 pb-12 pt-16 text-center md:pt-20">
<div className="mx-auto w-fit rounded-full border border-rose-100 bg-white/80 px-4 py-1 text-sm font-medium text-rose-500 shadow-sm">
Willkommen bei Fotospiel
</div>
<h1 className="font-display text-4xl font-semibold tracking-tight text-slate-900 md:text-5xl">
Eure Gäste als Geschichtenerzähler ohne Technikstress
</h1>
<p className="mx-auto max-w-2xl text-base leading-relaxed text-slate-600 md:text-lg">
Dieses Kontrollzentrum zeigt euch, wie ihr Fotospiel für Hochzeit, Jubiläum oder Team-Event einsetzt.
Wir führen euch Schritt für Schritt durch Aufgaben, Event-Setup und Einladungen.
</p>
<div className="flex flex-col justify-center gap-3 md:flex-row">
<button
type="button"
className="group inline-flex items-center justify-center gap-2 rounded-full border border-transparent bg-white px-6 py-3 text-base font-semibold text-rose-500 shadow-sm transition hover:border-rose-200 hover:text-rose-600"
onClick={() => login(ADMIN_HOME_PATH)}
>
Ich habe bereits Zugang
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" />
</button>
</div>
</header>
<main className="mx-auto w-full max-w-5xl space-y-12 px-6 pb-16">
<section className="grid gap-6 rounded-3xl border border-white/60 bg-white/80 p-6 shadow-xl shadow-rose-100/40 backdrop-blur-md md:grid-cols-3 md:p-8">
{highlights.map((item) => (
<article key={item.title} className="flex flex-col gap-4 rounded-2xl bg-white/70 p-5 shadow-sm">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100 text-rose-500">
<item.icon className="h-6 w-6" />
</div>
<h2 className="text-lg font-semibold text-slate-900">{item.title}</h2>
<p className="text-sm leading-relaxed text-slate-600">{item.description}</p>
</article>
))}
</section>
<section className="grid gap-8 rounded-3xl border border-sky-100 bg-gradient-to-br from-white via-sky-50 to-white p-6 shadow-lg md:grid-cols-2 md:p-10">
<div className="space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700">
<Heart className="h-4 w-4" />
So startet ihr
</div>
<h2 className="text-2xl font-semibold text-slate-900 md:text-3xl">In drei Schritten zu eurer Story</h2>
<ol className="space-y-4 text-sm leading-relaxed text-slate-600">
<li>
<span className="font-semibold text-slate-900">1. Aufgaben entdecken&nbsp;</span>
Stellt euer erstes Aufgabenpaket zusammen, das zu eurer Feier passt.
</li>
<li>
<span className="font-semibold text-slate-900">2. Event anlegen&nbsp;</span>
Benennt euer Event, legt Farben fest und erstellt den QR-Einladungslink.
</li>
<li>
<span className="font-semibold text-slate-900">3. Link teilen&nbsp;</span>
Gäste scannen, laden Fotos hoch und ihr entscheidet, was in euer Album kommt.
</li>
</ol>
</div>
<aside className="space-y-4 rounded-2xl border border-rose-100 bg-white/90 p-6 text-sm leading-relaxed text-slate-600 shadow-md">
<p>
Ihr könnt jederzeit unterbrechen und später weiter machen. Falls ihr Fragen habt, meldet euch unter{' '}
<a className="font-medium text-rose-500 underline" href="mailto:hallo@fotospiel.de">
hallo@fotospiel.de
</a>
.
</p>
<p>
Nach dem Login geleiten wir euch automatisch zur geführten Einrichtung. Dort entscheidet ihr auch,
wann eure Gästegalerie sichtbar wird.
</p>
</aside>
</section>
</main>
<footer className="border-t border-white/60 bg-white/70 py-6 text-center text-xs text-slate-500">
Fotospiel Eure Gäste gestalten eure Lieblingsmomente
</footer>
</div>
);
}

View File

@@ -11,12 +11,16 @@ import EventMembersPage from './pages/EventMembersPage';
import EventTasksPage from './pages/EventTasksPage';
import BillingPage from './pages/BillingPage';
import TasksPage from './pages/TasksPage';
import TaskCollectionsPage from './pages/TaskCollectionsPage';
import EmotionsPage from './pages/EmotionsPage';
import AuthCallbackPage from './pages/AuthCallbackPage';
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
import { useAuth } from './auth/context';
import {
ADMIN_AUTH_CALLBACK_PATH,
ADMIN_BASE_PATH,
ADMIN_HOME_PATH,
ADMIN_LOGIN_PATH,
ADMIN_PUBLIC_LANDING_PATH,
} from './constants';
import WelcomeLandingPage from './onboarding/pages/WelcomeLandingPage';
import WelcomePackagesPage from './onboarding/pages/WelcomePackagesPage';
@@ -42,15 +46,36 @@ function RequireAuth() {
return <Outlet />;
}
function LandingGate() {
const { status } = useAuth();
if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Bitte warten ...
</div>
);
}
if (status === 'authenticated') {
return <Navigate to={ADMIN_HOME_PATH} replace />;
}
return <WelcomeTeaserPage />;
}
export const router = createBrowserRouter([
{ path: ADMIN_LOGIN_PATH, element: <LoginPage /> },
{ path: ADMIN_AUTH_CALLBACK_PATH, element: <AuthCallbackPage /> },
{
path: ADMIN_BASE_PATH,
element: <Outlet />,
children: [
{ index: true, element: <LandingGate /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'auth/callback', element: <AuthCallbackPage /> },
{
element: <RequireAuth />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'dashboard', element: <Navigate to={ADMIN_BASE_PATH} replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'events', element: <EventsPage /> },
{ path: 'events/new', element: <EventFormPage /> },
{ path: 'events/:slug', element: <EventDetailPage /> },
@@ -59,15 +84,22 @@ export const router = createBrowserRouter([
{ path: 'events/:slug/members', element: <EventMembersPage /> },
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
{ path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> },
{ path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'welcome', element: <WelcomeLandingPage /> },
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
{ path: '', element: <Navigate to="dashboard" replace /> },
],
},
{ path: '*', element: <Navigate to={ADMIN_BASE_PATH} replace /> },
],
},
{
path: '*',
element: <Navigate to={ADMIN_PUBLIC_LANDING_PATH} replace />,
},
]);

View File

@@ -41,9 +41,18 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
return
}
console.log('Embla API initialized:', api)
console.log('Embla options:', opts)
console.log('Embla plugins:', plugins)
setCount(api.slideNodes().length)
api.on("reInit", setCount)
api.on("slideChanged", ({ slide }: { slide: number }) => setCurrent(slide))
api.on("slideChanged", ({ slide }: { slide: number }) => {
console.log('Slide changed to:', slide)
setCurrent(slide)
})
api.on("pointerDown", () => console.log('Pointer down event'))
api.on("pointerUp", () => console.log('Pointer up event'))
setApi?.(api)
}, [api, setApi])
@@ -55,11 +64,15 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
"relative w-full",
className
)}
onTouchStart={(e) => console.log('Carousel touch start:', e.touches.length)}
onTouchMove={(e) => console.log('Carousel touch move:', e.touches.length)}
onTouchEnd={(e) => console.log('Carousel touch end')}
{...props}
>
<div
className="overflow-hidden"
ref={emblaRef}
style={{ touchAction: 'pan-y pinch-zoom' }}
>
<div className="flex">{children}</div>
</div>

View File

@@ -3,6 +3,10 @@ import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
interface Props {
posts: {
@@ -44,38 +48,43 @@ const Blog: React.FC<Props> = ({ posts }) => {
if (!posts.links || posts.links.length <= 3) return null;
return (
<div className="mt-12 text-center">
<div className="flex justify-center space-x-2">
<div className="mt-12">
<Card className="p-6">
<div className="flex justify-center">
<div className="flex flex-wrap justify-center gap-2">
{posts.links.map((link, index) => {
const href = resolvePaginationHref(link.url);
const baseClasses = `px-3 py-2 rounded ${
link.active
? 'bg-[#FFB6C1] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`;
if (!href) {
return (
<span
<Button
key={index}
className={`${baseClasses} cursor-default`}
variant={link.active ? "default" : "outline"}
disabled
className={link.active ? "bg-[#FFB6C1] hover:bg-[#FF69B4]" : ""}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
);
}
return (
<Link
<Button
key={index}
asChild
variant={link.active ? "default" : "outline"}
className={link.active ? "bg-[#FFB6C1] hover:bg-[#FF69B4]" : ""}
>
<Link
href={href}
className={baseClasses}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
</Button>
);
})}
</div>
</div>
</Card>
</div>
);
};
@@ -83,54 +92,91 @@ const Blog: React.FC<Props> = ({ posts }) => {
<MarketingLayout title={t('blog.title')}>
<Head title={t('blog.title')} />
{/* Hero Section */}
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
<div className="container mx-auto text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('blog.hero_title')}</h1>
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">{t('blog.hero_description')}</p>
<Link href="#posts" className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<section className="bg-aurora-enhanced py-20 px-4">
<div className="container mx-auto max-w-4xl">
<Card className="bg-white/10 backdrop-blur-sm border-white/20 shadow-xl">
<CardContent className="p-8 md:p-12 text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6 font-display text-gray-900 dark:text-gray-100">{t('blog.hero_title')}</h1>
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing text-gray-800 dark:text-gray-200">{t('blog.hero_description')}</p>
<Button
asChild
size="lg"
className="bg-white text-[#FFB6C1] hover:bg-gray-100 px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing"
>
<Link href="#posts">
{t('blog.hero_cta')}
</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
{/* Posts Section */}
<section id="posts" className="py-20 px-4 bg-white dark:bg-gray-900">
<div className="container mx-auto max-w-4xl">
<h2 className="text-3xl font-bold text-center mb-12 font-display text-gray-900 dark:text-gray-100">{t('blog.posts_title')}</h2>
<div className="container mx-auto max-w-6xl">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold font-display text-gray-900 dark:text-gray-100 mb-4">{t('blog.posts_title')}</h2>
<Separator className="w-24 mx-auto" />
</div>
{posts.data.length > 0 ? (
<>
<div className="grid md:grid-cols-2 gap-8">
{posts.data.map((post) => (
<div key={post.id} className="bg-gray-50 dark:bg-gray-800 p-6 rounded-lg">
<Card key={post.id} className="overflow-hidden hover:shadow-lg transition-shadow duration-300">
{post.featured_image && (
<div className="aspect-video overflow-hidden">
<img
src={post.featured_image}
alt={post.title}
className="w-full h-48 object-cover rounded mb-4"
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
/>
</div>
)}
<h3 className="text-xl font-semibold mb-2 font-sans-marketing text-gray-900 dark:text-gray-100">
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="hover:text-[#FFB6C1]">
{post.title?.[locale] || post.title?.de || post.title || 'No Title'}
</Link>
</h3>
<p className="mb-4 text-gray-700 dark:text-gray-300 font-serif-custom">{post.excerpt?.[locale] || post.excerpt?.de || post.excerpt || 'No Excerpt'}</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 font-sans-marketing">
{t('blog.by')} {post.author?.name?.[locale] || post.author?.name?.de || post.author?.name || t('blog.team')} | {t('blog.published_at')} {post.published_at}
</p>
<CardContent className="p-6">
<CardTitle className="text-xl font-semibold mb-3 font-sans-marketing">
<Link
href={`${localizedPath(`/blog/${post.slug}`)}`}
className="text-[#FFB6C1] font-semibold font-sans-marketing hover:underline"
className="hover:text-[#FFB6C1] transition-colors"
>
{post.title?.[locale] || post.title?.de || post.title || 'No Title'}
</Link>
</CardTitle>
<p className="text-gray-700 dark:text-gray-300 font-serif-custom mb-4 leading-relaxed">
{post.excerpt?.[locale] || post.excerpt?.de || post.excerpt || 'No Excerpt'}
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
<Badge variant="secondary" className="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{t('blog.by')} {post.author?.name?.[locale] || post.author?.name?.de || post.author?.name || t('blog.team')}
</Badge>
<Separator orientation="vertical" className="hidden sm:block h-4" />
<Badge variant="outline" className="text-gray-500">
{t('blog.published_at')} {new Date(post.published_at).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Badge>
</div>
<Button asChild variant="ghost" className="p-0 h-auto text-[#FFB6C1] hover:text-[#FF69B4] hover:bg-transparent">
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="font-semibold">
{t('blog.read_more')}
</Link>
</div>
</Button>
</CardContent>
</Card>
))}
</div>
{renderPagination()}
</>
) : (
<p className="text-center text-gray-600 dark:text-gray-400 font-serif-custom">{t('blog.empty')}</p>
<Card className="p-8 text-center">
<p className="text-gray-600 dark:text-gray-400 font-serif-custom">{t('blog.empty')}</p>
</Card>
)}
</div>
</section>

View File

@@ -3,6 +3,10 @@ import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
interface Props {
post: {
@@ -25,39 +29,82 @@ const BlogShow: React.FC<Props> = ({ post }) => {
return (
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
<Head title={`${post.title} ${t('title_suffix')}`} />
{/* Hero Section */}
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] text-white py-20 px-4">
<div className="container mx-auto text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
<p className="text-lg mb-8">
{t('by_author')} {post.author?.name || t('team')} | {t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE')}
</p>
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] py-20 px-4">
<div className="container mx-auto max-w-4xl">
<Card className="bg-white/10 backdrop-blur-sm border-white/20 text-white shadow-xl">
<CardContent className="p-8 text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-6 leading-tight">{post.title}</h1>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8 text-lg">
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
{t('by_author')} {post.author?.name || t('team')}
</Badge>
<Separator orientation="vertical" className="hidden sm:block h-6 bg-white/30" />
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
{t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Badge>
</div>
{post.featured_image && (
<div className="mt-8">
<img
src={post.featured_image}
alt={post.title}
className="mx-auto rounded-lg shadow-lg max-w-2xl"
className="mx-auto rounded-lg shadow-lg max-w-2xl w-full object-cover"
/>
</div>
)}
</CardContent>
</Card>
</div>
</section>
{/* Post Content */}
<section className="py-20 px-4 bg-white">
<div className="container mx-auto max-w-4xl prose prose-lg max-w-none">
<div dangerouslySetInnerHTML={{ __html: post.content_html }} />
<div className="container mx-auto max-w-4xl">
<Card className="shadow-sm">
<CardContent className="p-8 md:p-12">
<div
className="prose prose-lg prose-slate max-w-none
prose-headings:text-slate-900 prose-headings:font-semibold
prose-p:text-slate-700 prose-p:leading-relaxed
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
prose-strong:text-slate-900 prose-strong:font-semibold
prose-code:text-slate-900 prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-900 prose-pre:text-slate-100
prose-blockquote:border-l-4 prose-blockquote:border-blue-500 prose-blockquote:pl-6 prose-blockquote:italic
prose-ul:text-slate-700 prose-ol:text-slate-700
prose-li:text-slate-700"
dangerouslySetInnerHTML={{ __html: post.content_html }}
/>
</CardContent>
</Card>
</div>
</section>
{/* Back to Blog */}
<section className="py-10 px-4 bg-gray-50">
<div className="container mx-auto text-center">
<Link
href={localizedPath('/blog')}
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition"
<div className="container mx-auto max-w-4xl">
<Card className="shadow-sm">
<CardContent className="p-8 text-center">
<Separator className="mb-6" />
<Button
asChild
size="lg"
className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-8 py-3 rounded-full font-semibold transition-colors"
>
<Link href={localizedPath('/blog')}>
{t('back_to_blog')}
</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
</MarketingLayout>

View File

@@ -106,12 +106,15 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
{/* Mobile Carousel for Endcustomer Packages */}
<div className="block md:hidden">
<Carousel className="w-full max-w-md mx-auto">
<Carousel className="w-full max-w-md mx-auto" opts={{ loop: true }}>
<CarouselContent className="-ml-1">
{endcustomerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="pl-1 basis-full">
<div
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
onTouchStart={(e) => console.log('Touch start on carousel item:', e.touches.length)}
onTouchMove={(e) => console.log('Touch move on carousel item:', e.touches.length)}
onTouchEnd={(e) => console.log('Touch end on carousel item')}
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
@@ -329,12 +332,15 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
{/* Mobile Carousel for Reseller Packages */}
<div className="block md:hidden">
<Carousel className="w-full max-w-md mx-auto">
<Carousel className="w-full max-w-md mx-auto" opts={{ loop: true }}>
<CarouselContent className="-ml-1">
{resellerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="pl-1 basis-full">
<div
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
onTouchStart={(e) => console.log('Touch start on reseller carousel item:', e.touches.length)}
onTouchMove={(e) => console.log('Touch move on reseller carousel item:', e.touches.length)}
onTouchEnd={(e) => console.log('Touch end on reseller carousel item')}
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />

View File

@@ -0,0 +1,122 @@
<x-filament-panels::page>
<div class="mx-auto w-full max-w-4xl space-y-10">
<section class="rounded-3xl border border-white/70 bg-white/85 p-8 shadow-xl shadow-sky-100/50">
<header class="space-y-3">
<h1 class="text-3xl font-semibold text-slate-900">Einladungen & QR-Codes</h1>
<p class="text-sm text-slate-600">
Erstellt und verwaltet eure QR-Einladungen. Jeder Link enthält druckfertige Layouts als PDF &amp; SVG.
</p>
</header>
<div class="mt-6 grid gap-6 md:grid-cols-[2fr,1fr]">
<div>
<label class="block text-sm font-semibold text-slate-700">
Event auswählen
<select
wire:model="selectedEventId"
class="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400">
<option value="">Bitte wählt ein Event</option>
@foreach ($this->events as $event)
<option value="{{ $event->id }}">{{ data_get($event->name, app()->getLocale()) ?? data_get($event->name, 'de') ?? 'Event #' . $event->id }}</option>
@endforeach
</select>
</label>
</div>
<div class="rounded-2xl border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-700 shadow-inner">
<p class="font-semibold">Tipp</p>
<p class="mt-2">Druckt mehrere Layouts aus und verteilt sie am Eingang, am Gästebuch und beim DJ-Pult.</p>
</div>
</div>
</section>
<section class="rounded-3xl border border-white/70 bg-white/85 p-8 shadow-xl shadow-rose-100/50">
<form wire:submit.prevent="createInvite" class="grid gap-4 md:grid-cols-[2fr,1fr] md:items-end">
<label class="block text-sm font-semibold text-slate-700">
Bezeichnung des Links (optional)
<input
type="text"
wire:model.defer="tokenLabel"
class="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400"
placeholder="z.B. Empfang, Fotobox, Tanzfläche" />
</label>
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:opacity-70"
@disabled($this->events->isEmpty())
wire:loading.attr="disabled"
wire:target="createInvite">
<span wire:loading.remove wire:target="createInvite">Neuen Einladungslink erzeugen</span>
<span wire:loading wire:target="createInvite" class="flex items-center gap-2">
<x-filament::loading-indicator class="h-4 w-4" />
Wird erstellt
</span>
</button>
</div>
</form>
@if ($this->events->isEmpty())
<p class="mt-4 text-sm text-slate-600">Legt zunächst ein Event an, um Einladungslinks zu erstellen.</p>
@endif
</section>
<section class="rounded-3xl border border-white/70 bg-white/90 p-8 shadow-xl shadow-slate-100/60">
<h2 class="text-2xl font-semibold text-slate-900">Aktive Einladungslinks</h2>
@if (empty($tokens))
<p class="mt-4 text-sm text-slate-600">
Noch keine Einladungen erstellt. Generiert euren ersten Link, um die QR-Codes als PDF oder SVG herunterzuladen.
</p>
@else
<div class="mt-6 space-y-4">
@foreach ($tokens as $token)
<article class="rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="text-lg font-semibold text-slate-900">{{ $token['label'] }}</h3>
<p class="text-sm text-slate-500">
{{ $token['url'] }} · erstellt am {{ $token['created_at'] }} · Aufrufe: {{ $token['usage_count'] }}{{ $token['usage_limit'] ? ' / ' . $token['usage_limit'] : '' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<a
href="{{ $token['url'] }}"
target="_blank"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600">
Link öffnen
</a>
<button
type="button"
x-data="{ copied: false }"
x-on:click="navigator.clipboard.writeText('{{ $token['url'] }}').then(() => { copied = true; setTimeout(() => copied = false, 1500); })"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600">
<span x-show="!copied">Link kopieren</span>
<span x-cloak x-show="copied" class="text-rose-500">Kopiert!</span>
</button>
</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
@foreach ($token['downloads'] as $layout)
<div class="rounded-xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-600">
<p class="font-semibold text-slate-900">{{ $layout['name'] }}</p>
<p class="text-xs text-slate-500">{{ $layout['subtitle'] }}</p>
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($layout['download_urls'] as $format => $url)
<a
href="{{ $url }}"
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600"
target="_blank">
{{ strtoupper($format) }} herunterladen
</a>
@endforeach
</div>
</div>
@endforeach
</div>
</article>
@endforeach
</div>
@endif
</section>
</div>
</x-filament-panels::page>

View File

@@ -0,0 +1,278 @@
@php
$steps = [
'intro' => 'Willkommen',
'packages' => 'Aufgaben wählen',
'event' => 'Event benennen',
'palette' => 'Farbwelt',
'invite' => 'Einladungen',
];
@endphp
<x-filament-panels::page>
<div class="mx-auto w-full max-w-4xl space-y-10">
<section class="rounded-3xl border border-white/60 bg-white/80 p-8 shadow-xl shadow-rose-100/40 backdrop-blur-md">
<header class="space-y-4 text-center">
<p class="inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-4 py-1 text-sm font-medium text-rose-500 shadow-sm">
Fotospiel Studio · Geführter Start
</p>
<h1 class="font-display text-4xl font-semibold tracking-tight text-slate-900">
Eure Gäste werden zu Geschichtenerzähler:innen
</h1>
<p class="mx-auto max-w-2xl text-base leading-relaxed text-slate-600">
Wir richten mit euch die Aufgaben, das Event und den ersten QR-Code ein. Alles ohne Technikstress Schritt für Schritt.
</p>
</header>
<ol class="mt-8 grid gap-3 md:grid-cols-5">
@foreach ($steps as $key => $label)
<li class="flex flex-col items-center gap-2">
<span
@class([
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition',
'bg-rose-500 text-white shadow-lg shadow-rose-300/70' => $step === $key,
'bg-white text-rose-500 border border-rose-200' => $step !== $key,
])>
{{ $loop->iteration }}
</span>
<span class="text-sm font-medium text-slate-600">
{{ $label }}
</span>
</li>
@endforeach
</ol>
</section>
@if ($step === 'intro')
<section class="rounded-3xl border border-white/70 bg-gradient-to-br from-rose-50 via-white to-sky-50 p-10 shadow-lg shadow-rose-100/50">
<div class="space-y-6 text-center">
<h2 class="text-3xl font-semibold text-slate-900">So funktioniert Fotospiel</h2>
<div class="grid gap-6 md:grid-cols-3">
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
<h3 class="font-semibold text-slate-900">1 · Aufgaben auswählen</h3>
<p class="mt-2 text-sm text-slate-600">Kuratiere Aufgaben, die eure Gäste inspirieren ohne schon Fotos zu sammeln.</p>
</div>
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
<h3 class="font-semibold text-slate-900">2 · Event gestalten</h3>
<p class="mt-2 text-sm text-slate-600">Name, Datum und Farben bestimmen das Look & Feel eurer Fotostory.</p>
</div>
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
<h3 class="font-semibold text-slate-900">3 · QR-Code teilen</h3>
<p class="mt-2 text-sm text-slate-600">Euer Einladungslink führt Gäste direkt in die Galerie kein App-Download notwendig.</p>
</div>
</div>
<button
wire:click="start"
type="button"
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
Jetzt loslegen
</button>
</div>
</section>
@endif
@if ($step === 'packages')
<section class="rounded-3xl border border-white/70 bg-white/85 p-10 shadow-xl shadow-rose-100/50">
<form wire:submit.prevent="savePackages" class="space-y-6">
<div class="space-y-2 text-center">
<h2 class="text-3xl font-semibold text-slate-900">Wählt euer erstes Aufgabenpaket</h2>
<p class="text-sm text-slate-600">Jedes Paket enthält spielerische Prompts. Ihr könnt später weitere hinzufügen oder eigene Aufgaben erstellen.</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
@foreach ($this->packageList as $package)
<label
for="package-{{ $package['id'] }}"
class="group flex cursor-pointer flex-col gap-3 rounded-2xl border border-rose-100 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
<div class="flex items-start gap-3">
<input
id="package-{{ $package['id'] }}"
type="checkbox"
value="{{ $package['id'] }}"
wire:model="selectedPackages"
class="mt-1 h-4 w-4 rounded border-rose-200 text-rose-500 focus:ring-rose-400" />
<div>
<p class="text-base font-semibold text-slate-900">{{ $package['name'] }}</p>
@if ($package['description'])
<p class="mt-1 text-sm text-slate-600">{{ $package['description'] }}</p>
@endif
</div>
</div>
</label>
@endforeach
</div>
@error('selectedPackages')
<p class="text-sm text-rose-600">{{ $message }}</p>
@enderror
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
Weiter zu Schritt 2
</button>
</div>
</form>
</section>
@endif
@if ($step === 'event')
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-sky-100/50">
<form wire:submit.prevent="saveEvent" class="space-y-6">
<div class="space-y-2">
<h2 class="text-3xl font-semibold text-slate-900">Wie heißt euer Event?</h2>
<p class="text-sm text-slate-600">Name und Anlass erscheinen in eurer Gästegalerie und auf den Einladungen.</p>
</div>
<div class="grid gap-5 md:grid-cols-2">
<div class="md:col-span-2">
<label class="block text-sm font-semibold text-slate-700">
Event-Name
<input
type="text"
wire:model.defer="eventName"
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400"
placeholder="z.B. Hochzeit Anna & Lea" />
</label>
@error('eventName')
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-semibold text-slate-700">
Datum
<input
type="date"
wire:model.defer="eventDate"
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400" />
</label>
@error('eventDate')
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-semibold text-slate-700">
Anlass
<select
wire:model.defer="eventTypeId"
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400">
<option value="">Bitte wählen</option>
@foreach ($this->eventTypeOptions as $id => $label)
<option value="{{ $id }}">{{ $label }}</option>
@endforeach
</select>
</label>
@error('eventTypeId')
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
@enderror
</div>
</div>
<div class="flex justify-between">
<button type="button" wire:click="$set('step', 'packages')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
Zurück
</button>
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
Weiter zu Schritt 3
</button>
</div>
</form>
</section>
@endif
@if ($step === 'palette')
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-emerald-100/50">
<form wire:submit.prevent="savePalette" class="space-y-6">
<div class="space-y-2">
<h2 class="text-3xl font-semibold text-slate-900">Welche Farben spiegeln eure Story?</h2>
<p class="text-sm text-slate-600">Wir wenden die Palette auf Karten, QR-Layouts und App-Elemente an. Ihr könnt sie später jederzeit ändern.</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
@foreach ($this->paletteOptions as $value => $data)
<label
for="palette-{{ $value }}"
class="flex cursor-pointer flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
<div class="flex items-start gap-3">
<input
id="palette-{{ $value }}"
type="radio"
value="{{ $value }}"
wire:model.defer="palette"
class="mt-1 h-4 w-4 border-rose-300 text-rose-500 focus:ring-rose-400" />
<div>
<p class="text-base font-semibold text-slate-900">{{ $data['label'] }}</p>
<p class="mt-1 text-sm text-slate-600">{{ $data['description'] }}</p>
</div>
</div>
</label>
@endforeach
</div>
@error('palette')
<p class="text-sm text-rose-600">{{ $message }}</p>
@enderror
<div class="flex justify-between">
<button type="button" wire:click="$set('step', 'event')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
Zurück
</button>
<button
type="submit"
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
Weiter zu Schritt 4
</button>
</div>
</form>
</section>
@endif
@if ($step === 'invite')
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-indigo-100/50">
<form wire:submit.prevent="finish" class="space-y-6">
<div class="space-y-2">
<h2 class="text-3xl font-semibold text-slate-900">Wie soll euer Einladungs-Layout aussehen?</h2>
<p class="text-sm text-slate-600">Wir generieren sofort einen QR-Code samt PDF/SVG-Downloads.</p>
</div>
<div class="grid gap-4 md:grid-cols-2">
@foreach ($this->layoutOptions as $layout)
<label
for="layout-{{ $layout['id'] }}"
class="flex cursor-pointer flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
<div class="flex items-start gap-3">
<input
id="layout-{{ $layout['id'] }}"
type="radio"
value="{{ $layout['id'] }}"
wire:model.defer="inviteLayout"
class="mt-1 h-4 w-4 border-rose-300 text-rose-500 focus:ring-rose-400" />
<div>
<p class="text-base font-semibold text-slate-900">{{ $layout['name'] }}</p>
<p class="mt-1 text-sm text-slate-600">{{ $layout['subtitle'] }}</p>
</div>
</div>
</label>
@endforeach
</div>
@error('inviteLayout')
<p class="text-sm text-rose-600">{{ $message }}</p>
@enderror
<div class="flex justify-between">
<button type="button" wire:click="$set('step', 'palette')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
Zurück
</button>
<button
type="submit"
@class([
'inline-flex items-center justify-center gap-2 rounded-full px-6 py-3 text-sm font-semibold text-white shadow-lg transition',
'bg-rose-500 shadow-rose-300/60 hover:bg-rose-600' => ! $isProcessing,
'bg-rose-400 opacity-70' => $isProcessing,
])
wire:loading.attr="disabled"
wire:target="finish">
<span wire:loading.remove wire:target="finish">Setup abschließen</span>
<span wire:loading wire:target="finish" class="flex items-center gap-2">
<x-filament::loading-indicator class="h-4 w-4" />
Bitte warten
</span>
</button>
</div>
</form>
</section>
@endif
</div>
</x-filament-panels::page>

View File

@@ -6,7 +6,9 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
use App\Http\Controllers\Api\Tenant\SettingsController;
use App\Http\Controllers\Api\Tenant\TaskController;
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
use App\Http\Controllers\Api\Tenant\PhotoController;
use App\Http\Controllers\Api\Tenant\EmotionController;
use App\Http\Controllers\OAuthController;
use App\Http\Controllers\RevenueCatWebhookController;
use App\Http\Controllers\Api\PackageController;
@@ -96,6 +98,17 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection'])
->name('tenant.tasks.from-collection');
Route::get('task-collections', [TaskCollectionController::class, 'index'])
->name('tenant.task-collections.index');
Route::get('task-collections/{collection}', [TaskCollectionController::class, 'show'])
->name('tenant.task-collections.show');
Route::post('task-collections/{collection}/activate', [TaskCollectionController::class, 'activate'])
->name('tenant.task-collections.activate');
Route::get('emotions', [EmotionController::class, 'index'])->name('tenant.emotions.index');
Route::post('emotions', [EmotionController::class, 'store'])->name('tenant.emotions.store');
Route::patch('emotions/{emotion}', [EmotionController::class, 'update'])->name('tenant.emotions.update');
Route::prefix('settings')->group(function () {
Route::get('/', [SettingsController::class, 'index'])
->name('tenant.settings.index');