From 3d9eaa1194ae8bbc5dfe4acf909014ec1ee86bea Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 22 Nov 2025 14:25:48 +0100 Subject: [PATCH] =?UTF-8?q?event=20photo=20wasserzeichen=20umgesetzt.=20Ev?= =?UTF-8?q?ent=20admins=20k=C3=B6nnen=20eigene=20einsetzen=20(als=20brandi?= =?UTF-8?q?ng)=20falls=20das=20Paket=20es=20erlaubt.=20der=20Super=20Admin?= =?UTF-8?q?=20kann=20f=C3=BCr=20die=20g=C3=BCnstigen=20Pakete=20eigene=20W?= =?UTF-8?q?asserzeichen=20erzwingen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Filament/Resources/EventResource.php | 1 + .../EventResource/Pages/ManageWatermark.php | 141 +++++++++++++++++ .../Pages/WatermarkSettingsPage.php | 103 +++++++++++++ .../Controllers/Api/EventPublicController.php | 121 +++++++++++++-- .../Api/Tenant/PhotoController.php | 68 ++++++++- app/Models/WatermarkSetting.php | 19 +++ .../Filament/SuperAdminPanelProvider.php | 4 + .../Photobooth/PhotoboothIngestService.php | 59 +++++++- app/Support/Concerns/PresentsPackages.php | 2 + app/Support/ImageHelper.php | 119 ++++++++++++++- app/Support/WatermarkConfigResolver.php | 90 +++++++++++ config/watermark.php | 20 +++ ..._133343_add_watermark_fields_to_events.php | 33 ++++ ...000900_create_watermark_settings_table.php | 26 ++++ database/seeders/PackageSeeder.php | 8 +- database/seeders/_DemoLifecycleSeeder.php | 8 +- playwright.config.ts | 2 +- resources/js/pages/marketing/Packages.tsx | 85 ++++++----- resources/lang/de/filament-watermark.php | 20 +++ resources/lang/en/filament-watermark.php | 20 +++ .../pages/manage-watermark.blade.php | 13 ++ .../pages/watermark-settings-page.blade.php | 22 +++ tests/ui/admin/event-addon-upgrade.test.ts | 142 ++++++++++++++++++ tests/ui/guest/guest-limit-experience.test.ts | 67 +++++++++ tests/ui/helpers/test-fixtures.ts | 2 +- 25 files changed, 1124 insertions(+), 71 deletions(-) create mode 100644 app/Filament/Resources/EventResource/Pages/ManageWatermark.php create mode 100644 app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php create mode 100644 app/Models/WatermarkSetting.php create mode 100644 app/Support/WatermarkConfigResolver.php create mode 100644 config/watermark.php create mode 100644 database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php create mode 100644 database/migrations/2025_12_01_000900_create_watermark_settings_table.php create mode 100644 resources/lang/de/filament-watermark.php create mode 100644 resources/lang/en/filament-watermark.php create mode 100644 resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php create mode 100644 resources/views/filament/super-admin/pages/watermark-settings-page.blade.php create mode 100644 tests/ui/admin/event-addon-upgrade.test.ts diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 597c2f7..1a50549 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -258,6 +258,7 @@ class EventResource extends Resource 'create' => Pages\CreateEvent::route('/create'), 'view' => Pages\ViewEvent::route('/{record}'), 'edit' => Pages\EditEvent::route('/{record}/edit'), + 'watermark' => Pages\ManageWatermark::route('/{record}/watermark'), ]; } } diff --git a/app/Filament/Resources/EventResource/Pages/ManageWatermark.php b/app/Filament/Resources/EventResource/Pages/ManageWatermark.php new file mode 100644 index 0000000..b6a9280 --- /dev/null +++ b/app/Filament/Resources/EventResource/Pages/ManageWatermark.php @@ -0,0 +1,141 @@ +record; + $settings = $event->settings ?? []; + $watermark = Arr::get($settings, 'watermark', []); + + $this->watermark_mode = $watermark['mode'] ?? 'base'; + $this->watermark_asset = $watermark['asset'] ?? null; + $this->watermark_position = $watermark['position'] ?? 'bottom-right'; + $this->watermark_opacity = (float) ($watermark['opacity'] ?? 0.25); + $this->watermark_scale = (float) ($watermark['scale'] ?? 0.2); + $this->watermark_padding = (int) ($watermark['padding'] ?? 16); + $this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false); + } + + protected function getForms(): array + { + return [ + 'form' => $this->form( + $this->makeForm() + ->schema([ + Forms\Components\Fieldset::make(__('filament-watermark.heading')) + ->schema([ + Forms\Components\Select::make('watermark_mode') + ->label(__('filament-watermark.mode.label')) + ->options([ + 'base' => __('filament-watermark.mode.base'), + 'custom' => __('filament-watermark.mode.custom'), + 'off' => __('filament-watermark.mode.off'), + ]) + ->required(), + Forms\Components\FileUpload::make('watermark_asset') + ->label(__('filament-watermark.asset')) + ->disk('public') + ->directory('branding') + ->preserveFilenames() + ->image() + ->visible(fn (callable $get) => $get('watermark_mode') === 'custom'), + Forms\Components\Select::make('watermark_position') + ->label(__('filament-watermark.position')) + ->options([ + 'top-left' => 'Top Left', + 'top-right' => 'Top Right', + 'bottom-left' => 'Bottom Left', + 'bottom-right' => 'Bottom Right', + 'center' => 'Center', + ]) + ->required(), + Forms\Components\TextInput::make('watermark_opacity') + ->label(__('filament-watermark.opacity')) + ->numeric() + ->minValue(0) + ->maxValue(1) + ->step(0.05) + ->required(), + Forms\Components\TextInput::make('watermark_scale') + ->label(__('filament-watermark.scale')) + ->numeric() + ->minValue(0.05) + ->maxValue(1) + ->step(0.05) + ->required(), + Forms\Components\TextInput::make('watermark_padding') + ->label(__('filament-watermark.padding')) + ->numeric() + ->minValue(0) + ->required(), + Forms\Components\Toggle::make('serve_originals') + ->label(__('filament-watermark.serve_originals')) + ->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.') + ->default(false), + ]) + ->columns(2), + ]) + ), + ]; + } + + public function save(): void + { + $event = $this->record; + $package = $event->eventPackage?->package; + + $brandingAllowed = $package?->branding_allowed === true; + $policy = $package?->watermark_allowed === false ? 'none' : 'basic'; + + if (! $brandingAllowed && $this->watermark_mode === 'custom') { + Notification::make()->title('Branding nicht erlaubt für dieses Paket')->danger()->send(); + + return; + } + + if ($policy === 'basic' && $this->watermark_mode === 'off') { + Notification::make()->title('Kein Wasserzeichen ist in diesem Paket nicht erlaubt')->danger()->send(); + + return; + } + + $settings = $event->settings ?? []; + $settings['watermark'] = [ + 'mode' => $this->watermark_mode, + 'asset' => $this->watermark_asset, + 'position' => $this->watermark_position, + 'opacity' => (float) $this->watermark_opacity, + 'scale' => (float) $this->watermark_scale, + 'padding' => (int) $this->watermark_padding, + ]; + $settings['watermark_serve_originals'] = $this->serve_originals; + + $event->forceFill(['settings' => $settings])->save(); + + Notification::make() + ->title(__('filament-watermark.saved')) + ->success() + ->send(); + } +} diff --git a/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php new file mode 100644 index 0000000..e9e7e60 --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php @@ -0,0 +1,103 @@ +first(); + + if ($settings) { + $this->asset = $settings->asset; + $this->position = $settings->position; + $this->opacity = (float) $settings->opacity; + $this->scale = (float) $settings->scale; + $this->padding = (int) $settings->padding; + } + } + + public function form(Form $form): Form + { + return $form->schema([ + Forms\Components\FileUpload::make('asset') + ->label('Basis-Wasserzeichen (PNG/SVG empfohlen)') + ->disk('public') + ->directory('branding') + ->preserveFilenames() + ->image() + ->required(), + Forms\Components\Select::make('position') + ->label('Position') + ->options([ + 'top-left' => 'Oben links', + 'top-right' => 'Oben rechts', + 'bottom-left' => 'Unten links', + 'bottom-right' => 'Unten rechts', + 'center' => 'Zentriert', + ]) + ->required(), + Forms\Components\TextInput::make('opacity') + ->label('Opacity (0–1)') + ->numeric() + ->minValue(0) + ->maxValue(1) + ->step(0.05) + ->default(0.25) + ->required(), + Forms\Components\TextInput::make('scale') + ->label('Skalierung (0–1, relativ zur Bildbreite)') + ->numeric() + ->minValue(0.05) + ->maxValue(1) + ->step(0.05) + ->default(0.2) + ->required(), + Forms\Components\TextInput::make('padding') + ->label('Padding (px)') + ->numeric() + ->minValue(0) + ->default(16) + ->required(), + ])->columns(2); + } + + public function save(): void + { + $this->validate(); + + $settings = WatermarkSetting::query()->firstOrNew([]); + $settings->asset = $this->asset; + $settings->position = $this->position; + $settings->opacity = $this->opacity; + $settings->scale = $this->scale; + $settings->padding = $this->padding; + $settings->save(); + + Notification::make() + ->title('Wasserzeichen aktualisiert') + ->success() + ->send(); + } +} diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 5938110..f9c0e4a 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -23,6 +23,7 @@ use App\Services\PushSubscriptionService; use App\Services\Storage\EventStorageManager; use App\Support\ApiError; use App\Support\ImageHelper; +use App\Support\WatermarkConfigResolver; use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; @@ -877,14 +878,32 @@ class EventPublicController extends BaseController return $fallback; } + private function determineBrandingAllowed(Event $event): bool + { + return WatermarkConfigResolver::determineBrandingAllowed($event); + } + + private function determineWatermarkPolicy(Event $event): string + { + return WatermarkConfigResolver::determinePolicy($event); + } + + private function resolveWatermarkConfig(Event $event): array + { + return WatermarkConfigResolver::resolve($event); + } + private function buildGalleryBranding(Event $event): array { $defaultPrimary = '#f43f5e'; $defaultSecondary = '#fb7185'; $defaultBackground = '#ffffff'; - $eventBranding = Arr::get($event->settings, 'branding', []); - $tenantBranding = Arr::get($event->tenant?->settings, 'branding', []); + $event->loadMissing('eventPackage.package', 'tenant'); + $brandingAllowed = $this->determineBrandingAllowed($event); + + $eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : []; + $tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : []; return [ 'primary_color' => Arr::get($eventBranding, 'primary_color') @@ -1239,6 +1258,7 @@ class EventPublicController extends BaseController $variantPreference = $variant === 'thumbnail' ? ['thumbnail', 'original'] : ['original']; + $preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false); return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline'); } @@ -1360,8 +1380,11 @@ class EventPublicController extends BaseController $branding = $this->buildGalleryBranding($event); $fontFamily = Arr::get($event->settings, 'branding.font_family') ?? Arr::get($event->tenant?->settings, 'branding.font_family'); - $logoUrl = Arr::get($event->settings, 'branding.logo_url') - ?? Arr::get($event->tenant?->settings, 'branding.logo_url'); + $brandingAllowed = $this->determineBrandingAllowed($event); + $logoUrl = $brandingAllowed + ? (Arr::get($event->settings, 'branding.logo_url') + ?? Arr::get($event->tenant?->settings, 'branding.logo_url')) + : null; if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); @@ -1430,6 +1453,9 @@ class EventPublicController extends BaseController $package = $eventPackage->package; $summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage); + $watermarkPolicy = $this->determineWatermarkPolicy($event); + $brandingAllowed = $this->determineBrandingAllowed($event); + $watermark = $this->resolveWatermarkConfig($event); return response()->json([ 'id' => $eventPackage->id, @@ -1441,18 +1467,23 @@ class EventPublicController extends BaseController 'max_photos' => $package?->max_photos, 'max_guests' => $package?->max_guests, 'gallery_days' => $package?->gallery_days, + 'watermark_policy' => $watermarkPolicy, + 'branding_allowed' => $brandingAllowed, ], 'used_photos' => (int) $eventPackage->used_photos, 'used_guests' => (int) $eventPackage->used_guests, 'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(), 'limits' => $summary, + 'watermark' => $watermark, ])->header('Cache-Control', 'no-store'); } private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition) { + $preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false); + foreach ($variantPreference as $variant) { - [$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant); + [$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant, $preferOriginals); if (! $path) { continue; @@ -1550,18 +1581,24 @@ class EventPublicController extends BaseController ); } - private function resolvePhotoVariant(Photo $record, string $variant): array + private function resolvePhotoVariant(Photo $record, string $variant, bool $preferOriginals = false): array { if ($variant === 'thumbnail') { $asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first(); + $watermarked = $preferOriginals + ? null + : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_thumbnail')->first(); $disk = $asset?->disk ?? $record->mediaAsset?->disk; - $path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path); - $mime = $asset?->mime_type ?? 'image/jpeg'; + $path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path); + $mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg'; } else { - $asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first(); + $watermarked = $preferOriginals + ? null + : EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked')->first(); + $asset = $record->mediaAsset ?? $watermarked ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first(); $disk = $asset?->disk ?? $record->mediaAsset?->disk; - $path = $asset?->path ?? ($record->file_path ?? null); - $mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg'); + $path = $watermarked?->path ?? $asset?->path ?? ($record->file_path ?? null); + $mime = $watermarked?->mime_type ?? $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg'); } return [ @@ -2363,7 +2400,7 @@ class EventPublicController extends BaseController $file = $validated['photo']; $disk = $this->eventStorageManager->getHotDiskForEvent($eventModel); $path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file); - $url = $this->resolveDiskUrl($disk, $path); + $watermarkConfig = WatermarkConfigResolver::resolve($eventModel); // Generate thumbnail (JPEG) under photos/thumbs $baseName = pathinfo($path, PATHINFO_FILENAME); @@ -2371,7 +2408,27 @@ class EventPublicController extends BaseController $thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82); $thumbUrl = $thumbPath ? $this->resolveDiskUrl($disk, $thumbPath) - : $url; + : $this->resolveDiskUrl($disk, $path); + + // Create watermarked copies (non-destructive). + $watermarkedPath = $path; + $watermarkedThumb = $thumbPath ?: $path; + if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) { + $watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path; + if ($thumbPath) { + $watermarkedThumb = ImageHelper::copyWithWatermark( + $disk, + $thumbPath, + "events/{$eventId}/photos/watermarked/{$baseName}_thumb.jpg", + $watermarkConfig + ) ?? $thumbPath; + } else { + $watermarkedThumb = $watermarkedPath; + } + } + + $url = $this->resolveDiskUrl($disk, $watermarkedPath); + $thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb); $photoId = DB::table('photos')->insertGetId([ 'event_id' => $eventId, @@ -2390,16 +2447,35 @@ class EventPublicController extends BaseController 'updated_at' => now(), ]); + $storedPath = Storage::disk($disk)->path($path); + $storedSize = Storage::disk($disk)->exists($path) ? Storage::disk($disk)->size($path) : $file->getSize(); $asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getClientMimeType(), - 'size_bytes' => $file->getSize(), - 'checksum' => hash_file('sha256', $file->getRealPath()), + 'size_bytes' => $storedSize, + 'checksum' => file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath()), 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photoId, ]); + $watermarkedAsset = null; + if ($watermarkedPath !== $path) { + $watermarkedAsset = $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPath, [ + 'variant' => 'watermarked', + 'mime_type' => $file->getClientMimeType(), + 'size_bytes' => Storage::disk($disk)->exists($watermarkedPath) + ? Storage::disk($disk)->size($watermarkedPath) + : null, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photoId, + 'meta' => [ + 'source_variant_id' => $asset->id, + ], + ]); + } + if ($thumbPath) { $this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [ 'variant' => 'thumbnail', @@ -2415,6 +2491,21 @@ class EventPublicController extends BaseController ], ]); } + if ($watermarkedThumb !== $thumbPath) { + $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [ + 'variant' => 'watermarked_thumbnail', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photoId, + 'size_bytes' => Storage::disk($disk)->exists($watermarkedThumb) + ? Storage::disk($disk)->size($watermarkedThumb) + : null, + 'meta' => [ + 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id, + ], + ]); + } DB::table('photos') ->where('id', $photoId) diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 99e2f11..2c20d61 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker; use App\Services\Storage\EventStorageManager; use App\Support\ApiError; use App\Support\ImageHelper; +use App\Support\WatermarkConfigResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -153,13 +154,38 @@ class PhotoController extends Controller $thumbnailPath = $thumbnailRelative; } + // Apply watermark policy (in-place) to original and thumbnail where applicable. + $watermarkConfig = WatermarkConfigResolver::resolve($event); + if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset'])) { + ImageHelper::applyWatermarkOnDisk($disk, $path, $watermarkConfig); + if ($thumbnailRelative) { + ImageHelper::applyWatermarkOnDisk($disk, $thumbnailPath, $watermarkConfig); + } + } + + $watermarkConfig = WatermarkConfigResolver::resolve($event); + $watermarkedPath = $path; + $watermarkedThumb = $thumbnailPath; + + if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) { + $watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventSlug}/watermarked/{$filename}", $watermarkConfig) ?? $path; + if ($thumbnailRelative) { + $watermarkedThumb = ImageHelper::copyWithWatermark( + $disk, + $thumbnailPath, + "events/{$eventSlug}/watermarked/thumbnails/{$filename}", + $watermarkConfig + ) ?? $thumbnailPath; + } + } + $photoAttributes = [ 'event_id' => $event->id, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), - 'file_path' => $path, - 'thumbnail_path' => $thumbnailPath, + 'file_path' => $watermarkedPath, + 'thumbnail_path' => $watermarkedThumb, 'width' => null, // Filled below 'height' => null, 'status' => 'pending', // Requires moderation @@ -179,17 +205,36 @@ class PhotoController extends Controller $photo = Photo::create($photoAttributes); // Record primary asset metadata - $checksum = hash_file('sha256', $file->getRealPath()); + $storedPath = Storage::disk($disk)->path($path); + $checksum = file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath()); + $storedSize = Storage::disk($disk)->exists($path) ? Storage::disk($disk)->size($path) : $file->getSize(); $asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [ 'variant' => 'original', 'mime_type' => $file->getMimeType(), - 'size_bytes' => $file->getSize(), + 'size_bytes' => $storedSize, 'checksum' => $checksum, 'status' => 'hot', 'processed_at' => now(), 'photo_id' => $photo->id, ]); + $watermarkedAsset = null; + if ($watermarkedPath !== $path) { + $watermarkedAsset = $this->eventStorageManager->recordAsset($event, $disk, $watermarkedPath, [ + 'variant' => 'watermarked', + 'mime_type' => $file->getMimeType(), + 'size_bytes' => Storage::disk($disk)->exists($watermarkedPath) + ? Storage::disk($disk)->size($watermarkedPath) + : null, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + 'meta' => [ + 'source_variant_id' => $asset->id, + ], + ]); + } + if ($thumbnailRelative) { $this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [ 'variant' => 'thumbnail', @@ -205,6 +250,21 @@ class PhotoController extends Controller ], ]); } + if ($watermarkedThumb !== $thumbnailPath) { + $this->eventStorageManager->recordAsset($event, $disk, $watermarkedThumb, [ + 'variant' => 'watermarked_thumbnail', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + 'size_bytes' => Storage::disk($disk)->exists($watermarkedThumb) + ? Storage::disk($disk)->size($watermarkedThumb) + : null, + 'meta' => [ + 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id, + ], + ]); + } $photo->update(['media_asset_id' => $asset->id]); diff --git a/app/Models/WatermarkSetting.php b/app/Models/WatermarkSetting.php new file mode 100644 index 0000000..673237e --- /dev/null +++ b/app/Models/WatermarkSetting.php @@ -0,0 +1,19 @@ +pages([ + Pages\Dashboard::class, + \App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class, + ]) ->authGuard('web'); // SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation diff --git a/app/Services/Photobooth/PhotoboothIngestService.php b/app/Services/Photobooth/PhotoboothIngestService.php index 815a3ce..ebb5e61 100644 --- a/app/Services/Photobooth/PhotoboothIngestService.php +++ b/app/Services/Photobooth/PhotoboothIngestService.php @@ -140,6 +140,29 @@ class PhotoboothIngestService $thumbnailRelative = ImageHelper::makeThumbnailOnDisk($destinationDisk, $destinationPath, $thumbnailPath, 640, 82); $thumbnailToStore = $thumbnailRelative ?? $destinationPath; + // Create watermarked copies (non-destructive). + $watermarkConfig = \App\Support\WatermarkConfigResolver::resolve($event); + $watermarkedPath = $destinationPath; + $watermarkedThumb = $thumbnailToStore; + if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) { + $watermarkedPath = ImageHelper::copyWithWatermark( + $destinationDisk, + $destinationPath, + "events/{$eventSlug}/watermarked/{$filename}", + $watermarkConfig + ) ?? $destinationPath; + if ($thumbnailRelative) { + $watermarkedThumb = ImageHelper::copyWithWatermark( + $destinationDisk, + $thumbnailToStore, + "events/{$eventSlug}/watermarked/thumbnails/{$filename}", + $watermarkConfig + ) ?? $thumbnailToStore; + } else { + $watermarkedThumb = $watermarkedPath; + } + } + $size = Storage::disk($destinationDisk)->size($destinationPath); $mimeType = Storage::disk($destinationDisk)->mimeType($destinationPath) ?? 'image/jpeg'; $originalName = basename($file); @@ -165,8 +188,8 @@ class PhotoboothIngestService 'original_name' => $originalName, 'mime_type' => $mimeType, 'size' => $size, - 'file_path' => $destinationPath, - 'thumbnail_path' => $thumbnailToStore, + 'file_path' => $watermarkedPath, + 'thumbnail_path' => $watermarkedThumb, 'status' => 'pending', 'guest_name' => Photo::SOURCE_PHOTOBOOTH, 'ingest_source' => Photo::SOURCE_PHOTOBOOTH, @@ -192,6 +215,23 @@ class PhotoboothIngestService 'photo_id' => $photo->id, ]); + $watermarkedAsset = null; + if ($watermarkedPath !== $destinationPath) { + $watermarkedAsset = $this->storageManager->recordAsset($event, $destinationDisk, $watermarkedPath, [ + 'variant' => 'watermarked', + 'mime_type' => $mimeType, + 'size_bytes' => Storage::disk($destinationDisk)->exists($watermarkedPath) + ? Storage::disk($destinationDisk)->size($watermarkedPath) + : null, + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + 'meta' => [ + 'source_variant_id' => $asset->id, + ], + ]); + } + if ($thumbnailRelative) { $this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [ 'variant' => 'thumbnail', @@ -201,6 +241,21 @@ class PhotoboothIngestService 'photo_id' => $photo->id, ]); } + if ($watermarkedThumb !== $thumbnailToStore) { + $this->storageManager->recordAsset($event, $destinationDisk, $watermarkedThumb, [ + 'variant' => 'watermarked_thumbnail', + 'mime_type' => 'image/jpeg', + 'status' => 'hot', + 'processed_at' => now(), + 'photo_id' => $photo->id, + 'size_bytes' => Storage::disk($destinationDisk)->exists($watermarkedThumb) + ? Storage::disk($destinationDisk)->size($watermarkedThumb) + : null, + 'meta' => [ + 'source_variant_id' => $watermarkedAsset?->id ?? $asset->id, + ], + ]); + } $photo->update(['media_asset_id' => $asset->id]); diff --git a/app/Support/Concerns/PresentsPackages.php b/app/Support/Concerns/PresentsPackages.php index 0be2854..365a078 100644 --- a/app/Support/Concerns/PresentsPackages.php +++ b/app/Support/Concerns/PresentsPackages.php @@ -13,6 +13,7 @@ trait PresentsPackages $packageArray = $package->toArray(); $features = $packageArray['features'] ?? []; $features = $this->normaliseFeatures($features); + $watermarkPolicy = $package->watermark_allowed === false ? 'none' : 'basic'; $locale = app()->getLocale(); $name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale); @@ -50,6 +51,7 @@ trait PresentsPackages 'gallery_duration_label' => $galleryDuration, 'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null), 'features' => $features, + 'watermark_policy' => $watermarkPolicy, 'limits' => $package->limits, 'max_photos' => $package->max_photos, 'max_guests' => $package->max_guests, diff --git a/app/Support/ImageHelper.php b/app/Support/ImageHelper.php index 444bbdd..98037f8 100644 --- a/app/Support/ImageHelper.php +++ b/app/Support/ImageHelper.php @@ -65,5 +65,122 @@ class ImageHelper return null; } } -} + /** + * Apply a watermark in-place on the given disk/path. + * Expects $config with keys: asset (path), position, opacity (0..1), scale (0..1), padding (px). + */ + public static function applyWatermarkOnDisk(string $disk, string $path, array $config): bool + { + $fullSrc = Storage::disk($disk)->path($path); + if (! file_exists($fullSrc)) { + return false; + } + + $assetPath = $config['asset'] ?? null; + if (! $assetPath) { + return false; + } + + $assetFull = null; + if (Storage::disk('public')->exists($assetPath)) { + $assetFull = Storage::disk('public')->path($assetPath); + } elseif (file_exists($assetPath)) { + $assetFull = $assetPath; + } + + if (! $assetFull || ! file_exists($assetFull)) { + return false; + } + + try { + $data = @file_get_contents($fullSrc); + $src = $data !== false ? @imagecreatefromstring($data) : null; + if (! $src) { + return false; + } + + $wmData = @file_get_contents($assetFull); + $watermark = $wmData !== false ? @imagecreatefromstring($wmData) : null; + if (! $watermark) { + imagedestroy($src); + + return false; + } + + imagesavealpha($src, true); + imagesavealpha($watermark, true); + + $srcW = imagesx($src); + $srcH = imagesy($src); + $wmW = imagesx($watermark); + $wmH = imagesy($watermark); + + if ($srcW <= 0 || $srcH <= 0 || $wmW <= 0 || $wmH <= 0) { + imagedestroy($src); + imagedestroy($watermark); + + return false; + } + + $scale = max(0.05, min(1.0, (float) ($config['scale'] ?? 0.2))); + $targetW = max(1, (int) round($srcW * $scale)); + $targetH = max(1, (int) round($wmH * ($targetW / $wmW))); + + $resized = imagecreatetruecolor($targetW, $targetH); + imagealphablending($resized, false); + imagesavealpha($resized, true); + imagecopyresampled($resized, $watermark, 0, 0, 0, 0, $targetW, $targetH, $wmW, $wmH); + imagedestroy($watermark); + + $padding = max(0, (int) ($config['padding'] ?? 0)); + $position = $config['position'] ?? 'bottom-right'; + $x = $padding; + $y = $padding; + + if ($position === 'top-right') { + $x = max(0, $srcW - $targetW - $padding); + } elseif ($position === 'bottom-left') { + $y = max(0, $srcH - $targetH - $padding); + } elseif ($position === 'bottom-right') { + $x = max(0, $srcW - $targetW - $padding); + $y = max(0, $srcH - $targetH - $padding); + } elseif ($position === 'center') { + $x = (int) max(0, ($srcW - $targetW) / 2); + $y = (int) max(0, ($srcH - $targetH) / 2); + } + + $opacity = max(0.0, min(1.0, (float) ($config['opacity'] ?? 0.25))); + $mergeOpacity = (int) round($opacity * 100); // imagecopymerge uses 0-100 + + imagealphablending($src, true); + imagecopymerge($src, $resized, $x, $y, 0, 0, $targetW, $targetH, $mergeOpacity); + imagedestroy($resized); + + // Overwrite original (respect mime: always JPEG for compatibility) + @imagejpeg($src, $fullSrc, 90); + imagedestroy($src); + + return true; + } catch (\Throwable $e) { + return false; + } + } + + /** + * Copy a source to destination and apply watermark there. + */ + public static function copyWithWatermark(string $disk, string $sourcePath, string $destPath, array $config): ?string + { + if (! Storage::disk($disk)->exists($sourcePath)) { + return null; + } + + Storage::disk($disk)->makeDirectory(dirname($destPath)); + Storage::disk($disk)->copy($sourcePath, $destPath); + + $applied = self::applyWatermarkOnDisk($disk, $destPath, $config); + + return $applied ? $destPath : null; + } +} diff --git a/app/Support/WatermarkConfigResolver.php b/app/Support/WatermarkConfigResolver.php new file mode 100644 index 0000000..0ed9490 --- /dev/null +++ b/app/Support/WatermarkConfigResolver.php @@ -0,0 +1,90 @@ +loadMissing('eventPackage.package'); + + return $event->eventPackage?->package?->branding_allowed === true; + } + + public static function determinePolicy(Event $event): string + { + $event->loadMissing('eventPackage.package'); + + return $event->eventPackage?->package?->watermark_allowed === false ? 'none' : 'basic'; + } + + /** + * @return array{type:string, policy:string, asset?:string, position?:string, opacity?:float, scale?:float, padding?:int} + */ + public static function resolve(Event $event): array + { + $policy = self::determinePolicy($event); + + if ($policy === 'none') { + return [ + 'type' => 'none', + 'policy' => $policy, + ]; + } + + $baseSetting = WatermarkSetting::query()->first(); + $base = [ + 'asset' => $baseSetting?->asset ?? config('watermark.base.asset', 'branding/fotospiel-watermark.png'), + 'position' => $baseSetting?->position ?? config('watermark.base.position', 'bottom-right'), + 'opacity' => $baseSetting?->opacity ?? config('watermark.base.opacity', 0.25), + 'scale' => $baseSetting?->scale ?? config('watermark.base.scale', 0.2), + 'padding' => $baseSetting?->padding ?? config('watermark.base.padding', 16), + ]; + + $event->loadMissing('eventPackage.package', 'tenant'); + $brandingAllowed = self::determineBrandingAllowed($event); + $eventWatermark = Arr::get($event->settings, 'watermark', []); + $tenantWatermark = Arr::get($event->tenant?->settings, 'watermark', []); + $serveOriginals = (bool) Arr::get($event->settings, 'watermark_serve_originals', false); + + $mode = $brandingAllowed + ? ($eventWatermark['mode'] ?? $tenantWatermark['mode'] ?? 'base') + : 'base'; + + if ($mode === 'off' && $policy === 'basic') { + $mode = 'base'; + } + + if ($mode === 'off') { + return [ + 'type' => 'none', + 'policy' => $policy, + ]; + } + + $source = $mode === 'custom' && $brandingAllowed ? ($eventWatermark ?: $tenantWatermark) : []; + + $asset = $source['asset'] ?? $base['asset'] ?? null; + $position = $source['position'] ?? $base['position'] ?? 'bottom-right'; + $opacity = (float) ($source['opacity'] ?? $base['opacity'] ?? 0.25); + $scale = (float) ($source['scale'] ?? $base['scale'] ?? 0.2); + $padding = (int) ($source['padding'] ?? $base['padding'] ?? 16); + + $clamp = static fn (float $value, float $min, float $max) => max($min, min($max, $value)); + + return [ + 'type' => $mode === 'custom' && $brandingAllowed ? 'custom' : 'base', + 'policy' => $policy, + 'asset' => $asset, + 'position' => $position, + 'opacity' => $clamp($opacity, 0.0, 1.0), + 'scale' => $clamp($scale, 0.05, 1.0), + 'padding' => max(0, $padding), + 'serve_originals' => $serveOriginals, + ]; + } +} +use App\Models\WatermarkSetting; diff --git a/config/watermark.php b/config/watermark.php new file mode 100644 index 0000000..e15d0a6 --- /dev/null +++ b/config/watermark.php @@ -0,0 +1,20 @@ + [ + 'asset' => 'branding/fotospiel-watermark.png', + 'position' => 'bottom-right', // top-left, top-right, bottom-left, bottom-right, center + 'opacity' => 0.25, // 0..1 + 'scale' => 0.2, // relative zur Bildbreite/-höhe + 'padding' => 16, // px vom Rand + ], +]; diff --git a/database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php b/database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php new file mode 100644 index 0000000..f2a4504 --- /dev/null +++ b/database/migrations/2025_11_22_133343_add_watermark_fields_to_events.php @@ -0,0 +1,33 @@ +json('settings')->nullable()->after('max_participants'); + } + + $table->boolean('watermark_serve_originals')->default(false)->after('photobooth_metadata'); + $table->json('watermark_settings')->nullable()->after('photobooth_metadata'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + $table->dropColumn(['watermark_settings', 'watermark_serve_originals']); + }); + } +}; diff --git a/database/migrations/2025_12_01_000900_create_watermark_settings_table.php b/database/migrations/2025_12_01_000900_create_watermark_settings_table.php new file mode 100644 index 0000000..c7dd547 --- /dev/null +++ b/database/migrations/2025_12_01_000900_create_watermark_settings_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('asset')->nullable(); + $table->string('position')->default('bottom-right'); + $table->float('opacity')->default(0.25); + $table->float('scale')->default(0.2); + $table->unsignedInteger('padding')->default(16); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('watermark_settings'); + } +}; diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index 34c5464..d0c1dda 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -30,7 +30,7 @@ class PackageSeeder extends Seeder 'max_tasks' => 30, 'watermark_allowed' => true, 'branding_allowed' => false, - 'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'], + 'features' => ['basic_uploads', 'custom_tasks'], 'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej', 'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27', 'description' => << 100, 'watermark_allowed' => false, 'branding_allowed' => true, - 'features' => ['basic_uploads', 'unlimited_sharing', 'no_watermark', 'custom_branding', 'custom_tasks'], + 'features' => ['basic_uploads', 'custom_branding', 'custom_tasks'], 'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j', 'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc', 'description' => << 200, 'watermark_allowed' => false, 'branding_allowed' => true, - 'features' => ['basic_uploads', 'unlimited_sharing', 'no_watermark', 'custom_branding', 'advanced_analytics', 'live_slideshow', 'priority_support'], + 'features' => ['basic_uploads', 'custom_branding', 'advanced_analytics', 'live_slideshow', 'priority_support'], 'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s', 'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy', 'description' => << null, 'watermark_allowed' => true, 'branding_allowed' => false, - 'features' => ['basic_uploads', 'unlimited_sharing', 'no_watermark', 'custom_branding', 'custom_tasks'], + 'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks'], 'paddle_product_id' => 'pro_01k8jcxsmxrs45v1qvtpsepn13', 'paddle_price_id' => 'pri_01k8jcxswh74jv8vy5q7a4h70p', 'description' => null, diff --git a/database/seeders/_DemoLifecycleSeeder.php b/database/seeders/_DemoLifecycleSeeder.php index 284c2e3..d8e2017 100644 --- a/database/seeders/_DemoLifecycleSeeder.php +++ b/database/seeders/_DemoLifecycleSeeder.php @@ -40,10 +40,11 @@ class DemoLifecycleSeeder extends Seeder 'max_photos' => 1500, 'max_guests' => 400, 'gallery_days' => 60, + 'watermark_allowed' => false, + 'branding_allowed' => false, 'features' => [ 'basic_uploads' => true, 'unlimited_sharing' => true, - 'no_watermark' => true, 'custom_tasks' => true, ], ] @@ -59,10 +60,11 @@ class DemoLifecycleSeeder extends Seeder 'max_photos' => 5000, 'max_guests' => 1000, 'gallery_days' => 180, + 'watermark_allowed' => false, + 'branding_allowed' => true, 'features' => [ 'basic_uploads' => true, 'unlimited_sharing' => true, - 'no_watermark' => true, 'custom_branding' => true, 'custom_tasks' => true, ], @@ -77,6 +79,8 @@ class DemoLifecycleSeeder extends Seeder 'name_translations' => ['de' => 'Studio Jahrespaket', 'en' => 'Studio Annual'], 'price' => 1299, 'max_events_per_year' => 24, + 'watermark_allowed' => false, + 'branding_allowed' => true, 'features' => [ 'custom_branding' => true, 'unlimited_sharing' => true, diff --git a/playwright.config.ts b/playwright.config.ts index 82655bd..fb96743 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: 'http://localhost:8000', + baseURL: 'http://fotospiel-app.test', trace: 'on-first-retry', headless: true, screenshot: 'only-on-failure', diff --git a/resources/js/pages/marketing/Packages.tsx b/resources/js/pages/marketing/Packages.tsx index 8067d58..c5475b5 100644 --- a/resources/js/pages/marketing/Packages.tsx +++ b/resources/js/pages/marketing/Packages.tsx @@ -14,7 +14,7 @@ import MarketingLayout from '@/layouts/mainWebsite'; import { useAnalytics } from '@/hooks/useAnalytics'; import { useCtaExperiment } from '@/hooks/useCtaExperiment'; import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes'; -import { ArrowRight, Check, Shield, Star, Sparkles } from 'lucide-react'; +import { ArrowRight, Check, Star } from 'lucide-react'; interface Package { id: number; @@ -46,6 +46,39 @@ interface PackageComparisonProps { variant: 'endcustomer' | 'reseller'; } +const buildDisplayFeatures = (pkg: Package): string[] => { + const features = [...pkg.features]; + + const removeFeature = (key: string) => { + const index = features.indexOf(key); + if (index !== -1) { + features.splice(index, 1); + } + }; + + const addFeature = (key: string) => { + if (!features.includes(key)) { + features.push(key); + } + }; + + if (pkg.watermark_allowed === false) { + removeFeature('watermark'); + addFeature('no_watermark'); + } else { + removeFeature('no_watermark'); + addFeature('watermark'); + } + + if (pkg.branding_allowed) { + addFeature('custom_branding'); + } else { + removeFeature('custom_branding'); + } + + return Array.from(new Set(features)); +}; + function PackageComparison({ packages, variant }: PackageComparisonProps) { const { t } = useTranslation('marketing'); const { t: tCommon } = useTranslation('common'); @@ -547,7 +580,9 @@ function PackageCard({ ? t('packages.badge_starter') : null; - const keyFeatures = pkg.features.slice(0, 3); + const displayFeatures = buildDisplayFeatures(pkg); + const keyFeatures = displayFeatures.slice(0, 3); + const visibleFeatures = compact ? displayFeatures.slice(0, 3) : displayFeatures.slice(0, 5); const metrics = resolvePackageMetrics(pkg, variant, t, tCommon); const metricList = compact ? ( @@ -572,45 +607,21 @@ function PackageCard({ const featureList = compact ? (
    - {keyFeatures.map((feature) => ( + {visibleFeatures.map((feature) => (
  • {t(`packages.feature_${feature}`)}
  • ))} - {pkg.watermark_allowed === false && ( -
  • - - {t('packages.no_watermark')} -
  • - )} - {pkg.branding_allowed && ( -
  • - - {t('packages.custom_branding')} -
  • - )}
) : (
    - {keyFeatures.map((feature) => ( + {visibleFeatures.map((feature) => (
  • {t(`packages.feature_${feature}`)}
  • ))} - {pkg.watermark_allowed === false && ( -
  • - - {t('packages.no_watermark')} -
  • - )} - {pkg.branding_allowed && ( -
  • - - {t('packages.custom_branding')} -
  • - )}
); @@ -703,6 +714,10 @@ const PackageDetailGrid: React.FC = ({ close, }) => { const metrics = resolvePackageMetrics(packageData, variant, t, tCommon); + const highlightFeatures = useMemo( + () => buildDisplayFeatures(packageData).slice(0, 5), + [packageData], + ); return (
@@ -757,24 +772,12 @@ const PackageDetailGrid: React.FC = ({

{t('packages.feature_highlights')}

    - {packageData.features.slice(0, 5).map((feature) => ( + {highlightFeatures.map((feature) => (
  • {t(`packages.feature_${feature}`)}
  • ))} - {packageData.watermark_allowed === false && ( -
  • - - {t('packages.no_watermark')} -
  • - )} - {packageData.branding_allowed && ( -
  • - - {t('packages.custom_branding')} -
  • - )}
diff --git a/resources/lang/de/filament-watermark.php b/resources/lang/de/filament-watermark.php new file mode 100644 index 0000000..887425f --- /dev/null +++ b/resources/lang/de/filament-watermark.php @@ -0,0 +1,20 @@ + 'Wasserzeichen', + 'description' => 'Branding- und Wasserzeichen-Optionen für dieses Event.', + 'mode' => [ + 'label' => 'Wasserzeichen-Modus', + 'base' => 'Standard-Fotospiel-Wasserzeichen', + 'custom' => 'Eigenes Wasserzeichen (Logo)', + 'off' => 'Kein Wasserzeichen', + ], + 'asset' => 'Logo/Wasserzeichen-Datei', + 'position' => 'Position', + 'opacity' => 'Deckkraft (0-1)', + 'scale' => 'Skalierung (0-1)', + 'padding' => 'Abstand (px)', + 'serve_originals' => 'Originale statt watermarked ausliefern (nur Admin/Owner)', + 'save' => 'Speichern', + 'saved' => 'Branding/Wasserzeichen gespeichert.', +]; diff --git a/resources/lang/en/filament-watermark.php b/resources/lang/en/filament-watermark.php new file mode 100644 index 0000000..0edc1f1 --- /dev/null +++ b/resources/lang/en/filament-watermark.php @@ -0,0 +1,20 @@ + 'Watermark', + 'description' => 'Branding and watermark options for this event.', + 'mode' => [ + 'label' => 'Watermark mode', + 'base' => 'Standard Fotospiel watermark', + 'custom' => 'Custom watermark (logo)', + 'off' => 'No watermark', + ], + 'asset' => 'Logo/Watermark file', + 'position' => 'Position', + 'opacity' => 'Opacity (0-1)', + 'scale' => 'Scale (0-1)', + 'padding' => 'Padding (px)', + 'serve_originals' => 'Serve originals instead of watermarked (admin/owner only)', + 'save' => 'Save', + 'saved' => 'Branding/Watermark saved.', +]; diff --git a/resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php b/resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php new file mode 100644 index 0000000..effd5e6 --- /dev/null +++ b/resources/views/filament/resources/event-resource/pages/manage-watermark.blade.php @@ -0,0 +1,13 @@ + +
+
+ {{ __('filament-watermark.description') }} +
+ {{ $this->form }} +
+ + {{ __('filament-watermark.save') }} + +
+
+
diff --git a/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php b/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php new file mode 100644 index 0000000..c68de33 --- /dev/null +++ b/resources/views/filament/super-admin/pages/watermark-settings-page.blade.php @@ -0,0 +1,22 @@ +@php +use Illuminate\Support\Facades\Storage; +@endphp + + +
+
+ {{ $this->form }} +
+ + Speichern + +
+
+ @if($asset) +
+

Aktuelles Basis-Wasserzeichen

+ Watermark +
+ @endif +
+
diff --git a/tests/ui/admin/event-addon-upgrade.test.ts b/tests/ui/admin/event-addon-upgrade.test.ts new file mode 100644 index 0000000..2fdf151 --- /dev/null +++ b/tests/ui/admin/event-addon-upgrade.test.ts @@ -0,0 +1,142 @@ +import { test, expectFixture as expect } from '../helpers/test-fixtures'; + +test.describe('Tenant admin add-on upgrades', () => { + test.beforeEach(async ({ signInTenantAdmin }) => { + await signInTenantAdmin(); + }); + + test('purchasing an add-on increases photo limits in moderation view', async ({ page }) => { + let addonPurchased = false; + + const initialLimits = { + photos: { + limit: 120, + used: 120, + remaining: 0, + percentage: 100, + state: 'limit_reached', + threshold_reached: 120, + next_threshold: null, + thresholds: [80, 95, 120], + }, + guests: null, + gallery: { + state: 'warning', + expires_at: new Date(Date.now() + 86400000).toISOString(), + days_remaining: 1, + warning_thresholds: [7, 1], + warning_triggered: 1, + warning_sent_at: null, + expired_notified_at: null, + }, + can_upload_photos: false, + can_add_guests: true, + }; + + const upgradedLimits = { + photos: { + limit: 620, + used: 120, + remaining: 500, + percentage: 19, + state: 'ok', + threshold_reached: 80, + next_threshold: 95, + thresholds: [80, 95, 120, 600], + }, + guests: null, + gallery: initialLimits.gallery, + can_upload_photos: true, + can_add_guests: true, + }; + + await page.route('**/api/v1/tenant/addons/catalog', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [ + { key: 'extra_photos_500', label: '+500 Fotos', price_id: 'pri_addon_photos', increments: { extra_photos: 500 } }, + ], + }), + }); + }); + + await page.route('**/api/v1/tenant/events/limit-event/photos', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [], + limits: addonPurchased ? upgradedLimits : initialLimits, + }), + }); + }); + + await page.route('**/api/v1/tenant/events/limit-event', async (route) => { + const timestamp = new Date().toISOString(); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + id: 101, + name: 'Limit Event', + slug: 'limit-event', + event_date: timestamp.slice(0, 10), + event_type_id: null, + event_type: null, + status: 'published', + addons: addonPurchased + ? [ + { + id: 1, + key: 'extra_photos_500', + addon_key: 'extra_photos_500', + label: '+500 Fotos', + status: 'completed', + price_id: 'pri_addon_photos', + transaction_id: 'txn_addon_1', + extra_photos: 500, + extra_guests: 0, + extra_gallery_days: 0, + purchased_at: timestamp, + metadata: { price_eur: 49 }, + }, + ] + : [], + }, + }), + }); + }); + + await page.route('**/api/v1/tenant/events/limit-event/addons/checkout', async (route) => { + addonPurchased = true; + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + checkout_url: '/event-admin/events/limit-event/photos?addon_success=1', + checkout_id: 'chk_addon_1', + expires_at: new Date(Date.now() + 600000).toISOString(), + }), + }); + }); + + await page.goto('/event-admin/events/limit-event/photos'); + + await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible(); + + const purchaseButton = page.getByRole('button', { name: /Mehr Fotos freischalten/i }); + await expect(purchaseButton).toBeVisible(); + await purchaseButton.click(); + + await expect(page).toHaveURL(/addon_success=1/); + await expect(page.getByText(/Add-on angewendet/i)).toBeVisible(); + + await expect(page.getByText(/Upload-Limit erreicht/i)).not.toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/\+500\s*Fotos/i)).toBeVisible(); + }); +}); diff --git a/tests/ui/guest/guest-limit-experience.test.ts b/tests/ui/guest/guest-limit-experience.test.ts index b00d9bb..b56a53e 100644 --- a/tests/ui/guest/guest-limit-experience.test.ts +++ b/tests/ui/guest/guest-limit-experience.test.ts @@ -206,4 +206,71 @@ test.describe('Guest PWA limit experiences', () => { await expect(page.getByText(/Galerie abgelaufen/i)).toBeVisible(); await expect(page.getByText(/Die Galerie ist abgelaufen\. Uploads sind nicht mehr möglich\./i)).toBeVisible(); }); + + test('blocks uploads and guest access once all limits are exhausted', async ({ page }) => { + await page.route(`**/api/v1/events/${EVENT_TOKEN}/package`, async (route) => { + const exhaustedAt = new Date().toISOString(); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 44, + event_id: 1, + used_photos: 120, + expires_at: exhaustedAt, + package: { + id: 90, + name: 'Starter', + max_photos: 120, + max_guests: 3, + gallery_days: 30, + }, + limits: { + photos: { + limit: 120, + used: 120, + remaining: 0, + percentage: 100, + state: 'limit_reached', + threshold_reached: 120, + next_threshold: null, + thresholds: [80, 95, 120], + }, + guests: { + limit: 3, + used: 3, + remaining: 0, + percentage: 100, + state: 'limit_reached', + threshold_reached: 3, + next_threshold: null, + thresholds: [2, 3], + }, + gallery: { + state: 'ok', + expires_at: exhaustedAt, + days_remaining: 10, + warning_thresholds: [7, 1], + warning_triggered: null, + warning_sent_at: null, + expired_notified_at: null, + }, + can_upload_photos: false, + can_add_guests: false, + }, + }), + }); + }); + + await page.goto(`/e/${EVENT_TOKEN}/upload?task=1`); + + await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /Upload/i })).toBeDisabled(); + await expect(page.getByText(/Limit erreicht/i)).toBeVisible(); + + await page.goto(`/e/${EVENT_TOKEN}/gallery`); + await expect(page.getByText(/Upload-Limit erreicht/i)).toBeVisible(); + await expect(page.getByText(/Limit erreicht/i)).toBeVisible(); + }); }); diff --git a/tests/ui/helpers/test-fixtures.ts b/tests/ui/helpers/test-fixtures.ts index b80eb14..461c9bd 100644 --- a/tests/ui/helpers/test-fixtures.ts +++ b/tests/ui/helpers/test-fixtures.ts @@ -81,7 +81,7 @@ const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.de const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!'; export const test = base.extend({ - tenantAdminCredentials: async (_context, use) => { + tenantAdminCredentials: async ({}, use) => { if (!tenantAdminEmail || !tenantAdminPassword) { await use(null); return;