event photo wasserzeichen umgesetzt. Event admins können eigene einsetzen (als branding) falls das Paket es erlaubt. der Super Admin kann für die günstigen Pakete eigene Wasserzeichen erzwingen
This commit is contained in:
@@ -258,6 +258,7 @@ class EventResource extends Resource
|
|||||||
'create' => Pages\CreateEvent::route('/create'),
|
'create' => Pages\CreateEvent::route('/create'),
|
||||||
'view' => Pages\ViewEvent::route('/{record}'),
|
'view' => Pages\ViewEvent::route('/{record}'),
|
||||||
'edit' => Pages\EditEvent::route('/{record}/edit'),
|
'edit' => Pages\EditEvent::route('/{record}/edit'),
|
||||||
|
'watermark' => Pages\ManageWatermark::route('/{record}/watermark'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
app/Filament/Resources/EventResource/Pages/ManageWatermark.php
Normal file
141
app/Filament/Resources/EventResource/Pages/ManageWatermark.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\EventResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\EventResource;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\Page;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class ManageWatermark extends Page
|
||||||
|
{
|
||||||
|
protected static string $resource = EventResource::class;
|
||||||
|
|
||||||
|
protected string $view = 'filament.resources.event-resource.pages.manage-watermark';
|
||||||
|
|
||||||
|
public ?string $watermark_mode = 'base';
|
||||||
|
public ?string $watermark_asset = null;
|
||||||
|
public string $watermark_position = 'bottom-right';
|
||||||
|
public float $watermark_opacity = 0.25;
|
||||||
|
public float $watermark_scale = 0.2;
|
||||||
|
public int $watermark_padding = 16;
|
||||||
|
public bool $serve_originals = false;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$event = $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php
Normal file
103
app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\SuperAdmin\Pages;
|
||||||
|
|
||||||
|
use App\Models\WatermarkSetting;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class WatermarkSettingsPage extends Page
|
||||||
|
{
|
||||||
|
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
|
||||||
|
|
||||||
|
protected string $view = 'filament.super-admin.pages.watermark-settings-page';
|
||||||
|
|
||||||
|
protected static null|string|\UnitEnum $navigationGroup = 'Branding';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
|
public ?string $asset = null;
|
||||||
|
public string $position = 'bottom-right';
|
||||||
|
public float $opacity = 0.25;
|
||||||
|
public float $scale = 0.2;
|
||||||
|
public int $padding = 16;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$settings = WatermarkSetting::query()->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ use App\Services\PushSubscriptionService;
|
|||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
|
use App\Support\WatermarkConfigResolver;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -877,14 +878,32 @@ class EventPublicController extends BaseController
|
|||||||
return $fallback;
|
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
|
private function buildGalleryBranding(Event $event): array
|
||||||
{
|
{
|
||||||
$defaultPrimary = '#f43f5e';
|
$defaultPrimary = '#f43f5e';
|
||||||
$defaultSecondary = '#fb7185';
|
$defaultSecondary = '#fb7185';
|
||||||
$defaultBackground = '#ffffff';
|
$defaultBackground = '#ffffff';
|
||||||
|
|
||||||
$eventBranding = Arr::get($event->settings, 'branding', []);
|
$event->loadMissing('eventPackage.package', 'tenant');
|
||||||
$tenantBranding = Arr::get($event->tenant?->settings, 'branding', []);
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
|
|
||||||
|
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
||||||
|
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'primary_color' => Arr::get($eventBranding, 'primary_color')
|
'primary_color' => Arr::get($eventBranding, 'primary_color')
|
||||||
@@ -1239,6 +1258,7 @@ class EventPublicController extends BaseController
|
|||||||
$variantPreference = $variant === 'thumbnail'
|
$variantPreference = $variant === 'thumbnail'
|
||||||
? ['thumbnail', 'original']
|
? ['thumbnail', 'original']
|
||||||
: ['original'];
|
: ['original'];
|
||||||
|
$preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false);
|
||||||
|
|
||||||
return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline');
|
return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline');
|
||||||
}
|
}
|
||||||
@@ -1360,8 +1380,11 @@ class EventPublicController extends BaseController
|
|||||||
$branding = $this->buildGalleryBranding($event);
|
$branding = $this->buildGalleryBranding($event);
|
||||||
$fontFamily = Arr::get($event->settings, 'branding.font_family')
|
$fontFamily = Arr::get($event->settings, 'branding.font_family')
|
||||||
?? Arr::get($event->tenant?->settings, 'branding.font_family');
|
?? Arr::get($event->tenant?->settings, 'branding.font_family');
|
||||||
$logoUrl = Arr::get($event->settings, 'branding.logo_url')
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
?? Arr::get($event->tenant?->settings, 'branding.logo_url');
|
$logoUrl = $brandingAllowed
|
||||||
|
? (Arr::get($event->settings, 'branding.logo_url')
|
||||||
|
?? Arr::get($event->tenant?->settings, 'branding.logo_url'))
|
||||||
|
: null;
|
||||||
|
|
||||||
if ($joinToken) {
|
if ($joinToken) {
|
||||||
$this->joinTokenService->incrementUsage($joinToken);
|
$this->joinTokenService->incrementUsage($joinToken);
|
||||||
@@ -1430,6 +1453,9 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
$package = $eventPackage->package;
|
$package = $eventPackage->package;
|
||||||
$summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
|
$summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
|
||||||
|
$watermarkPolicy = $this->determineWatermarkPolicy($event);
|
||||||
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
|
$watermark = $this->resolveWatermarkConfig($event);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'id' => $eventPackage->id,
|
'id' => $eventPackage->id,
|
||||||
@@ -1441,18 +1467,23 @@ class EventPublicController extends BaseController
|
|||||||
'max_photos' => $package?->max_photos,
|
'max_photos' => $package?->max_photos,
|
||||||
'max_guests' => $package?->max_guests,
|
'max_guests' => $package?->max_guests,
|
||||||
'gallery_days' => $package?->gallery_days,
|
'gallery_days' => $package?->gallery_days,
|
||||||
|
'watermark_policy' => $watermarkPolicy,
|
||||||
|
'branding_allowed' => $brandingAllowed,
|
||||||
],
|
],
|
||||||
'used_photos' => (int) $eventPackage->used_photos,
|
'used_photos' => (int) $eventPackage->used_photos,
|
||||||
'used_guests' => (int) $eventPackage->used_guests,
|
'used_guests' => (int) $eventPackage->used_guests,
|
||||||
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
|
||||||
'limits' => $summary,
|
'limits' => $summary,
|
||||||
|
'watermark' => $watermark,
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
|
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
|
||||||
{
|
{
|
||||||
|
$preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false);
|
||||||
|
|
||||||
foreach ($variantPreference as $variant) {
|
foreach ($variantPreference as $variant) {
|
||||||
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant);
|
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant, $preferOriginals);
|
||||||
|
|
||||||
if (! $path) {
|
if (! $path) {
|
||||||
continue;
|
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') {
|
if ($variant === 'thumbnail') {
|
||||||
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
|
$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;
|
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||||
$path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
||||||
$mime = $asset?->mime_type ?? 'image/jpeg';
|
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
|
||||||
} else {
|
} 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;
|
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||||
$path = $asset?->path ?? ($record->file_path ?? null);
|
$path = $watermarked?->path ?? $asset?->path ?? ($record->file_path ?? null);
|
||||||
$mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
|
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -2363,7 +2400,7 @@ class EventPublicController extends BaseController
|
|||||||
$file = $validated['photo'];
|
$file = $validated['photo'];
|
||||||
$disk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
|
$disk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
|
||||||
$path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file);
|
$path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file);
|
||||||
$url = $this->resolveDiskUrl($disk, $path);
|
$watermarkConfig = WatermarkConfigResolver::resolve($eventModel);
|
||||||
|
|
||||||
// Generate thumbnail (JPEG) under photos/thumbs
|
// Generate thumbnail (JPEG) under photos/thumbs
|
||||||
$baseName = pathinfo($path, PATHINFO_FILENAME);
|
$baseName = pathinfo($path, PATHINFO_FILENAME);
|
||||||
@@ -2371,7 +2408,27 @@ class EventPublicController extends BaseController
|
|||||||
$thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82);
|
$thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82);
|
||||||
$thumbUrl = $thumbPath
|
$thumbUrl = $thumbPath
|
||||||
? $this->resolveDiskUrl($disk, $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([
|
$photoId = DB::table('photos')->insertGetId([
|
||||||
'event_id' => $eventId,
|
'event_id' => $eventId,
|
||||||
@@ -2390,16 +2447,35 @@ class EventPublicController extends BaseController
|
|||||||
'updated_at' => now(),
|
'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, [
|
$asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [
|
||||||
'variant' => 'original',
|
'variant' => 'original',
|
||||||
'mime_type' => $file->getClientMimeType(),
|
'mime_type' => $file->getClientMimeType(),
|
||||||
'size_bytes' => $file->getSize(),
|
'size_bytes' => $storedSize,
|
||||||
'checksum' => hash_file('sha256', $file->getRealPath()),
|
'checksum' => file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath()),
|
||||||
'status' => 'hot',
|
'status' => 'hot',
|
||||||
'processed_at' => now(),
|
'processed_at' => now(),
|
||||||
'photo_id' => $photoId,
|
'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) {
|
if ($thumbPath) {
|
||||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [
|
$this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [
|
||||||
'variant' => 'thumbnail',
|
'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')
|
DB::table('photos')
|
||||||
->where('id', $photoId)
|
->where('id', $photoId)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
|
|||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
|
use App\Support\WatermarkConfigResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -153,13 +154,38 @@ class PhotoController extends Controller
|
|||||||
$thumbnailPath = $thumbnailRelative;
|
$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 = [
|
$photoAttributes = [
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'original_name' => $file->getClientOriginalName(),
|
'original_name' => $file->getClientOriginalName(),
|
||||||
'mime_type' => $file->getMimeType(),
|
'mime_type' => $file->getMimeType(),
|
||||||
'size' => $file->getSize(),
|
'size' => $file->getSize(),
|
||||||
'file_path' => $path,
|
'file_path' => $watermarkedPath,
|
||||||
'thumbnail_path' => $thumbnailPath,
|
'thumbnail_path' => $watermarkedThumb,
|
||||||
'width' => null, // Filled below
|
'width' => null, // Filled below
|
||||||
'height' => null,
|
'height' => null,
|
||||||
'status' => 'pending', // Requires moderation
|
'status' => 'pending', // Requires moderation
|
||||||
@@ -179,17 +205,36 @@ class PhotoController extends Controller
|
|||||||
$photo = Photo::create($photoAttributes);
|
$photo = Photo::create($photoAttributes);
|
||||||
|
|
||||||
// Record primary asset metadata
|
// 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, [
|
$asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [
|
||||||
'variant' => 'original',
|
'variant' => 'original',
|
||||||
'mime_type' => $file->getMimeType(),
|
'mime_type' => $file->getMimeType(),
|
||||||
'size_bytes' => $file->getSize(),
|
'size_bytes' => $storedSize,
|
||||||
'checksum' => $checksum,
|
'checksum' => $checksum,
|
||||||
'status' => 'hot',
|
'status' => 'hot',
|
||||||
'processed_at' => now(),
|
'processed_at' => now(),
|
||||||
'photo_id' => $photo->id,
|
'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) {
|
if ($thumbnailRelative) {
|
||||||
$this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [
|
$this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [
|
||||||
'variant' => 'thumbnail',
|
'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]);
|
$photo->update(['media_asset_id' => $asset->id]);
|
||||||
|
|
||||||
|
|||||||
19
app/Models/WatermarkSetting.php
Normal file
19
app/Models/WatermarkSetting.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class WatermarkSetting extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'asset',
|
||||||
|
'position',
|
||||||
|
'opacity',
|
||||||
|
'scale',
|
||||||
|
'padding',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -89,6 +89,10 @@ class SuperAdminPanelProvider extends PanelProvider
|
|||||||
LegalPageResource::class,
|
LegalPageResource::class,
|
||||||
InfrastructureActionLogResource::class,
|
InfrastructureActionLogResource::class,
|
||||||
])
|
])
|
||||||
|
->pages([
|
||||||
|
Pages\Dashboard::class,
|
||||||
|
\App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class,
|
||||||
|
])
|
||||||
->authGuard('web');
|
->authGuard('web');
|
||||||
|
|
||||||
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
|
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation
|
||||||
|
|||||||
@@ -140,6 +140,29 @@ class PhotoboothIngestService
|
|||||||
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk($destinationDisk, $destinationPath, $thumbnailPath, 640, 82);
|
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk($destinationDisk, $destinationPath, $thumbnailPath, 640, 82);
|
||||||
$thumbnailToStore = $thumbnailRelative ?? $destinationPath;
|
$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);
|
$size = Storage::disk($destinationDisk)->size($destinationPath);
|
||||||
$mimeType = Storage::disk($destinationDisk)->mimeType($destinationPath) ?? 'image/jpeg';
|
$mimeType = Storage::disk($destinationDisk)->mimeType($destinationPath) ?? 'image/jpeg';
|
||||||
$originalName = basename($file);
|
$originalName = basename($file);
|
||||||
@@ -165,8 +188,8 @@ class PhotoboothIngestService
|
|||||||
'original_name' => $originalName,
|
'original_name' => $originalName,
|
||||||
'mime_type' => $mimeType,
|
'mime_type' => $mimeType,
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
'file_path' => $destinationPath,
|
'file_path' => $watermarkedPath,
|
||||||
'thumbnail_path' => $thumbnailToStore,
|
'thumbnail_path' => $watermarkedThumb,
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'guest_name' => Photo::SOURCE_PHOTOBOOTH,
|
'guest_name' => Photo::SOURCE_PHOTOBOOTH,
|
||||||
'ingest_source' => Photo::SOURCE_PHOTOBOOTH,
|
'ingest_source' => Photo::SOURCE_PHOTOBOOTH,
|
||||||
@@ -192,6 +215,23 @@ class PhotoboothIngestService
|
|||||||
'photo_id' => $photo->id,
|
'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) {
|
if ($thumbnailRelative) {
|
||||||
$this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [
|
$this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [
|
||||||
'variant' => 'thumbnail',
|
'variant' => 'thumbnail',
|
||||||
@@ -201,6 +241,21 @@ class PhotoboothIngestService
|
|||||||
'photo_id' => $photo->id,
|
'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]);
|
$photo->update(['media_asset_id' => $asset->id]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ trait PresentsPackages
|
|||||||
$packageArray = $package->toArray();
|
$packageArray = $package->toArray();
|
||||||
$features = $packageArray['features'] ?? [];
|
$features = $packageArray['features'] ?? [];
|
||||||
$features = $this->normaliseFeatures($features);
|
$features = $this->normaliseFeatures($features);
|
||||||
|
$watermarkPolicy = $package->watermark_allowed === false ? 'none' : 'basic';
|
||||||
|
|
||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);
|
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);
|
||||||
@@ -50,6 +51,7 @@ trait PresentsPackages
|
|||||||
'gallery_duration_label' => $galleryDuration,
|
'gallery_duration_label' => $galleryDuration,
|
||||||
'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null),
|
'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null),
|
||||||
'features' => $features,
|
'features' => $features,
|
||||||
|
'watermark_policy' => $watermarkPolicy,
|
||||||
'limits' => $package->limits,
|
'limits' => $package->limits,
|
||||||
'max_photos' => $package->max_photos,
|
'max_photos' => $package->max_photos,
|
||||||
'max_guests' => $package->max_guests,
|
'max_guests' => $package->max_guests,
|
||||||
|
|||||||
@@ -65,5 +65,122 @@ class ImageHelper
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
90
app/Support/WatermarkConfigResolver.php
Normal file
90
app/Support/WatermarkConfigResolver.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class WatermarkConfigResolver
|
||||||
|
{
|
||||||
|
public static function determineBrandingAllowed(Event $event): bool
|
||||||
|
{
|
||||||
|
$event->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;
|
||||||
20
config/watermark.php
Normal file
20
config/watermark.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Base watermark configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Dieses Wasserzeichen wird bei Paketen mit "basic" Policy verwendet.
|
||||||
|
| Super-Admins können diesen Block anpassen. Pfade sind relativ zu public/.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'base' => [
|
||||||
|
'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
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('events', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('events', 'settings')) {
|
||||||
|
$table->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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('watermark_settings', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -30,7 +30,7 @@ class PackageSeeder extends Seeder
|
|||||||
'max_tasks' => 30,
|
'max_tasks' => 30,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => false,
|
'branding_allowed' => false,
|
||||||
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
'features' => ['basic_uploads', 'custom_tasks'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
||||||
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
||||||
'description' => <<<TEXT
|
'description' => <<<TEXT
|
||||||
@@ -63,7 +63,7 @@ TEXT,
|
|||||||
'max_tasks' => 100,
|
'max_tasks' => 100,
|
||||||
'watermark_allowed' => false,
|
'watermark_allowed' => false,
|
||||||
'branding_allowed' => true,
|
'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_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
|
||||||
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
|
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
|
||||||
'description' => <<<TEXT
|
'description' => <<<TEXT
|
||||||
@@ -96,7 +96,7 @@ TEXT,
|
|||||||
'max_tasks' => 200,
|
'max_tasks' => 200,
|
||||||
'watermark_allowed' => false,
|
'watermark_allowed' => false,
|
||||||
'branding_allowed' => true,
|
'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_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
|
||||||
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
|
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
|
||||||
'description' => <<<TEXT
|
'description' => <<<TEXT
|
||||||
@@ -129,7 +129,7 @@ TEXT,
|
|||||||
'max_tasks' => null,
|
'max_tasks' => null,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => false,
|
'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_product_id' => 'pro_01k8jcxsmxrs45v1qvtpsepn13',
|
||||||
'paddle_price_id' => 'pri_01k8jcxswh74jv8vy5q7a4h70p',
|
'paddle_price_id' => 'pri_01k8jcxswh74jv8vy5q7a4h70p',
|
||||||
'description' => null,
|
'description' => null,
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
'max_photos' => 1500,
|
'max_photos' => 1500,
|
||||||
'max_guests' => 400,
|
'max_guests' => 400,
|
||||||
'gallery_days' => 60,
|
'gallery_days' => 60,
|
||||||
|
'watermark_allowed' => false,
|
||||||
|
'branding_allowed' => false,
|
||||||
'features' => [
|
'features' => [
|
||||||
'basic_uploads' => true,
|
'basic_uploads' => true,
|
||||||
'unlimited_sharing' => true,
|
'unlimited_sharing' => true,
|
||||||
'no_watermark' => true,
|
|
||||||
'custom_tasks' => true,
|
'custom_tasks' => true,
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
@@ -59,10 +60,11 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
'max_photos' => 5000,
|
'max_photos' => 5000,
|
||||||
'max_guests' => 1000,
|
'max_guests' => 1000,
|
||||||
'gallery_days' => 180,
|
'gallery_days' => 180,
|
||||||
|
'watermark_allowed' => false,
|
||||||
|
'branding_allowed' => true,
|
||||||
'features' => [
|
'features' => [
|
||||||
'basic_uploads' => true,
|
'basic_uploads' => true,
|
||||||
'unlimited_sharing' => true,
|
'unlimited_sharing' => true,
|
||||||
'no_watermark' => true,
|
|
||||||
'custom_branding' => true,
|
'custom_branding' => true,
|
||||||
'custom_tasks' => true,
|
'custom_tasks' => true,
|
||||||
],
|
],
|
||||||
@@ -77,6 +79,8 @@ class DemoLifecycleSeeder extends Seeder
|
|||||||
'name_translations' => ['de' => 'Studio Jahrespaket', 'en' => 'Studio Annual'],
|
'name_translations' => ['de' => 'Studio Jahrespaket', 'en' => 'Studio Annual'],
|
||||||
'price' => 1299,
|
'price' => 1299,
|
||||||
'max_events_per_year' => 24,
|
'max_events_per_year' => 24,
|
||||||
|
'watermark_allowed' => false,
|
||||||
|
'branding_allowed' => true,
|
||||||
'features' => [
|
'features' => [
|
||||||
'custom_branding' => true,
|
'custom_branding' => true,
|
||||||
'unlimited_sharing' => true,
|
'unlimited_sharing' => true,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:8000',
|
baseURL: 'http://fotospiel-app.test',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
headless: true,
|
headless: true,
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import MarketingLayout from '@/layouts/mainWebsite';
|
|||||||
import { useAnalytics } from '@/hooks/useAnalytics';
|
import { useAnalytics } from '@/hooks/useAnalytics';
|
||||||
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
import { useCtaExperiment } from '@/hooks/useCtaExperiment';
|
||||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||||
import { ArrowRight, Check, Shield, Star, Sparkles } from 'lucide-react';
|
import { ArrowRight, Check, Star } from 'lucide-react';
|
||||||
|
|
||||||
interface Package {
|
interface Package {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -46,6 +46,39 @@ interface PackageComparisonProps {
|
|||||||
variant: 'endcustomer' | 'reseller';
|
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) {
|
function PackageComparison({ packages, variant }: PackageComparisonProps) {
|
||||||
const { t } = useTranslation('marketing');
|
const { t } = useTranslation('marketing');
|
||||||
const { t: tCommon } = useTranslation('common');
|
const { t: tCommon } = useTranslation('common');
|
||||||
@@ -547,7 +580,9 @@ function PackageCard({
|
|||||||
? t('packages.badge_starter')
|
? t('packages.badge_starter')
|
||||||
: null;
|
: 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 metrics = resolvePackageMetrics(pkg, variant, t, tCommon);
|
||||||
|
|
||||||
const metricList = compact ? (
|
const metricList = compact ? (
|
||||||
@@ -572,45 +607,21 @@ function PackageCard({
|
|||||||
|
|
||||||
const featureList = compact ? (
|
const featureList = compact ? (
|
||||||
<ul className="space-y-1 text-sm text-gray-700">
|
<ul className="space-y-1 text-sm text-gray-700">
|
||||||
{keyFeatures.map((feature) => (
|
{visibleFeatures.map((feature) => (
|
||||||
<li key={feature} className="flex items-start gap-2 text-xs">
|
<li key={feature} className="flex items-start gap-2 text-xs">
|
||||||
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
|
<Check className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
|
||||||
<span>{t(`packages.feature_${feature}`)}</span>
|
<span>{t(`packages.feature_${feature}`)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{pkg.watermark_allowed === false && (
|
|
||||||
<li className="flex items-start gap-2 text-xs">
|
|
||||||
<Shield className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
|
|
||||||
<span>{t('packages.no_watermark')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{pkg.branding_allowed && (
|
|
||||||
<li className="flex items-start gap-2 text-xs">
|
|
||||||
<Sparkles className="mt-0.5 h-3.5 w-3.5 text-gray-900" />
|
|
||||||
<span>{t('packages.custom_branding')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2 text-sm text-gray-700">
|
<ul className="space-y-2 text-sm text-gray-700">
|
||||||
{keyFeatures.map((feature) => (
|
{visibleFeatures.map((feature) => (
|
||||||
<li key={feature} className="flex items-center gap-2">
|
<li key={feature} className="flex items-center gap-2">
|
||||||
<Check className="h-4 w-4 text-gray-900" />
|
<Check className="h-4 w-4 text-gray-900" />
|
||||||
<span>{t(`packages.feature_${feature}`)}</span>
|
<span>{t(`packages.feature_${feature}`)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{pkg.watermark_allowed === false && (
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<Shield className="h-4 w-4 text-gray-900" />
|
|
||||||
<span>{t('packages.no_watermark')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{pkg.branding_allowed && (
|
|
||||||
<li className="flex items-center gap-2">
|
|
||||||
<Sparkles className="h-4 w-4 text-gray-900" />
|
|
||||||
<span>{t('packages.custom_branding')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -703,6 +714,10 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
close,
|
close,
|
||||||
}) => {
|
}) => {
|
||||||
const metrics = resolvePackageMetrics(packageData, variant, t, tCommon);
|
const metrics = resolvePackageMetrics(packageData, variant, t, tCommon);
|
||||||
|
const highlightFeatures = useMemo(
|
||||||
|
() => buildDisplayFeatures(packageData).slice(0, 5),
|
||||||
|
[packageData],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-8 lg:grid-cols-[320px,1fr]">
|
<div className="grid gap-8 lg:grid-cols-[320px,1fr]">
|
||||||
@@ -757,24 +772,12 @@ const PackageDetailGrid: React.FC<PackageDetailGridProps> = ({
|
|||||||
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{t('packages.feature_highlights')}</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{t('packages.feature_highlights')}</h3>
|
||||||
<ul className="mt-4 space-y-3 text-sm text-gray-700">
|
<ul className="mt-4 space-y-3 text-sm text-gray-700">
|
||||||
{packageData.features.slice(0, 5).map((feature) => (
|
{highlightFeatures.map((feature) => (
|
||||||
<li key={feature} className="flex items-start gap-2">
|
<li key={feature} className="flex items-start gap-2">
|
||||||
<Check className="mt-1 h-4 w-4 text-gray-900" />
|
<Check className="mt-1 h-4 w-4 text-gray-900" />
|
||||||
<span>{t(`packages.feature_${feature}`)}</span>
|
<span>{t(`packages.feature_${feature}`)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{packageData.watermark_allowed === false && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<Shield className="mt-1 h-4 w-4 text-gray-900" />
|
|
||||||
<span>{t('packages.no_watermark')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{packageData.branding_allowed && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<Sparkles className="mt-1 h-4 w-4 text-gray-900" />
|
|
||||||
<span>{t('packages.custom_branding')}</span>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
<div className="rounded-2xl border border-gray-100 bg-white p-6">
|
||||||
|
|||||||
20
resources/lang/de/filament-watermark.php
Normal file
20
resources/lang/de/filament-watermark.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'heading' => '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.',
|
||||||
|
];
|
||||||
20
resources/lang/en/filament-watermark.php
Normal file
20
resources/lang/en/filament-watermark.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'heading' => '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.',
|
||||||
|
];
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<x-filament::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ __('filament-watermark.description') }}
|
||||||
|
</div>
|
||||||
|
{{ $this->form }}
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<x-filament::button wire:click="save" color="primary">
|
||||||
|
{{ __('filament-watermark.save') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::page>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@php
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-filament::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-xl bg-white p-6 shadow-sm">
|
||||||
|
{{ $this->form }}
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<x-filament::button wire:click="save" color="primary">
|
||||||
|
Speichern
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if($asset)
|
||||||
|
<div class="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<p class="text-sm font-medium mb-2">Aktuelles Basis-Wasserzeichen</p>
|
||||||
|
<img src="{{ Storage::disk('public')->url($asset) }}" alt="Watermark" class="max-h-32 object-contain">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::page>
|
||||||
142
tests/ui/admin/event-addon-upgrade.test.ts
Normal file
142
tests/ui/admin/event-addon-upgrade.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -206,4 +206,71 @@ test.describe('Guest PWA limit experiences', () => {
|
|||||||
await expect(page.getByText(/Galerie abgelaufen/i)).toBeVisible();
|
await expect(page.getByText(/Galerie abgelaufen/i)).toBeVisible();
|
||||||
await expect(page.getByText(/Die Galerie ist abgelaufen\. Uploads sind nicht mehr möglich\./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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.de
|
|||||||
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
|
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
|
||||||
|
|
||||||
export const test = base.extend<TenantAdminFixtures & TestingApiFixtures>({
|
export const test = base.extend<TenantAdminFixtures & TestingApiFixtures>({
|
||||||
tenantAdminCredentials: async (_context, use) => {
|
tenantAdminCredentials: async ({}, use) => {
|
||||||
if (!tenantAdminEmail || !tenantAdminPassword) {
|
if (!tenantAdminEmail || !tenantAdminPassword) {
|
||||||
await use(null);
|
await use(null);
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user