feat: extend event toolkit and polish guest pwa
This commit is contained in:
129
app/Filament/Resources/InviteLayouts/InviteLayoutResource.php
Normal file
129
app/Filament/Resources/InviteLayouts/InviteLayoutResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
app/Http/Controllers/Api/Tenant/TenantFeedbackController.php
Normal file
63
app/Http/Controllers/Api/Tenant/TenantFeedbackController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
38
app/Models/InviteLayout.php
Normal file
38
app/Models/InviteLayout.php
Normal 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');
|
||||
}
|
||||
}
|
||||
30
app/Models/TenantFeedback.php
Normal file
30
app/Models/TenantFeedback.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user