feat: extend event toolkit and polish guest pwa

This commit is contained in:
Codex Agent
2025-10-28 18:28:22 +01:00
parent f29067f570
commit a7bbf230fd
45 changed files with 3809 additions and 351 deletions

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Filament\Resources\InviteLayouts;
use App\Filament\Resources\InviteLayouts\Pages\CreateInviteLayout;
use App\Filament\Resources\InviteLayouts\Pages\EditInviteLayout;
use App\Filament\Resources\InviteLayouts\Pages\ListInviteLayouts;
use App\Filament\Resources\InviteLayouts\Schemas\InviteLayoutForm;
use App\Filament\Resources\InviteLayouts\Tables\InviteLayoutsTable;
use App\Models\InviteLayout;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use UnitEnum;
class InviteLayoutResource extends Resource
{
protected static ?string $model = InviteLayout::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static string|UnitEnum|null $navigationGroup = null;
protected static ?int $navigationSort = 8;
public static function getNavigationLabel(): string
{
return __('admin.nav.invite_layouts') ?? 'Layout-Vorlagen';
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.branding') ?? 'Branding & Assets';
}
public static function form(Schema $schema): Schema
{
return InviteLayoutForm::configure($schema);
}
public static function table(Table $table): Table
{
return InviteLayoutsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListInviteLayouts::route('/'),
'create' => CreateInviteLayout::route('/create'),
'edit' => EditInviteLayout::route('/{record}/edit'),
];
}
public static function normalizePayload(array $data): array
{
$data['slug'] = Str::slug($data['slug'] ?? $data['name'] ?? 'layout');
$preview = $data['preview'] ?? [];
$qrSize = Arr::get($preview, 'qr.size_px', Arr::get($preview, 'qr_size_px'));
$svgWidth = Arr::get($preview, 'svg.width', Arr::get($preview, 'svg_width'));
$svgHeight = Arr::get($preview, 'svg.height', Arr::get($preview, 'svg_height'));
$data['preview'] = array_filter([
'background' => $preview['background'] ?? null,
'background_gradient' => $preview['background_gradient'] ?? null,
'accent' => $preview['accent'] ?? null,
'secondary' => $preview['secondary'] ?? null,
'text' => $preview['text'] ?? null,
'badge' => $preview['badge'] ?? null,
'qr' => array_filter([
'size_px' => $qrSize !== null ? (int) $qrSize : null,
]),
'svg' => array_filter([
'width' => $svgWidth !== null ? (int) $svgWidth : null,
'height' => $svgHeight !== null ? (int) $svgHeight : null,
]),
], fn ($value) => $value !== null && (! is_array($value) || ! empty($value)));
if (empty($data['preview']['qr'])) {
unset($data['preview']['qr']);
}
if (empty($data['preview']['svg'])) {
unset($data['preview']['svg']);
}
$layoutOptions = $data['layout_options'] ?? [];
$formats = $layoutOptions['formats'] ?? ['pdf', 'svg'];
if (is_string($formats)) {
$formats = array_values(array_filter(array_map('trim', explode(',', $formats))));
}
$layoutOptions['formats'] = $formats ?: ['pdf', 'svg'];
$data['layout_options'] = array_filter([
'badge_label' => $layoutOptions['badge_label'] ?? null,
'instructions_heading' => $layoutOptions['instructions_heading'] ?? null,
'link_heading' => $layoutOptions['link_heading'] ?? null,
'cta_label' => $layoutOptions['cta_label'] ?? null,
'cta_caption' => $layoutOptions['cta_caption'] ?? null,
'link_label' => $layoutOptions['link_label'] ?? null,
'logo_url' => $layoutOptions['logo_url'] ?? null,
'formats' => $layoutOptions['formats'],
], fn ($value) => $value !== null && $value !== []);
if (empty($data['layout_options']['logo_url'])) {
unset($data['layout_options']['logo_url']);
}
$instructions = $data['instructions'] ?? [];
if (is_array($instructions) && isset($instructions[0]) && is_array($instructions[0]) && array_key_exists('value', $instructions[0])) {
$instructions = array_map(fn ($item) => $item['value'] ?? null, $instructions);
}
$data['instructions'] = array_values(array_filter(array_map(fn ($value) => is_string($value) ? trim($value) : null, $instructions)));
return $data;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Pages;
use App\Filament\Resources\InviteLayouts\InviteLayoutResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
class CreateInviteLayout extends CreateRecord
{
protected static string $resource = InviteLayoutResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data = InviteLayoutResource::normalizePayload($data);
$data['created_by'] = Auth::id();
return $data;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Pages;
use App\Filament\Resources\InviteLayouts\InviteLayoutResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditInviteLayout extends EditRecord
{
protected static string $resource = InviteLayoutResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
return InviteLayoutResource::normalizePayload($data);
}
}

View File

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

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Schemas;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
class InviteLayoutForm
{
public static function configure(Schema $schema): Schema
{
return $schema->schema([
Section::make('Grunddaten')
->columns(2)
->schema([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255)
->afterStateUpdated(fn (callable $set, $state) => $set('slug', Str::slug((string) $state ?? '')))
->reactive(),
TextInput::make('slug')
->label('Slug')
->required()
->maxLength(191)
->unique(ignoreRecord: true),
TextInput::make('subtitle')
->label('Unterzeile')
->maxLength(255)
->columnSpanFull(),
Textarea::make('description')
->label('Beschreibung')
->rows(4)
->columnSpanFull(),
Toggle::make('is_active')
->label('Aktiv')
->default(true),
]),
Section::make('Papier & Format')
->columns(3)
->schema([
Select::make('paper')
->label('Papierformat')
->options([
'a4' => 'A4 (210 × 297 mm)',
'a5' => 'A5 (148 × 210 mm)',
'a3' => 'A3 (297 × 420 mm)',
'custom' => 'Benutzerdefiniert',
])
->default('a4'),
Select::make('orientation')
->label('Ausrichtung')
->options([
'portrait' => 'Hochformat',
'landscape' => 'Querformat',
])
->default('portrait'),
TextInput::make('layout_options.formats')
->label('Formate (Komma getrennt)')
->default('pdf,svg')
->helperText('Bestimmt, welche Downloads angeboten werden.')
->dehydrateStateUsing(fn ($state) => collect(explode(',', (string) $state))
->map(fn ($value) => trim((string) $value))
->filter()
->values()
->all())
->afterStateHydrated(function (TextInput $component, $state) {
if (is_array($state)) {
$component->state(implode(',', $state));
}
}),
]),
Section::make('Farben')
->columns(5)
->schema([
ColorPicker::make('preview.background')
->label('Hintergrund')
->default('#F9FAFB'),
ColorPicker::make('preview.accent')
->label('Akzent')
->default('#6366F1'),
ColorPicker::make('preview.text')
->label('Text')
->default('#0F172A'),
ColorPicker::make('preview.secondary')
->label('Sekundär')
->default('#CBD5F5'),
ColorPicker::make('preview.badge')
->label('Badge')
->default('#2563EB'),
TextInput::make('preview.qr.size_px')
->label('QR-Größe (px)')
->numeric()
->default(320)
->columnSpan(2),
TextInput::make('preview.svg.width')
->label('SVG Breite')
->numeric()
->default(1080),
TextInput::make('preview.svg.height')
->label('SVG Höhe')
->numeric()
->default(1520),
]),
Section::make('Texte & Hinweise')
->columns(2)
->schema([
TextInput::make('layout_options.badge_label')
->label('Badge-Label')
->default('Digitale Gästebox'),
TextInput::make('layout_options.instructions_heading')
->label('Anleitungstitel')
->default("So funktioniert's"),
TextInput::make('layout_options.link_heading')
->label('Link-Titel')
->default('Alternative zum Einscannen'),
TextInput::make('layout_options.cta_label')
->label('CTA Label')
->default('Scan mich & starte direkt'),
TextInput::make('layout_options.cta_caption')
->label('CTA Untertitel')
->default('Scan mich & starte direkt'),
TextInput::make('layout_options.link_label')
->label('Link Text (optional)')
->helperText('Überschreibt den standardmäßigen Einladungslink.'),
TextInput::make('layout_options.logo_url')
->label('Logo URL')
->columnSpanFull(),
Repeater::make('instructions')
->label('Hinweise')
->maxItems(6)
->schema([
Textarea::make('value')
->label('Hinweistext')
->rows(2)
->required()
->maxLength(180),
])
->afterStateHydrated(function (Repeater $component, $state): void {
if (is_array($state) && ! empty($state) && ! is_array(current($state))) {
$component->state(array_map(fn ($value) => ['value' => $value], $state));
}
})
->dehydrateStateUsing(fn ($state) => collect($state)->pluck('value')->filter()->values()->all()),
]),
]);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Filament\Resources\InviteLayouts\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class InviteLayoutsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
TextColumn::make('slug')
->label('Slug')
->copyable()
->searchable()
->sortable(),
TextColumn::make('paper')
->label('Papier')
->sortable(),
TextColumn::make('orientation')
->label('Ausrichtung')
->sortable(),
BadgeColumn::make('is_active')
->label('Status')
->colors([
'success' => fn ($state) => $state === true,
'gray' => fn ($state) => $state === false,
])
->getStateUsing(fn ($record) => $record->is_active)
->formatStateUsing(fn ($state) => $state ? 'Aktiv' : 'Inaktiv'),
TextColumn::make('updated_at')
->label('Aktualisiert')
->dateTime('d.m.Y H:i')
->sortable(),
])
->filters([
SelectFilter::make('is_active')
->label('Status')
->options([
'1' => 'Aktiv',
'0' => 'Inaktiv',
])
->query(function ($query, $state) {
if ($state === '1') {
$query->where('is_active', true);
} elseif ($state === '0') {
$query->where('is_active', false);
}
}),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest;
use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Http\Resources\Tenant\EventResource;
use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\Package;
@@ -228,6 +230,10 @@ class EventController extends Controller
unset($validated[$unused]);
}
if (isset($validated['settings']) && is_array($validated['settings'])) {
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
}
$event->update($validated);
$event->load(['eventType', 'tenant']);
@@ -277,6 +283,141 @@ class EventController extends Controller
]);
}
public function toolkit(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
$event->load(['eventType', 'eventPackage.package']);
$photoQuery = Photo::query()->where('event_id', $event->id);
$pendingPhotos = (clone $photoQuery)
->where('status', 'pending')
->latest('created_at')
->take(6)
->get();
$recentUploads = (clone $photoQuery)
->where('status', 'approved')
->latest('created_at')
->take(8)
->get();
$pendingCount = (clone $photoQuery)->where('status', 'pending')->count();
$uploads24h = (clone $photoQuery)->where('created_at', '>=', now()->subDay())->count();
$totalUploads = (clone $photoQuery)->count();
$tasks = $event->tasks()
->orderBy('tasks.sort_order')
->orderBy('tasks.created_at')
->get(['tasks.id', 'tasks.title', 'tasks.description', 'tasks.priority', 'tasks.is_completed']);
$taskSummary = [
'total' => $tasks->count(),
'completed' => $tasks->where('is_completed', true)->count(),
];
$taskSummary['pending'] = max(0, $taskSummary['total'] - $taskSummary['completed']);
$translate = static function ($value, string $fallback = '') {
if (is_array($value)) {
$locale = app()->getLocale();
$candidates = array_filter([
$locale,
$locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null,
'de',
'en',
]);
foreach ($candidates as $candidate) {
if ($candidate && isset($value[$candidate]) && $value[$candidate] !== '') {
return $value[$candidate];
}
}
$first = reset($value);
return $first !== false ? $first : $fallback;
}
if (is_string($value) && $value !== '') {
return $value;
}
return $fallback;
};
$taskPreview = $tasks
->take(6)
->map(fn ($task) => [
'id' => $task->id,
'title' => $translate($task->title, 'Task'),
'description' => $translate($task->description, null),
'is_completed' => (bool) $task->is_completed,
'priority' => $task->priority,
])
->values();
$joinTokenQuery = $event->joinTokens();
$totalInvites = (clone $joinTokenQuery)->count();
$activeInvites = (clone $joinTokenQuery)
->whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->where(function ($query) {
$query->whereNull('usage_limit')
->orWhereColumn('usage_limit', '>', 'usage_count');
})
->count();
$recentInvites = (clone $joinTokenQuery)
->orderByDesc('created_at')
->take(3)
->get();
$alerts = [];
if (($event->settings['engagement_mode'] ?? 'tasks') !== 'photo_only' && $taskSummary['total'] === 0) {
$alerts[] = 'no_tasks';
}
if ($activeInvites === 0) {
$alerts[] = 'no_invites';
}
if ($pendingCount > 0) {
$alerts[] = 'pending_photos';
}
return response()->json([
'event' => new EventResource($event),
'metrics' => [
'uploads_total' => $totalUploads,
'uploads_24h' => $uploads24h,
'pending_photos' => $pendingCount,
'active_invites' => $activeInvites,
'engagement_mode' => $event->settings['engagement_mode'] ?? 'tasks',
],
'tasks' => [
'summary' => $taskSummary,
'items' => $taskPreview,
],
'photos' => [
'pending' => PhotoResource::collection($pendingPhotos)->resolve($request),
'recent' => PhotoResource::collection($recentUploads)->resolve($request),
],
'invites' => [
'summary' => [
'total' => $totalInvites,
'active' => $activeInvites,
],
'items' => EventJoinTokenResource::collection($recentInvites)->resolve($request),
],
'alerts' => $alerts,
]);
}
public function toggle(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');

View File

@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
@@ -30,12 +31,7 @@ class EventJoinTokenController extends Controller
{
$this->authorizeEvent($request, $event);
$validated = $request->validate([
'label' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date', 'after:now'],
'usage_limit' => ['nullable', 'integer', 'min:1'],
'metadata' => ['nullable', 'array'],
]);
$validated = $this->validatePayload($request);
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
'created_by' => Auth::id(),
@@ -46,6 +42,50 @@ class EventJoinTokenController extends Controller
->setStatusCode(201);
}
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
if ($joinToken->event_id !== $event->id) {
abort(404);
}
$validated = $this->validatePayload($request, true);
$payload = [];
if (array_key_exists('label', $validated)) {
$payload['label'] = $validated['label'];
}
if (array_key_exists('expires_at', $validated)) {
$payload['expires_at'] = $validated['expires_at'];
}
if (array_key_exists('usage_limit', $validated)) {
$payload['usage_limit'] = $validated['usage_limit'];
}
if (! empty($payload)) {
$joinToken->fill($payload);
}
if (array_key_exists('metadata', $validated)) {
$current = is_array($joinToken->metadata) ? $joinToken->metadata : [];
$incoming = $validated['metadata'];
if ($incoming === null) {
$joinToken->metadata = null;
} else {
$joinToken->metadata = array_replace_recursive($current, $incoming);
}
}
$joinToken->save();
return new EventJoinTokenResource($joinToken->fresh());
}
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{
$this->authorizeEvent($request, $event);
@@ -68,4 +108,54 @@ class EventJoinTokenController extends Controller
abort(404, 'Event not found');
}
}
private function validatePayload(Request $request, bool $partial = false): array
{
$rules = [
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
'metadata.layout_customization' => ['nullable', 'array'],
'metadata.layout_customization.layout_id' => ['nullable', 'string', 'max:100'],
'metadata.layout_customization.headline' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.subtitle' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.description' => ['nullable', 'string', 'max:500'],
'metadata.layout_customization.badge_label' => ['nullable', 'string', 'max:80'],
'metadata.layout_customization.instructions_heading' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.link_heading' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.cta_label' => ['nullable', 'string', 'max:120'],
'metadata.layout_customization.cta_caption' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.link_label' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.instructions' => ['nullable', 'array', 'max:6'],
'metadata.layout_customization.instructions.*' => ['nullable', 'string', 'max:160'],
'metadata.layout_customization.logo_url' => ['nullable', 'string', 'max:2048'],
'metadata.layout_customization.logo_data_url' => ['nullable', 'string'],
'metadata.layout_customization.accent_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.text_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.background_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.secondary_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.badge_color' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
'metadata.layout_customization.background_gradient' => ['nullable', 'array'],
'metadata.layout_customization.background_gradient.angle' => ['nullable', 'numeric'],
'metadata.layout_customization.background_gradient.stops' => ['nullable', 'array', 'max:5'],
'metadata.layout_customization.background_gradient.stops.*' => ['nullable', 'string', 'regex:/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/'],
];
$validated = $request->validate($rules);
if (isset($validated['metadata']['layout_customization']['instructions'])) {
$validated['metadata']['layout_customization']['instructions'] = array_values(array_filter(
$validated['metadata']['layout_customization']['instructions'],
fn ($value) => is_string($value) && trim($value) !== ''
));
}
if (isset($validated['metadata']['layout_customization']['logo_data_url'])
&& ! is_string($validated['metadata']['layout_customization']['logo_data_url'])) {
unset($validated['metadata']['layout_customization']['logo_data_url']);
}
return $validated;
}
}

View File

@@ -46,6 +46,8 @@ class EventJoinTokenLayoutController extends Controller
abort(404, 'Unbekanntes Exportformat.');
}
$layoutConfig = $this->applyCustomization($layoutConfig, $joinToken);
$tokenUrl = url('/e/'.$joinToken->token);
$qrPngDataUri = 'data:image/png;base64,'.base64_encode(
@@ -66,6 +68,7 @@ class EventJoinTokenLayoutController extends Controller
'tokenUrl' => $tokenUrl,
'qrPngDataUri' => $qrPngDataUri,
'backgroundStyle' => $backgroundStyle,
'customization' => $joinToken->metadata['layout_customization'] ?? null,
];
$filename = sprintf('%s-%s.%s', Str::slug($eventName ?: 'event'), $layoutConfig['id'], $format);
@@ -80,7 +83,7 @@ class EventJoinTokenLayoutController extends Controller
$html = view('layouts.join-token.pdf', $viewData)->render();
$options = new Options();
$options = new Options;
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Helvetica');
@@ -115,6 +118,57 @@ class EventJoinTokenLayoutController extends Controller
return is_string($name) && $name !== '' ? $name : 'Event';
}
private function applyCustomization(array $layout, EventJoinToken $joinToken): array
{
$customization = data_get($joinToken->metadata, 'layout_customization');
if (! is_array($customization)) {
return $layout;
}
$layoutId = $customization['layout_id'] ?? null;
if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) {
// Allow customization to target a specific layout; if mismatch, skip style overrides.
// General text overrides are still applied below.
}
$colorKeys = [
'accent' => 'accent_color',
'text' => 'text_color',
'background' => 'background_color',
'secondary' => 'secondary_color',
'badge' => 'badge_color',
];
foreach ($colorKeys as $layoutKey => $customKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) {
$layout['background_gradient'] = $customization['background_gradient'];
}
foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) {
$layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== ''));
}
if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) {
$layout['logo_url'] = $customization['logo_data_url'];
} elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) {
$layout['logo_url'] = $customization['logo_url'];
}
return $layout;
}
private function buildBackgroundStyle(array $layout): string
{
$gradient = $layout['background_gradient'] ?? null;
@@ -128,4 +182,4 @@ class EventJoinTokenLayoutController extends Controller
return $layout['background'] ?? '#FFFFFF';
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\TenantFeedback;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TenantFeedbackController extends Controller
{
public function store(Request $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if (! $tenantId) {
abort(403, 'Unauthorised');
}
$validated = $request->validate([
'category' => ['required', 'string', 'max:80'],
'sentiment' => ['nullable', 'string', Rule::in(['positive', 'neutral', 'negative'])],
'rating' => ['nullable', 'integer', 'min:1', 'max:5'],
'title' => ['nullable', 'string', 'max:120'],
'message' => ['nullable', 'string', 'max:2000'],
'event_slug' => ['nullable', 'string', 'max:255'],
'metadata' => ['nullable', 'array'],
]);
$eventId = null;
if (! empty($validated['event_slug'])) {
$eventSlug = $validated['event_slug'];
$event = Event::query()
->where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->select('id')
->first();
$eventId = $event?->id;
}
$feedback = TenantFeedback::create([
'tenant_id' => $tenantId,
'event_id' => $eventId,
'category' => $validated['category'],
'sentiment' => $validated['sentiment'] ?? null,
'rating' => $validated['rating'] ?? null,
'title' => $validated['title'] ?? null,
'message' => $validated['message'] ?? null,
'metadata' => $validated['metadata'] ?? null,
]);
return response()->json([
'message' => 'Feedback gespeichert',
'data' => [
'id' => $feedback->id,
'created_at' => $feedback->created_at?->toIso8601String(),
],
], 201);
}
}

View File

@@ -7,22 +7,25 @@ use App\Models\OAuthCode;
use App\Models\RefreshToken;
use App\Models\Tenant;
use App\Models\TenantToken;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Firebase\JWT\JWT;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Log;
class OAuthController extends Controller
{
private const AUTH_CODE_TTL_MINUTES = 5;
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
private const REFRESH_TOKEN_TTL_DAYS = 30;
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
/**
@@ -104,6 +107,14 @@ class OAuthController extends Controller
'state' => $request->state,
]);
if ($this->shouldReturnJsonAuthorizeResponse($request)) {
return response()->json([
'code' => $code,
'state' => $request->state,
'redirect_url' => $redirectUrl,
]);
}
return redirect()->away($redirectUrl);
}
@@ -402,6 +413,40 @@ class OAuthController extends Controller
];
}
private function shouldReturnJsonAuthorizeResponse(Request $request): bool
{
if ($request->expectsJson() || $request->ajax()) {
return true;
}
$redirectUri = (string) $request->string('redirect_uri');
$redirectHost = $redirectUri !== '' ? parse_url($redirectUri, PHP_URL_HOST) : null;
$requestHost = $request->getHost();
if ($redirectHost && ! $this->hostsMatch($requestHost, $redirectHost)) {
return true;
}
$origin = $request->headers->get('Origin');
if ($origin) {
$originHost = parse_url($origin, PHP_URL_HOST);
if ($originHost && $redirectHost && ! $this->hostsMatch($originHost, $redirectHost)) {
return true;
}
}
return false;
}
private function hostsMatch(?string $first, ?string $second): bool
{
if (! $first || ! $second) {
return false;
}
return strtolower($first) === strtolower($second);
}
private function createRefreshToken(Tenant $tenant, OAuthClient $client, array $scopes, string $accessTokenJti, Request $request): string
{
$refreshTokenId = (string) Str::uuid();
@@ -566,6 +611,7 @@ class OAuthController extends Controller
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
}
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
{
if (empty($requestedScopes)) {
@@ -682,7 +728,7 @@ class OAuthController extends Controller
return redirect('/event-admin')->with('error', 'Invalid state parameter');
}
$client = new Client();
$client = new Client;
$clientId = config('services.stripe.connect_client_id');
$secret = config('services.stripe.connect_secret');
$redirectUri = url('/api/v1/oauth/stripe-callback');
@@ -710,11 +756,12 @@ class OAuthController extends Controller
}
session()->forget(['stripe_state', 'tenant_id']);
return redirect('/event-admin')->with('success', 'Stripe account connected successfully');
} catch (\Exception $e) {
Log::error('Stripe OAuth error: '.$e->getMessage());
return redirect('/event-admin')->with('error', 'Connection error: '.$e->getMessage());
}
}
}

View File

@@ -23,7 +23,7 @@ class EventStoreRequest extends FormRequest
public function rules(): array
{
$tenantId = request()->attributes->get('tenant_id');
return [
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
@@ -41,6 +41,8 @@ class EventStoreRequest extends FormRequest
'status' => ['nullable', Rule::in(['draft', 'published', 'archived'])],
'features' => ['nullable', 'array'],
'features.*' => ['string'],
'settings' => ['nullable', 'array'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
];
}
@@ -67,4 +69,4 @@ class EventStoreRequest extends FormRequest
'password_protected' => $this->boolean('password_protected'),
]);
}
}
}

View File

@@ -44,6 +44,8 @@ class EventResource extends JsonResource
'status' => $this->status ?? 'draft',
'is_active' => (bool) ($this->is_active ?? false),
'features' => $settings['features'] ?? [],
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
'settings' => $settings,
'event_type_id' => $this->event_type_id,
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InviteLayout extends Model
{
use HasFactory;
protected $fillable = [
'slug',
'name',
'subtitle',
'description',
'paper',
'orientation',
'preview',
'layout_options',
'instructions',
'is_active',
'created_by',
];
protected $casts = [
'preview' => 'array',
'layout_options' => 'array',
'instructions' => 'array',
'is_active' => 'bool',
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantFeedback extends Model
{
use HasFactory;
protected $table = 'tenant_feedback';
protected $guarded = [];
protected $casts = [
'metadata' => 'array',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -43,6 +44,10 @@ class TenantPackage extends Model
public function isActive(): bool
{
if ($this->package && $this->package->isEndcustomer()) {
return (bool) $this->active;
}
return $this->active && (! $this->expires_at || $this->expires_at->isFuture());
}
@@ -76,23 +81,43 @@ class TenantPackage extends Model
{
parent::boot();
static::creating(function ($tenantPackage) {
static::creating(function (self $tenantPackage) {
if (! $tenantPackage->purchased_at) {
$tenantPackage->purchased_at = now();
}
if (! $tenantPackage->expires_at && $tenantPackage->package) {
$tenantPackage->expires_at = now()->addYear(); // Standard für Reseller
$package = $tenantPackage->package;
if ($package && $package->isReseller()) {
if (! $tenantPackage->expires_at) {
$tenantPackage->expires_at = now()->addYear();
}
} else {
$tenantPackage->expires_at = now()->addCentury();
}
if ($tenantPackage->active === null) {
$tenantPackage->active = true;
}
$tenantPackage->active = true;
});
static::updating(function ($tenantPackage) {
if (
$tenantPackage->isDirty('expires_at')
&& $tenantPackage->expires_at instanceof \Carbon\CarbonInterface
&& $tenantPackage->expires_at->isPast()
) {
$tenantPackage->active = false;
static::updating(function (self $tenantPackage) {
$package = $tenantPackage->package;
if ($package && $package->isReseller()) {
if (
$tenantPackage->isDirty('expires_at')
&& $tenantPackage->expires_at instanceof CarbonInterface
&& $tenantPackage->expires_at->isPast()
) {
$tenantPackage->active = false;
}
return;
}
if ($tenantPackage->isDirty('expires_at')) {
$tenantPackage->expires_at = now()->addCentury();
}
});
}

View File

@@ -2,6 +2,8 @@
namespace App\Support;
use App\Models\InviteLayout;
class JoinTokenLayoutRegistry
{
/**
@@ -123,6 +125,18 @@ class JoinTokenLayoutRegistry
*/
public static function all(): array
{
$customLayouts = InviteLayout::query()
->where('is_active', true)
->orderBy('name')
->get();
if ($customLayouts->isNotEmpty()) {
return $customLayouts
->map(fn (InviteLayout $layout) => self::normalize(self::fromModel($layout)))
->values()
->all();
}
return array_values(array_map(fn ($layout) => self::normalize($layout), self::LAYOUTS));
}
@@ -131,6 +145,15 @@ class JoinTokenLayoutRegistry
*/
public static function find(string $id): ?array
{
$custom = InviteLayout::query()
->where('slug', $id)
->where('is_active', true)
->first();
if ($custom) {
return self::normalize(self::fromModel($custom));
}
$layout = self::LAYOUTS[$id] ?? null;
return $layout ? self::normalize($layout) : null;
@@ -151,6 +174,13 @@ class JoinTokenLayoutRegistry
'accent' => '#6366F1',
'secondary' => '#CBD5F5',
'badge' => '#2563EB',
'badge_label' => 'Digitale Gästebox',
'instructions_heading' => "So funktioniert's",
'link_heading' => 'Alternative zum Einscannen',
'cta_label' => 'Scan mich & starte direkt',
'cta_caption' => 'Scan mich & starte direkt',
'link_label' => null,
'logo_url' => null,
'qr' => [
'size_px' => 320,
],
@@ -160,11 +190,50 @@ class JoinTokenLayoutRegistry
],
'background_gradient' => null,
'instructions' => [],
'formats' => ['pdf', 'svg'],
];
return array_replace_recursive($defaults, $layout);
}
private static function fromModel(InviteLayout $layout): array
{
$preview = $layout->preview ?? [];
$options = $layout->layout_options ?? [];
$instructions = $layout->instructions ?? [];
return array_filter([
'id' => $layout->slug,
'name' => $layout->name,
'subtitle' => $layout->subtitle,
'description' => $layout->description,
'paper' => $layout->paper,
'orientation' => $layout->orientation,
'background' => $preview['background'] ?? null,
'background_gradient' => $preview['background_gradient'] ?? null,
'text' => $preview['text'] ?? null,
'accent' => $preview['accent'] ?? null,
'secondary' => $preview['secondary'] ?? null,
'badge' => $preview['badge'] ?? null,
'badge_label' => $options['badge_label'] ?? null,
'instructions_heading' => $options['instructions_heading'] ?? null,
'link_heading' => $options['link_heading'] ?? null,
'cta_label' => $options['cta_label'] ?? null,
'cta_caption' => $options['cta_caption'] ?? null,
'link_label' => $options['link_label'] ?? null,
'logo_url' => $options['logo_url'] ?? null,
'qr' => array_filter([
'size_px' => $preview['qr']['size_px'] ?? $options['qr']['size_px'] ?? $preview['qr_size_px'] ?? $options['qr_size_px'] ?? null,
]),
'svg' => array_filter([
'width' => $preview['svg']['width'] ?? $options['svg']['width'] ?? $preview['svg_width'] ?? $options['svg_width'] ?? null,
'height' => $preview['svg']['height'] ?? $options['svg']['height'] ?? $preview['svg_height'] ?? $options['svg_height'] ?? null,
]),
'formats' => $options['formats'] ?? ['pdf', 'svg'],
'instructions' => $instructions,
], fn ($value) => $value !== null && $value !== []);
}
/**
* Map layouts into an API-ready response structure, attaching URLs.
*
@@ -174,7 +243,7 @@ class JoinTokenLayoutRegistry
public static function toResponse(callable $urlResolver): array
{
return array_map(function (array $layout) use ($urlResolver) {
$formats = ['pdf', 'svg'];
$formats = $layout['formats'] ?? ['pdf', 'svg'];
return [
'id' => $layout['id'],
@@ -194,4 +263,4 @@ class JoinTokenLayoutRegistry
];
}, self::all());
}
}
}