diff --git a/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php index e9e7e60..4f55c63 100644 --- a/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php +++ b/app/Filament/SuperAdmin/Pages/WatermarkSettingsPage.php @@ -5,9 +5,8 @@ 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; +use Filament\Pages\Page; class WatermarkSettingsPage extends Page { @@ -20,11 +19,19 @@ class WatermarkSettingsPage extends Page 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 int $offset_x = 0; + + public int $offset_y = 0; + public function mount(): void { $settings = WatermarkSetting::query()->first(); @@ -35,6 +42,8 @@ class WatermarkSettingsPage extends Page $this->opacity = (float) $settings->opacity; $this->scale = (float) $settings->scale; $this->padding = (int) $settings->padding; + $this->offset_x = (int) ($settings->offset_x ?? 0); + $this->offset_y = (int) ($settings->offset_y ?? 0); } } @@ -80,6 +89,20 @@ class WatermarkSettingsPage extends Page ->minValue(0) ->default(16) ->required(), + Forms\Components\TextInput::make('offset_x') + ->label('Offset X (px)') + ->numeric() + ->minValue(-500) + ->maxValue(500) + ->default(0) + ->required(), + Forms\Components\TextInput::make('offset_y') + ->label('Offset Y (px)') + ->numeric() + ->minValue(-500) + ->maxValue(500) + ->default(0) + ->required(), ])->columns(2); } @@ -93,6 +116,8 @@ class WatermarkSettingsPage extends Page $settings->opacity = $this->opacity; $settings->scale = $this->scale; $settings->padding = $this->padding; + $settings->offset_x = $this->offset_x; + $settings->offset_y = $this->offset_y; $settings->save(); Notification::make() diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 4ec2287..c8bac06 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -21,6 +21,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -140,6 +141,7 @@ class EventController extends Controller } $settings['branding_allowed'] = $package->branding_allowed !== false; + $settings['watermark_allowed'] = $package->watermark_allowed !== false; $eventData['settings'] = $settings; @@ -258,7 +260,9 @@ class EventController extends Controller unset($validated[$unused]); } - $brandingAllowed = optional($event->eventPackage?->package)->branding_allowed !== false; + $package = $event->eventPackage?->package; + $brandingAllowed = optional($package)->branding_allowed !== false; + $watermarkAllowed = optional($package)->watermark_allowed !== false; if (isset($validated['settings']) && is_array($validated['settings'])) { $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); @@ -267,6 +271,92 @@ class EventController extends Controller } $validated['settings']['branding_allowed'] = $brandingAllowed; + $validated['settings']['watermark_allowed'] = $watermarkAllowed; + + $settings = $validated['settings']; + $watermark = Arr::get($settings, 'watermark', []); + $existingWatermark = is_array($watermark) ? $watermark : []; + + if (is_array($watermark)) { + $mode = $watermark['mode'] ?? 'base'; + $policy = $watermarkAllowed ? 'basic' : 'none'; + + if (! $watermarkAllowed) { + $mode = 'off'; + } elseif (! $brandingAllowed) { + $mode = 'base'; + } elseif ($mode === 'off' && $policy === 'basic') { + $mode = 'base'; + } + + $assetPath = $watermark['asset'] ?? null; + $assetDataUrl = $watermark['asset_data_url'] ?? null; + + if (! $watermarkAllowed) { + $assetPath = null; + } + + if ($assetDataUrl && $mode === 'custom' && $brandingAllowed) { + if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $assetDataUrl, $matches)) { + throw ValidationException::withMessages([ + 'settings.watermark.asset_data_url' => __('Ungültiges Wasserzeichen-Bild.'), + ]); + } + + $decoded = base64_decode($matches[2], true); + + if ($decoded === false) { + throw ValidationException::withMessages([ + 'settings.watermark.asset_data_url' => __('Wasserzeichen konnte nicht gelesen werden.'), + ]); + } + + if (strlen($decoded) > 3 * 1024 * 1024) { // 3 MB + throw ValidationException::withMessages([ + 'settings.watermark.asset_data_url' => __('Wasserzeichen ist zu groß (max. 3 MB).'), + ]); + } + + $extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]); + $path = sprintf('branding/watermarks/event-%s.%s', $event->id, $extension); + Storage::disk('public')->put($path, $decoded); + $assetPath = $path; + } + + $position = $watermark['position'] ?? 'bottom-right'; + $validPositions = [ + 'top-left', + 'top-center', + 'top-right', + 'middle-left', + 'center', + 'middle-right', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ]; + + if (! in_array($position, $validPositions, true)) { + $position = 'bottom-right'; + } + + $settings['watermark'] = [ + 'mode' => $mode, + 'asset' => $assetPath, + 'position' => $position, + 'opacity' => isset($watermark['opacity']) ? (float) $watermark['opacity'] : ($existingWatermark['opacity'] ?? null), + 'scale' => isset($watermark['scale']) ? (float) $watermark['scale'] : ($existingWatermark['scale'] ?? null), + 'padding' => isset($watermark['padding']) ? (int) $watermark['padding'] : ($existingWatermark['padding'] ?? null), + 'offset_x' => isset($watermark['offset_x']) ? (int) $watermark['offset_x'] : ($existingWatermark['offset_x'] ?? 0), + 'offset_y' => isset($watermark['offset_y']) ? (int) $watermark['offset_y'] : ($existingWatermark['offset_y'] ?? 0), + ]; + } + + if (array_key_exists('watermark_serve_originals', $settings)) { + $settings['watermark_serve_originals'] = (bool) $settings['watermark_serve_originals']; + } + + $validated['settings'] = $settings; $event->update($validated); $event->load(['eventType', 'tenant']); diff --git a/app/Http/Controllers/Api/TenantPackageController.php b/app/Http/Controllers/Api/TenantPackageController.php index 44fff29..150f798 100644 --- a/app/Http/Controllers/Api/TenantPackageController.php +++ b/app/Http/Controllers/Api/TenantPackageController.php @@ -30,8 +30,16 @@ class TenantPackageController extends Controller ->get(); $packages->each(function ($package) { - $package->remaining_events = $package->package->max_events_per_year - $package->used_events; - $package->package_limits = $package->package->getAttributes(); // Or custom accessor for limits + $pkg = $package->package; + $package->remaining_events = $pkg->max_events_per_year - $package->used_events; + $package->package_limits = array_merge( + $pkg->limits, + [ + 'branding_allowed' => $pkg->branding_allowed, + 'watermark_allowed' => $pkg->watermark_allowed, + 'features' => $pkg->features, + ] + ); }); return response()->json([ diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index 7608f55..d7645ff 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -46,6 +46,27 @@ class EventStoreRequest extends FormRequest 'settings.branding.*' => ['nullable'], 'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])], 'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])], + 'settings.watermark' => ['nullable', 'array'], + 'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])], + 'settings.watermark.asset' => ['nullable', 'string', 'max:500'], + 'settings.watermark.asset_data_url' => ['nullable', 'string'], + 'settings.watermark.position' => ['nullable', Rule::in([ + 'top-left', + 'top-center', + 'top-right', + 'middle-left', + 'center', + 'middle-right', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ])], + 'settings.watermark.opacity' => ['nullable', 'numeric', 'min:0', 'max:1'], + 'settings.watermark.scale' => ['nullable', 'numeric', 'min:0.05', 'max:1'], + 'settings.watermark.padding' => ['nullable', 'integer', 'min:0', 'max:500'], + 'settings.watermark.offset_x' => ['nullable', 'integer', 'min:-500', 'max:500'], + 'settings.watermark.offset_y' => ['nullable', 'integer', 'min:-500', 'max:500'], + 'settings.watermark_serve_originals' => ['nullable', 'boolean'], ]; } diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index bacc47e..b509725 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -79,6 +79,8 @@ class EventResource extends JsonResource 'price' => $eventPackage->purchased_price, 'purchased_at' => $eventPackage->purchased_at?->toIso8601String(), 'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(), + 'branding_allowed' => (bool) optional($eventPackage->package)->branding_allowed, + 'watermark_allowed' => (bool) optional($eventPackage->package)->watermark_allowed, ] : null, 'limits' => $eventPackage && $limitEvaluator ? $limitEvaluator->summarizeEventPackage($eventPackage) diff --git a/app/Models/WatermarkSetting.php b/app/Models/WatermarkSetting.php index 673237e..3ff5d27 100644 --- a/app/Models/WatermarkSetting.php +++ b/app/Models/WatermarkSetting.php @@ -15,5 +15,7 @@ class WatermarkSetting extends Model 'opacity', 'scale', 'padding', + 'offset_x', + 'offset_y', ]; } diff --git a/app/Support/ImageHelper.php b/app/Support/ImageHelper.php index 98037f8..26d1fcc 100644 --- a/app/Support/ImageHelper.php +++ b/app/Support/ImageHelper.php @@ -33,6 +33,7 @@ class ImageHelper $h = imagesy($src); if ($w === 0 || $h === 0) { imagedestroy($src); + return null; } @@ -138,18 +139,44 @@ class ImageHelper $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); + switch ($position) { + case 'top-right': + $x = max(0, $srcW - $targetW - $padding); + break; + case 'top-center': + $x = (int) max(0, ($srcW - $targetW) / 2); + break; + case 'middle-left': + $y = (int) max(0, ($srcH - $targetH) / 2); + break; + case 'center': + $x = (int) max(0, ($srcW - $targetW) / 2); + $y = (int) max(0, ($srcH - $targetH) / 2); + break; + case 'middle-right': + $x = max(0, $srcW - $targetW - $padding); + $y = (int) max(0, ($srcH - $targetH) / 2); + break; + case 'bottom-left': + $y = max(0, $srcH - $targetH - $padding); + break; + case 'bottom-center': + $x = (int) max(0, ($srcW - $targetW) / 2); + $y = max(0, $srcH - $targetH - $padding); + break; + case 'bottom-right': + $x = max(0, $srcW - $targetW - $padding); + $y = max(0, $srcH - $targetH - $padding); + break; + default: + break; } + $offsetX = (int) ($config['offset_x'] ?? 0); + $offsetY = (int) ($config['offset_y'] ?? 0); + $x = max(0, min($srcW - $targetW, $x + $offsetX)); + $y = max(0, min($srcH - $targetH, $y + $offsetY)); + $opacity = max(0.0, min(1.0, (float) ($config['opacity'] ?? 0.25))); $mergeOpacity = (int) round($opacity * 100); // imagecopymerge uses 0-100 diff --git a/app/Support/WatermarkConfigResolver.php b/app/Support/WatermarkConfigResolver.php index ebffa58..acfa97e 100644 --- a/app/Support/WatermarkConfigResolver.php +++ b/app/Support/WatermarkConfigResolver.php @@ -33,7 +33,7 @@ class WatermarkConfigResolver } /** - * @return array{type:string, policy:string, asset?:string, position?:string, opacity?:float, scale?:float, padding?:int} + * @return array{type:string, policy:string, asset?:string, position?:string, opacity?:float, scale?:float, padding?:int, offset_x?:int, offset_y?:int} */ public static function resolve(Event $event): array { @@ -61,6 +61,8 @@ class WatermarkConfigResolver '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), + 'offset_x' => $baseSetting?->offset_x ?? config('watermark.base.offset_x', 0), + 'offset_y' => $baseSetting?->offset_y ?? config('watermark.base.offset_y', 0), ]; $event->loadMissing('eventPackage.package', 'tenant'); @@ -91,6 +93,8 @@ class WatermarkConfigResolver $opacity = (float) ($source['opacity'] ?? $base['opacity'] ?? 0.25); $scale = (float) ($source['scale'] ?? $base['scale'] ?? 0.2); $padding = (int) ($source['padding'] ?? $base['padding'] ?? 16); + $offsetX = (int) ($source['offset_x'] ?? $base['offset_x'] ?? 0); + $offsetY = (int) ($source['offset_y'] ?? $base['offset_y'] ?? 0); $clamp = static fn (float $value, float $min, float $max) => max($min, min($max, $value)); @@ -102,6 +106,8 @@ class WatermarkConfigResolver 'opacity' => $clamp($opacity, 0.0, 1.0), 'scale' => $clamp($scale, 0.05, 1.0), 'padding' => max(0, $padding), + 'offset_x' => max(-500, min(500, $offsetX)), + 'offset_y' => max(-500, min(500, $offsetY)), 'serve_originals' => $serveOriginals, ]; } diff --git a/config/watermark.php b/config/watermark.php index e15d0a6..2f60c1a 100644 --- a/config/watermark.php +++ b/config/watermark.php @@ -12,9 +12,11 @@ return [ */ 'base' => [ 'asset' => 'branding/fotospiel-watermark.png', - 'position' => 'bottom-right', // top-left, top-right, bottom-left, bottom-right, center + 'position' => 'bottom-right', // top-left, top-center, top-right, middle-left, center, middle-right, bottom-left, bottom-center, bottom-right 'opacity' => 0.25, // 0..1 'scale' => 0.2, // relative zur Bildbreite/-höhe 'padding' => 16, // px vom Rand + 'offset_x' => 0, // px Feintuning auf der X-Achse + 'offset_y' => 0, // px Feintuning auf der Y-Achse ], ]; diff --git a/database/migrations/2025_12_01_010000_add_offsets_to_watermark_settings.php b/database/migrations/2025_12_01_010000_add_offsets_to_watermark_settings.php new file mode 100644 index 0000000..3fbb37b --- /dev/null +++ b/database/migrations/2025_12_01_010000_add_offsets_to_watermark_settings.php @@ -0,0 +1,38 @@ +integer('offset_x')->default(0)->after('padding'); + } + if (! Schema::hasColumn('watermark_settings', 'offset_y')) { + $table->integer('offset_y')->default(0)->after('offset_x'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('watermark_settings', function (Blueprint $table) { + if (Schema::hasColumn('watermark_settings', 'offset_y')) { + $table->dropColumn('offset_y'); + } + if (Schema::hasColumn('watermark_settings', 'offset_x')) { + $table->dropColumn('offset_x'); + } + }); + } +}; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 4c79dbf..1a450b3 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -80,6 +80,9 @@ export type TenantEvent = { settings?: Record & { engagement_mode?: 'tasks' | 'photo_only'; guest_upload_visibility?: 'review' | 'immediate'; + watermark?: WatermarkSettings; + watermark_allowed?: boolean | null; + watermark_serve_originals?: boolean | null; }; package?: { id: number | string | null; @@ -87,6 +90,8 @@ export type TenantEvent = { price: number | null; purchased_at: string | null; expires_at: string | null; + branding_allowed?: boolean | null; + watermark_allowed?: boolean | null; } | null; limits?: EventLimitSummary | null; addons?: EventAddonSummary[]; @@ -215,6 +220,27 @@ export type TenantFont = { variants: TenantFontVariant[]; }; +export type WatermarkSettings = { + mode?: 'base' | 'custom' | 'off'; + asset?: string | null; + asset_data_url?: string | null; + position?: + | 'top-left' + | 'top-center' + | 'top-right' + | 'middle-left' + | 'center' + | 'middle-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + opacity?: number; + scale?: number; + padding?: number; + offset_x?: number; + offset_y?: number; +}; + export type EventAddonSummary = { id: number; key: string; @@ -380,6 +406,9 @@ export type TenantPackageSummary = { purchased_at: string | null; expires_at: string | null; package_limits: Record | null; + branding_allowed?: boolean | null; + watermark_allowed?: boolean | null; + features?: string[] | null; }; export type NotificationPreferences = Record; @@ -653,7 +682,10 @@ type EventSavePayload = { status?: 'draft' | 'published' | 'archived'; is_active?: boolean; package_id?: number; - settings?: Record; + settings?: Record & { + watermark?: WatermarkSettings; + watermark_serve_originals?: boolean | null; + }; }; type JsonOrThrowOptions = { @@ -916,6 +948,9 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { purchased_at: pkg.purchased_at ?? pkg.created_at ?? null, expires_at: pkg.expires_at ?? pkg.valid_until ?? null, package_limits: pkg.package_limits ?? packageData.limits ?? null, + branding_allowed: pkg.branding_allowed ?? packageData.branding_allowed ?? null, + watermark_allowed: pkg.watermark_allowed ?? packageData.watermark_allowed ?? null, + features: pkg.features ?? packageData.features ?? null, }; } diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index c94ee5c..ca57f34 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -28,5 +28,7 @@ export const ADMIN_EVENT_MEMBERS_PATH = (slug: string): string => adminPath(`/mo export const ADMIN_EVENT_TASKS_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/tasks`); export const ADMIN_EVENT_INVITES_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/qr`); export const ADMIN_EVENT_PHOTOBOOTH_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/photobooth`); +export const ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH = (slug: string): string => + adminPath(`/mobile/events/${encodeURIComponent(slug)}/guest-notifications`); export const ADMIN_EVENT_RECAP_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}`); export const ADMIN_EVENT_BRANDING_PATH = (slug: string): string => adminPath(`/mobile/events/${encodeURIComponent(slug)}/branding`); diff --git a/resources/js/admin/i18n/index.ts b/resources/js/admin/i18n/index.ts index 1ff0d8b..9999463 100644 --- a/resources/js/admin/i18n/index.ts +++ b/resources/js/admin/i18n/index.ts @@ -41,6 +41,7 @@ const resources = { } as const; const FALLBACK_LOCALE = 'de'; +const NAMESPACES = ['common', 'dashboard', 'onboarding', 'management', 'settings', 'auth', 'mobile'] as const; if (!i18n.isInitialized) { i18n @@ -51,7 +52,9 @@ if (!i18n.isInitialized) { fallbackLng: FALLBACK_LOCALE, lng: document.documentElement.lang || undefined, supportedLngs: ['de', 'en'], + ns: NAMESPACES, defaultNS: DEFAULT_NAMESPACE, + fallbackNS: DEFAULT_NAMESPACE, interpolation: { escapeValue: false, }, diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index dc17129..83a01f6 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -30,6 +30,14 @@ "more": "Weitere Einträge konnten nicht geladen werden." }, "sections": { + "invoices": { + "title": "Rechnungen & Zahlungen", + "empty": "Keine Zahlungen gefunden." + }, + "addOns": { + "title": "Add-ons", + "empty": "Keine Add-ons gebucht." + }, "overview": { "title": "Paketübersicht", "description": "Dein aktives Paket und die wichtigsten Kennzahlen.", @@ -132,6 +140,15 @@ "loadMore": "Weitere Add-ons laden", "loadingMore": "Add-ons werden geladen…" }, + "features": { + "branding": "Branding", + "watermark": "Wasserzeichen", + "maxPhotos": "Max. Fotos", + "maxGuests": "Max. Gäste", + "galleryDays": "Galerietage", + "maxTasks": "Max. Aufgaben", + "featureList": "Enthaltene Features" + }, "packages": { "title": "Paket-Historie", "description": "Übersicht über aktive und vergangene Pakete.", @@ -282,6 +299,7 @@ "qr": "QR-Code-Layouts", "images": "Bildverwaltung", "guests": "Gästeverwaltung", + "guestMessages": "Gästebenachrichtigungen", "branding": "Branding & Design", "photobooth": "Photobooth", "recap": "Recap & Archiv" @@ -1782,8 +1800,60 @@ }, "errors": { "missingSlug": "Kein Event-Slug angegeben.", - "loadFailed": "Tasks konnten nicht geladen werden.", - "saveFailed": "Task konnte nicht gespeichert werden." + "missingType": "Event-Typ fehlt. Bitte speichere das Event erneut im Admin.", + "loadFailed": "Event-Daten konnten nicht geladen werden.", + "saveFailed": "Event konnte nicht gespeichert werden." + }, + "branding": { + "titleShort": "Branding", + "previewTitle": "Guest-App-Vorschau", + "previewSubtitle": "Aktuelle Farben & Schriften", + "primary": "Primärfarbe", + "accent": "Akzentfarbe", + "colors": "Farben", + "primaryColor": "Primärfarbe", + "accentColor": "Akzentfarbe", + "fonts": "Schriften", + "headingFont": "Überschrift-Schrift", + "bodyFont": "Fließtext-Schrift", + "logo": "Logo", + "replaceLogo": "Logo ersetzen", + "removeLogo": "Entfernen", + "logoHint": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.", + "uploadLogo": "Logo hochladen (max. 1 MB)", + "logoTooLarge": "Logo muss unter 1 MB sein.", + "save": "Branding speichern", + "saving": "Speichere...", + "saveSuccess": "Branding gespeichert.", + "reset": "Zurücksetzen", + "fontPicker": "Schrift auswählen", + "noFonts": "Keine Schriftarten gefunden." + }, + "watermark": { + "tab": "Wasserzeichen", + "title": "Wasserzeichen", + "previewTitle": "Wasserzeichen-Vorschau", + "mode": "Modus", + "modeBase": "Standard-Wasserzeichen", + "modeCustom": "Eigenes Wasserzeichen", + "modeOff": "Aus", + "upload": "Wasserzeichen hochladen", + "uploadCta": "PNG/SVG/JPG (max. 3 MB)", + "replace": "Wasserzeichen ersetzen", + "uploadHint": "PNG mit Transparenz empfohlen.", + "placement": "Position & Größe", + "size": "Größe", + "opacity": "Transparenz", + "padding": "Abstand", + "offset": "Feinjustierung X", + "offsetX": "X-Achse", + "offsetY": "Y-Achse", + "lockedBranding": "Eigenes Wasserzeichen ist in diesem Paket gesperrt. Standard wird genutzt.", + "lockedDisabled": "Wasserzeichen sind in diesem Paket deaktiviert.", + "errors": { + "noAsset": "Bitte zuerst ein Wasserzeichen hochladen.", + "fileTooLarge": "Wasserzeichen muss kleiner als 3 MB sein." + } }, "tasks": { "disabledTitle": "Task-Modus ist für dieses Event aus", @@ -1932,6 +2002,98 @@ "pref": {} }, "events": { + "detail": { + "kpi": { + "tasks": "Aktive Aufgaben", + "guests": "Registrierte Gäste", + "photos": "Hochgeladene Bilder" + }, + "pickEvent": "Event auswählen", + "active": "Aktiv", + "managementTitle": "Event-Verwaltung", + "dateTbd": "Datum folgt", + "locationPlaceholder": "Ort" + }, + "quick": { + "tasks": "Aufgaben & Checklisten", + "qr": "QR-Code-Layouts", + "images": "Bildverwaltung", + "guests": "Gästeverwaltung", + "guestMessages": "Gästebenachrichtigungen", + "branding": "Branding & Design", + "photobooth": "Photobooth", + "recap": "Recap & Archiv" + }, + "status": { + "published": "Live", + "draft": "Entwurf", + "archived": "Archiviert" + }, + "errors": { + "missingSlug": "Kein Event-Slug angegeben.", + "missingType": "Event-Typ fehlt. Bitte speichere das Event erneut im Admin.", + "loadFailed": "Event-Daten konnten nicht geladen werden.", + "saveFailed": "Event konnte nicht gespeichert werden.", + "notFoundTitle": "Event nicht gefunden", + "notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.", + "toggleFailed": "Status konnte nicht angepasst werden.", + "checkoutMissing": "Checkout konnte nicht gestartet werden.", + "checkoutFailed": "Add-on Checkout fehlgeschlagen." + }, + "placeholders": { + "untitled": "Unbenanntes Event" + }, + "branding": { + "titleShort": "Branding", + "previewTitle": "Guest-App-Vorschau", + "previewSubtitle": "Aktuelle Farben & Schriften", + "primary": "Primärfarbe", + "accent": "Akzentfarbe", + "colors": "Farben", + "primaryColor": "Primärfarbe", + "accentColor": "Akzentfarbe", + "fonts": "Schriften", + "headingFont": "Überschrift-Schrift", + "bodyFont": "Fließtext-Schrift", + "logo": "Logo", + "replaceLogo": "Logo ersetzen", + "removeLogo": "Entfernen", + "logoHint": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.", + "uploadLogo": "Logo hochladen (max. 1 MB)", + "logoTooLarge": "Logo muss unter 1 MB sein.", + "save": "Branding speichern", + "saving": "Speichere...", + "saveSuccess": "Branding gespeichert.", + "reset": "Zurücksetzen", + "fontPicker": "Schrift auswählen", + "noFonts": "Keine Schriftarten gefunden." + }, + "watermark": { + "tab": "Wasserzeichen", + "title": "Wasserzeichen", + "previewTitle": "Wasserzeichen-Vorschau", + "mode": "Modus", + "modeBase": "Standard-Wasserzeichen", + "modeCustom": "Eigenes Wasserzeichen", + "modeOff": "Aus", + "upload": "Wasserzeichen hochladen", + "uploadCta": "PNG/SVG/JPG (max. 3 MB)", + "replace": "Wasserzeichen ersetzen", + "uploadHint": "PNG mit Transparenz empfohlen.", + "placement": "Position & Größe", + "size": "Größe", + "opacity": "Transparenz", + "padding": "Abstand", + "offset": "Feinjustierung X", + "offsetX": "X-Achse", + "offsetY": "Y-Achse", + "lockedBranding": "Eigenes Wasserzeichen ist in diesem Paket gesperrt. Standard wird genutzt.", + "lockedDisabled": "Wasserzeichen sind in diesem Paket deaktiviert.", + "errors": { + "noAsset": "Bitte zuerst ein Wasserzeichen hochladen.", + "fileTooLarge": "Wasserzeichen muss kleiner als 3 MB sein." + } + }, "qr": { "title": "QR-Code & Druck-Layouts", "heroTitle": "Einlass-QR-Code", @@ -2043,5 +2205,57 @@ "empty": "Keine Benachrichtigungen vorhanden.", "filterByEvent": "Nach Event filtern", "unknownEvent": "Event" + }, + "guestMessages": { + "title": "Gästebenachrichtigungen", + "subtitle": "Schicke Push-Hinweise an Teilnehmende", + "composeTitle": "Nachricht senden", + "errorLoad": "Nachrichten konnten nicht geladen werden.", + "errorSend": "Nachricht konnte nicht gesendet werden.", + "sendSuccess": "Benachrichtigung an Gäste gesendet.", + "historyTitle": "Neueste Nachrichten", + "empty": "Noch keine Gästebenachrichtigungen.", + "status": { + "active": "Aktiv", + "draft": "Entwurf", + "archived": "Archiviert" + }, + "audience": { + "all": "Alle Gäste", + "guest": "Einzelner Gast" + }, + "type": { + "broadcast": "Broadcast", + "support_tip": "Support-Hinweis", + "upload_alert": "Upload-Status", + "achievement_major": "Achievement", + "photo_activity": "Foto-Aktivität", + "feedback_request": "Feedback-Anfrage" + }, + "history": { + "untitled": "Ohne Titel", + "noBody": "Kein Nachrichtentext." + }, + "form": { + "title": "Titel", + "titlePlaceholder": "Galerie-Erinnerung, Upload-Nudge ...", + "message": "Nachricht", + "messagePlaceholder": "Schreibe eine kurze Notiz an deine Gäste.", + "audience": "Zielgruppe", + "audienceAll": "Alle Gäste", + "audienceGuest": "Einzelner Gast (Name oder Gerät)", + "guestIdentifier": "Gastname oder Geräte-ID", + "guestPlaceholder": "z. B. Alex oder Gerätetoken", + "cta": "CTA (optional)", + "ctaLabel": "Button-Label", + "ctaUrl": "Button-Link", + "ctaHint": "Beide Felder werden benötigt, um einen Button zu senden.", + "ctaError": "CTA-Label und Link müssen zusammen ausgefüllt werden.", + "expiresIn": "Läuft ab in (Minuten)", + "priority": "Priorität", + "priorityValue": "Priorität {{value}}", + "send": "Benachrichtigung senden", + "validation": "Füge Titel, Nachricht und ggf. einen Ziel-Gast hinzu." + } } } diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index df345f1..8fc97cc 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -30,6 +30,14 @@ "more": "Unable to load more entries." }, "sections": { + "invoices": { + "title": "Invoices & payments", + "empty": "No payments found." + }, + "addOns": { + "title": "Add-ons", + "empty": "No add-ons booked." + }, "overview": { "title": "Package overview", "description": "Your active package and the most important metrics.", @@ -132,6 +140,15 @@ "loadMore": "Load more add-ons", "loadingMore": "Loading add-ons…" }, + "features": { + "branding": "Branding", + "watermark": "Watermark", + "maxPhotos": "Max photos", + "maxGuests": "Max guests", + "galleryDays": "Gallery days", + "maxTasks": "Max tasks", + "featureList": "Included features" + }, "packages": { "title": "Package history", "description": "Overview of current and past packages.", @@ -277,6 +294,7 @@ "qr": "QR code layouts", "images": "Image management", "guests": "Guest management", + "guestMessages": "Guest messages", "branding": "Branding & theme", "photobooth": "Photobooth", "recap": "Recap & archive" @@ -1802,8 +1820,60 @@ }, "errors": { "missingSlug": "No event slug provided.", - "loadFailed": "Tasks could not be loaded.", - "saveFailed": "Task could not be saved." + "missingType": "Event type is missing. Please save the event again in the admin.", + "loadFailed": "Event data could not be loaded.", + "saveFailed": "Event could not be saved." + }, + "branding": { + "titleShort": "Branding", + "previewTitle": "Guest app preview", + "previewSubtitle": "Current colors & fonts", + "primary": "Primary", + "accent": "Accent", + "colors": "Colors", + "primaryColor": "Primary color", + "accentColor": "Accent color", + "fonts": "Fonts", + "headingFont": "Headline font", + "bodyFont": "Body font", + "logo": "Logo", + "replaceLogo": "Replace logo", + "removeLogo": "Remove", + "logoHint": "Upload a logo to brand guest invites and QR posters.", + "uploadLogo": "Upload logo (max. 1 MB)", + "logoTooLarge": "Logo must be under 1 MB.", + "save": "Save branding", + "saving": "Saving...", + "saveSuccess": "Branding saved.", + "reset": "Reset to defaults", + "fontPicker": "Select font", + "noFonts": "No fonts found." + }, + "watermark": { + "tab": "Watermark", + "title": "Watermark", + "previewTitle": "Watermark Preview", + "mode": "Mode", + "modeBase": "Base watermark", + "modeCustom": "Custom watermark", + "modeOff": "Off", + "upload": "Upload watermark", + "uploadCta": "PNG/SVG/JPG (max. 3 MB)", + "replace": "Replace watermark", + "uploadHint": "PNG with transparency recommended.", + "placement": "Placement & size", + "size": "Size", + "opacity": "Opacity", + "padding": "Padding", + "offset": "Offset X", + "offsetX": "X-axis", + "offsetY": "Y-axis", + "lockedBranding": "Custom watermark locked by this package. Using base watermark.", + "lockedDisabled": "Watermarks are disabled for this package.", + "errors": { + "noAsset": "Please upload a watermark image first.", + "fileTooLarge": "Watermark must be under 3 MB." + } }, "tasks": { "disabledTitle": "Task mode is off for this event", @@ -1952,6 +2022,98 @@ "pref": {} }, "events": { + "detail": { + "kpi": { + "tasks": "Active tasks", + "guests": "Guests registered", + "photos": "Images uploaded" + }, + "pickEvent": "Select event", + "active": "Active", + "managementTitle": "Event management", + "dateTbd": "Date tbd", + "locationPlaceholder": "Location" + }, + "quick": { + "tasks": "Tasks & checklists", + "qr": "QR code layouts", + "images": "Image management", + "guests": "Guest management", + "guestMessages": "Guest messages", + "branding": "Branding & theme", + "photobooth": "Photobooth", + "recap": "Recap & archive" + }, + "status": { + "published": "Live", + "draft": "Draft", + "archived": "Archived" + }, + "errors": { + "missingSlug": "No event slug provided.", + "missingType": "Event type is missing. Please save the event again in the admin.", + "loadFailed": "Event data could not be loaded.", + "saveFailed": "Event could not be saved.", + "notFoundTitle": "Event not found", + "notFoundBody": "Without a valid identifier we cannot load data. Return to the event list and pick an event there.", + "toggleFailed": "Status could not be updated.", + "checkoutMissing": "Checkout could not be started.", + "checkoutFailed": "Add-on checkout failed." + }, + "placeholders": { + "untitled": "Untitled event" + }, + "branding": { + "titleShort": "Branding", + "previewTitle": "Guest app preview", + "previewSubtitle": "Current colors & fonts", + "primary": "Primary", + "accent": "Accent", + "colors": "Colors", + "primaryColor": "Primary color", + "accentColor": "Accent color", + "fonts": "Fonts", + "headingFont": "Headline font", + "bodyFont": "Body font", + "logo": "Logo", + "replaceLogo": "Replace logo", + "removeLogo": "Remove", + "logoHint": "Upload a logo to brand guest invites and QR posters.", + "uploadLogo": "Upload logo (max. 1 MB)", + "logoTooLarge": "Logo must be under 1 MB.", + "save": "Save branding", + "saving": "Saving...", + "saveSuccess": "Branding saved.", + "reset": "Reset to defaults", + "fontPicker": "Select font", + "noFonts": "No fonts found." + }, + "watermark": { + "tab": "Watermark", + "title": "Watermark", + "previewTitle": "Watermark Preview", + "mode": "Mode", + "modeBase": "Base watermark", + "modeCustom": "Custom watermark", + "modeOff": "Off", + "upload": "Upload watermark", + "uploadCta": "PNG/SVG/JPG (max. 3 MB)", + "replace": "Replace watermark", + "uploadHint": "PNG with transparency recommended.", + "placement": "Placement & size", + "size": "Size", + "opacity": "Opacity", + "padding": "Padding", + "offset": "Offset X", + "offsetX": "X-axis", + "offsetY": "Y-axis", + "lockedBranding": "Custom watermark locked by this package. Using base watermark.", + "lockedDisabled": "Watermarks are disabled for this package.", + "errors": { + "noAsset": "Please upload a watermark image first.", + "fileTooLarge": "Watermark must be under 3 MB." + } + }, "qr": { "title": "QR Code & Print Layouts", "heroTitle": "Entrance QR Code", @@ -2063,5 +2225,57 @@ "empty": "No notifications yet.", "filterByEvent": "Filter by event", "unknownEvent": "Event" + }, + "guestMessages": { + "title": "Guest messages", + "subtitle": "Send push updates to attendees", + "composeTitle": "Send a message", + "errorLoad": "Messages could not be loaded.", + "errorSend": "Message could not be sent.", + "sendSuccess": "Notification sent to guests.", + "historyTitle": "Recent messages", + "empty": "No guest messages yet.", + "status": { + "active": "Active", + "draft": "Draft", + "archived": "Archived" + }, + "audience": { + "all": "All guests", + "guest": "Specific guest" + }, + "type": { + "broadcast": "Broadcast", + "support_tip": "Support tip", + "upload_alert": "Upload alert", + "achievement_major": "Achievement", + "photo_activity": "Photo activity", + "feedback_request": "Feedback request" + }, + "history": { + "untitled": "Untitled", + "noBody": "No body provided." + }, + "form": { + "title": "Title", + "titlePlaceholder": "Gallery reminder, upload nudge, ...", + "message": "Message", + "messagePlaceholder": "Write a short note for your guests.", + "audience": "Audience", + "audienceAll": "All guests", + "audienceGuest": "Specific guest (name or device)", + "guestIdentifier": "Guest name or device ID", + "guestPlaceholder": "e.g., Alex or device token", + "cta": "CTA (optional)", + "ctaLabel": "Button label", + "ctaUrl": "Button link", + "ctaHint": "Both fields are required to add a button.", + "ctaError": "CTA label and link are required together.", + "expiresIn": "Expires in (minutes)", + "priority": "Priority", + "priorityValue": "Priority {{value}}", + "send": "Send notification", + "validation": "Add a title, message, and target guest when needed." + } } } diff --git a/resources/js/admin/lib/events.ts b/resources/js/admin/lib/events.ts index d2a46ca..26646fd 100644 --- a/resources/js/admin/lib/events.ts +++ b/resources/js/admin/lib/events.ts @@ -67,6 +67,11 @@ export function resolveEngagementMode(event?: TenantEvent | null): 'tasks' | 'ph return null; } +export function isBrandingAllowed(event?: TenantEvent | null): boolean { + if (!event) return true; + return Boolean((event.package as any)?.branding_allowed ?? true); +} + export function formatEventStatusLabel( status: TenantEvent['status'] | null, t: (key: string, options?: Record) => string, diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index 97a1157..1fe72d4 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -44,7 +44,8 @@ export default function MobileBillingPage() { setAddons(addonHistory.data ?? []); setError(null); } catch (err) { - setError(getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.'))); + const message = getApiErrorMessage(err, t('billing.errors.load', 'Konnte Abrechnung nicht laden.')); + setError(message); } finally { setLoading(false); } @@ -206,18 +207,71 @@ function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string {expires} ) : null} - + {t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining })} {pkg.price !== null && pkg.price !== undefined ? ( {formatAmount(pkg.price, pkg.currency ?? 'EUR')} ) : null} + {renderFeatureBadge(pkg, t, 'branding_allowed', t('billing.features.branding', 'Branding'))} + {renderFeatureBadge(pkg, t, 'watermark_allowed', t('billing.features.watermark', 'Watermark'))} + + + ); } +function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, label: string) { + const value = (pkg.package_limits as any)?.[key] ?? (pkg as any)[key]; + if (value === undefined || value === null) return null; + const enabled = value !== false; + return {enabled ? label : `${label} off`}; +} + +function FeatureList({ pkg }: { pkg: TenantPackageSummary }) { + const { t } = useTranslation('management'); + const limits = pkg.package_limits ?? {}; + const features = (pkg as any).features as string[] | undefined; + + const rows: Array<{ label: string; value: string }> = []; + + if (limits.max_photos !== undefined && limits.max_photos !== null) { + rows.push({ label: t('billing.features.maxPhotos', 'Max photos'), value: String(limits.max_photos) }); + } + if (limits.max_guests !== undefined && limits.max_guests !== null) { + rows.push({ label: t('billing.features.maxGuests', 'Max guests'), value: String(limits.max_guests) }); + } + if (limits.gallery_days !== undefined && limits.gallery_days !== null) { + rows.push({ label: t('billing.features.galleryDays', 'Gallery days'), value: String(limits.gallery_days) }); + } + if (limits.max_tasks !== undefined && limits.max_tasks !== null) { + rows.push({ label: t('billing.features.maxTasks', 'Max tasks'), value: String(limits.max_tasks) }); + } + if (Array.isArray(features) && features.length) { + rows.push({ label: t('billing.features.featureList', 'Included features'), value: features.join(', ') }); + } + + if (!rows.length) return null; + + return ( + + {rows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + + ); +} + function formatAmount(value: number | null | undefined, currency: string | null | undefined): string { if (value === null || value === undefined) { return '—'; diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 871f1e9..94433d7 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -1,15 +1,16 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save } from 'lucide-react'; +import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save, Droplets, Lock } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; -import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont } from '../api'; +import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; +import { isBrandingAllowed } from '../lib/events'; import { MobileSheet } from './components/Sheet'; import toast from 'react-hot-toast'; @@ -21,6 +22,31 @@ type BrandingForm = { logoDataUrl: string; }; +type WatermarkPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'middle-left' + | 'center' + | 'middle-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +type WatermarkForm = { + mode: 'base' | 'custom' | 'off'; + assetPath: string; + assetDataUrl: string; + position: WatermarkPosition; + opacity: number; + scale: number; + padding: number; + offsetX: number; + offsetY: number; +}; + +type TabKey = 'branding' | 'watermark'; + export default function MobileBrandingPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const slug = slugParam ?? null; @@ -35,6 +61,18 @@ export default function MobileBrandingPage() { bodyFont: '', logoDataUrl: '', }); + const [watermarkForm, setWatermarkForm] = React.useState({ + mode: 'base', + assetPath: '', + assetDataUrl: '', + position: 'bottom-right', + opacity: 0.25, + scale: 0.2, + padding: 16, + offsetX: 0, + offsetY: 0, + }); + const [activeTab, setActiveTab] = React.useState('branding'); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); @@ -52,6 +90,7 @@ export default function MobileBrandingPage() { const data = await getEvent(slug); setEvent(data); setForm(extractBranding(data)); + setWatermarkForm(extractWatermark(data)); setError(null); } catch (err) { if (!isAuthError(err)) { @@ -78,6 +117,9 @@ export default function MobileBrandingPage() { const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); const previewHeadingFont = form.headingFont || 'Montserrat'; const previewBodyFont = form.bodyFont || 'Montserrat'; + const watermarkAllowed = event?.package?.watermark_allowed !== false; + const brandingAllowed = isBrandingAllowed(event ?? null); + const watermarkLocked = watermarkAllowed && !brandingAllowed; async function handleSave() { if (!event?.slug) return; @@ -91,6 +133,14 @@ export default function MobileBrandingPage() { setSaving(true); setError(null); try { + if (watermarkAllowed && brandingAllowed && watermarkForm.mode === 'custom' && !watermarkForm.assetDataUrl && !watermarkForm.assetPath) { + const msg = t('events.watermark.errors.noAsset', 'Bitte lade zuerst ein Wasserzeichen hoch.'); + setError(msg); + setActiveTab('watermark'); + setSaving(false); + return; + } + const payload = { name: typeof event.name === 'string' ? event.name : renderName(event.name), slug: event.slug, @@ -130,6 +180,10 @@ export default function MobileBrandingPage() { } : null, }; + const watermarkPayload = buildWatermarkPayload(watermarkForm, watermarkAllowed, brandingAllowed); + if (watermarkPayload) { + settings.watermark = watermarkPayload; + } const updated = await updateEvent(event.slug, { ...payload, settings, @@ -158,9 +212,191 @@ export default function MobileBrandingPage() { function handleReset() { if (event) { setForm(extractBranding(event)); + setWatermarkForm(extractWatermark(event)); } } + function renderWatermarkTab() { + const policyLabel = watermarkAllowed ? 'basic' : 'none'; + const disabled = !watermarkAllowed; + const controlsLocked = watermarkLocked || disabled; + const mode = controlsLocked ? 'base' : watermarkForm.mode; + + return ( + <> + + + {t('events.watermark.previewTitle', 'Watermark Preview')} + + + + + {disabled ? ( + } + text={t('events.watermark.lockedDisabled', 'Kein Wasserzeichen in diesem Paket.')} + tone="danger" + /> + ) : null} + + {watermarkLocked ? ( + } + text={t('events.watermark.lockedBranding', 'Custom-Wasserzeichen ist im aktuellen Paket gesperrt. Standard wird genutzt.')} + /> + ) : null} + + + + {t('events.watermark.title', 'Wasserzeichen')} + + + { + if (controlsLocked) return; + if (value === 'custom' || value === 'base' || value === 'off') { + setWatermarkForm((prev) => ({ ...prev, mode: value as WatermarkForm['mode'] })); + } + }} + onPicker={() => undefined} + > + + + + {mode === 'custom' && !controlsLocked ? ( + + + {t('events.watermark.upload', 'Wasserzeichen hochladen')} + + document.getElementById('watermark-upload-input')?.click()}> + + + + {watermarkForm.assetPath + ? t('events.watermark.replace', 'Wasserzeichen ersetzen') + : t('events.watermark.uploadCta', 'PNG/SVG/JPG (max. 3 MB)')} + + + + { + const file = event.target.files?.[0]; + if (!file) return; + if (file.size > 3 * 1024 * 1024) { + setError(t('events.watermark.errors.fileTooLarge', 'Wasserzeichen muss kleiner als 3 MB sein.')); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : ''; + setWatermarkForm((prev) => ({ ...prev, assetDataUrl: result, assetPath: '' })); + setError(null); + }; + reader.readAsDataURL(file); + }} + /> + + {t('events.watermark.uploadHint', 'PNG mit transparenter Fläche empfohlen.')} + + + ) : null} + + + + + {t('events.watermark.placement', 'Position & Größe')} + + setWatermarkForm((prev) => ({ ...prev, position: next }))} + disabled={controlsLocked} + /> + setWatermarkForm((prev) => ({ ...prev, scale: value / 100 }))} + disabled={controlsLocked} + /> + setWatermarkForm((prev) => ({ ...prev, opacity: value / 100 }))} + disabled={controlsLocked} + /> + setWatermarkForm((prev) => ({ ...prev, padding: value }))} + disabled={controlsLocked} + /> + setWatermarkForm((prev) => ({ ...prev, offsetX: value }))} + disabled={controlsLocked} + suffix={t('events.watermark.offsetX', 'X-Achse')} + /> + setWatermarkForm((prev) => ({ ...prev, offsetY: value }))} + disabled={controlsLocked} + /> + + + ); + } + return ( ) : null} - - - {t('events.branding.previewTitle', 'Guest App Preview')} - - - - - - - {previewTitle} - - - {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} - - - - - - - - + + + setActiveTab('branding')} /> + setActiveTab('watermark')} /> + - - - {t('events.branding.colors', 'Colors')} - - setForm((prev) => ({ ...prev, primary: value }))} - /> - setForm((prev) => ({ ...prev, accent: value }))} - /> - - - - - {t('events.branding.fonts', 'Fonts')} - - setForm((prev) => ({ ...prev, headingFont: value }))} - onPicker={() => { - setFontField('heading'); - setShowFontsSheet(true); - }} - /> - setForm((prev) => ({ ...prev, bodyFont: value }))} - onPicker={() => { - setFontField('body'); - setShowFontsSheet(true); - }} - /> - - - - - {t('events.branding.logo', 'Logo')} - - - {form.logoDataUrl ? ( - <> - Logo - - document.getElementById('branding-logo-input')?.click()} - /> - setForm((prev) => ({ ...prev, logoDataUrl: '' }))}> - - - - {t('events.branding.removeLogo', 'Remove')} - - - - - - ) : ( - <> - - - {t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')} - - document.getElementById('branding-logo-input')?.click()}> - - - - {t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')} + {activeTab === 'branding' ? ( + <> + + + {t('events.branding.previewTitle', 'Guest App Preview')} + + + + + + + {previewTitle} - - - - )} - { - const file = event.target.files?.[0]; - if (!file) return; - if (file.size > 1024 * 1024) { - setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.')); - return; - } - const reader = new FileReader(); - reader.onload = () => { - const nextLogo = - typeof reader.result === 'string' - ? reader.result - : typeof reader.result === 'object' && reader.result !== null - ? String(reader.result) - : ''; - setForm((prev) => ({ ...prev, logoDataUrl: nextLogo })); - setError(null); - }; - reader.readAsDataURL(file); - }} - /> - - + + {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} + + + + + + + + + + + + + {t('events.branding.colors', 'Colors')} + + setForm((prev) => ({ ...prev, primary: value }))} + /> + setForm((prev) => ({ ...prev, accent: value }))} + /> + + + + + {t('events.branding.fonts', 'Fonts')} + + setForm((prev) => ({ ...prev, headingFont: value }))} + onPicker={() => { + setFontField('heading'); + setShowFontsSheet(true); + }} + /> + setForm((prev) => ({ ...prev, bodyFont: value }))} + onPicker={() => { + setFontField('body'); + setShowFontsSheet(true); + }} + /> + + + + + {t('events.branding.logo', 'Logo')} + + + {form.logoDataUrl ? ( + <> + Logo + + document.getElementById('branding-logo-input')?.click()} + /> + setForm((prev) => ({ ...prev, logoDataUrl: '' }))}> + + + + {t('events.branding.removeLogo', 'Remove')} + + + + + + ) : ( + <> + + + {t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')} + + document.getElementById('branding-logo-input')?.click()}> + + + + {t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')} + + + + + )} + { + const file = event.target.files?.[0]; + if (!file) return; + if (file.size > 1024 * 1024) { + setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.')); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const nextLogo = + typeof reader.result === 'string' + ? reader.result + : typeof reader.result === 'object' && reader.result !== null + ? String(reader.result) + : ''; + setForm((prev) => ({ ...prev, logoDataUrl: nextLogo })); + setError(null); + }; + reader.readAsDataURL(file); + }} + /> + + + + ) : ( + renderWatermarkTab() + )} handleSave()} /> @@ -432,6 +681,69 @@ function extractBranding(event: TenantEvent): BrandingForm { }; } +function extractWatermark(event: TenantEvent): WatermarkForm { + const settings = (event.settings as Record) ?? {}; + const wm = (settings.watermark as Record) ?? {}; + const readNumber = (key: string, fallback: number) => { + const value = wm[key]; + return typeof value === 'number' && !Number.isNaN(value) ? value : fallback; + }; + const readString = (key: string, fallback: string) => { + const value = wm[key]; + return typeof value === 'string' && value.trim() ? value : fallback; + }; + + const mode = (wm.mode === 'custom' || wm.mode === 'off') ? wm.mode : 'base'; + const position = readString('position', 'bottom-right') as WatermarkPosition; + + return { + mode, + assetPath: readString('asset', ''), + assetDataUrl: '', + position, + opacity: readNumber('opacity', 0.25), + scale: readNumber('scale', 0.2), + padding: readNumber('padding', 16), + offsetX: readNumber('offset_x', 0), + offsetY: readNumber('offset_y', 0), + }; +} + +function buildWatermarkPayload( + form: WatermarkForm, + watermarkAllowed: boolean, + brandingAllowed: boolean +): WatermarkSettings | null { + if (!watermarkAllowed) { + return { mode: 'off' }; + } + + const policy = watermarkAllowed ? 'basic' : 'none'; + const desiredMode = brandingAllowed ? form.mode : 'base'; + const mode = desiredMode === 'off' && policy === 'basic' ? 'base' : desiredMode; + + const payload: WatermarkSettings = { + mode, + position: form.position, + opacity: Math.min(1, Math.max(0, form.opacity)), + scale: Math.min(1, Math.max(0.05, form.scale)), + padding: Math.max(0, Math.round(form.padding)), + offset_x: Math.max(-500, Math.min(500, Math.round(form.offsetX))), + offset_y: Math.max(-500, Math.min(500, Math.round(form.offsetY))), + }; + + if (mode === 'custom' && brandingAllowed) { + if (form.assetDataUrl) { + payload.asset_data_url = form.assetDataUrl; + } + if (form.assetPath) { + payload.asset = form.assetPath; + } + } + + return payload; +} + function renderName(name: TenantEvent['name']): string { if (typeof name === 'string') return name; if (name && typeof name === 'object') { @@ -478,12 +790,14 @@ function InputField({ placeholder, onChange, onPicker, + children, }: { label: string; value: string; placeholder?: string; onChange: (next: string) => void; onPicker?: () => void; + children?: React.ReactNode; }) { return ( @@ -501,21 +815,23 @@ function InputField({ backgroundColor="white" space="$2" > - onChange(event.target.value)} - style={{ - flex: 1, - height: '100%', - border: 'none', - outline: 'none', - fontSize: 14, - background: 'transparent', - }} - onFocus={onPicker} - /> + {children ?? ( + onChange(event.target.value)} + style={{ + flex: 1, + height: '100%', + border: 'none', + outline: 'none', + fontSize: 14, + background: 'transparent', + }} + onFocus={onPicker} + /> + )} {onPicker ? ( @@ -525,3 +841,222 @@ function InputField({ ); } + +function LabeledSlider({ + label, + value, + min, + max, + step, + onChange, + disabled, + suffix, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + disabled?: boolean; + suffix?: string; +}) { + return ( + + + + {label} + + + {value} + {suffix ? ` ${suffix}` : ''} + + + onChange(Number(event.target.value))} + style={{ width: '100%' }} + /> + + ); +} + +function PositionGrid({ + value, + onChange, + disabled, +}: { + value: WatermarkPosition; + onChange: (value: WatermarkPosition) => void; + disabled?: boolean; +}) { + const positions: WatermarkPosition[] = [ + 'top-left', + 'top-center', + 'top-right', + 'middle-left', + 'center', + 'middle-right', + 'bottom-left', + 'bottom-center', + 'bottom-right', + ]; + + return ( + + + Position + +
+ {positions.map((pos) => ( + + ))} +
+
+ ); +} + +function WatermarkPreview({ + position, + scale, + opacity, + padding, + offsetX, + offsetY, +}: { + position: WatermarkPosition; + scale: number; + opacity: number; + padding: number; + offsetX: number; + offsetY: number; +}) { + const width = 280; + const height = 180; + const wmWidth = Math.max(24, Math.round(width * Math.min(1, Math.max(0.05, scale)))); + const wmHeight = Math.round(wmWidth * 0.4); + + const baseX = (() => { + switch (position) { + case 'top-center': + case 'center': + case 'bottom-center': + return (width - wmWidth) / 2; + case 'top-right': + case 'middle-right': + case 'bottom-right': + return width - wmWidth - padding; + default: + return padding; + } + })(); + + const baseY = (() => { + switch (position) { + case 'middle-left': + case 'center': + case 'middle-right': + return (height - wmHeight) / 2; + case 'bottom-left': + case 'bottom-center': + case 'bottom-right': + return height - wmHeight - padding; + default: + return padding; + } + })(); + + const x = Math.max(0, Math.min(width - wmWidth, baseX + offsetX)); + const y = Math.max(0, Math.min(height - wmHeight, baseY + offsetY)); + + return ( +
+
+
+
+ +
+
+
+ ); +} + +function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text: string; tone?: 'info' | 'danger' }) { + const background = tone === 'danger' ? '#fef2f2' : '#f1f5f9'; + const color = tone === 'danger' ? '#991b1b' : '#0f172a'; + + return ( + + + {icon} + + {text} + + + + ); +} + +function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + return ( + + + + {label} + + + + ); +} diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index a260ba6..436a8b7 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -11,7 +11,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge } from './compone import { adminPath } from '../constants'; import { useEventContext } from '../context/EventContext'; import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; -import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; +import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { useTheme } from '@tamagui/core'; export default function MobileDashboardPage() { @@ -469,7 +469,7 @@ function SecondaryGrid({ const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1'); const border = String(theme.borderColor?.val ?? '#334155'); const surface = String(theme.surface?.val ?? '#0b1220'); - const brandingAllowed = Boolean((event?.package as any)?.branding_allowed ?? true); + const brandingAllowed = isBrandingAllowed(event ?? null); const tiles = [ { icon: Users, diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index ef4b07d..33c78bc 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -1,19 +1,19 @@ import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil } from 'lucide-react'; +import { CalendarDays, MapPin, Settings, Users, Camera, Sparkles, QrCode, Image, Shield, Layout, RefreshCcw, Pencil, Megaphone } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell } from './components/MobileShell'; import { MobileCard, PillBadge, KpiTile, ActionTile } from './components/Primitives'; import { TenantEvent, EventStats, EventToolkit, getEvent, getEventStats, getEventToolkit, getEvents } from '../api'; -import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants'; +import { adminPath, ADMIN_EVENT_BRANDING_PATH, ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH, ADMIN_EVENT_INVITES_PATH, ADMIN_EVENT_MEMBERS_PATH, ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_TASKS_PATH } from '../constants'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { MobileSheet } from './components/Sheet'; import { useEventContext } from '../context/EventContext'; -import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; +import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { isPastEvent } from './eventDate'; export default function MobileEventDetailPage() { @@ -67,6 +67,7 @@ export default function MobileEventDetailPage() { }, [slug, t]); const tasksEnabled = resolveEngagementMode(event ?? activeEvent ?? null) !== 'photo_only'; + const brandingAllowed = isBrandingAllowed(event ?? activeEvent ?? null); const kpis = [ { @@ -248,11 +249,21 @@ export default function MobileEventDetailPage() { color="#4ade80" onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))} /> + slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))} + disabled={!slug} + /> navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`))} + onPress={ + brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined + } + disabled={!brandingAllowed} /> (); + const navigate = useNavigate(); + const { t, i18n } = useTranslation('management'); + const theme = useTheme(); + const { activeEvent, selectEvent } = useEventContext(); + const slug = slugParam ?? activeEvent?.slug ?? null; + const [history, setHistory] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [sending, setSending] = React.useState(false); + const [error, setError] = React.useState(null); + const [fallbackAttempted, setFallbackAttempted] = React.useState(false); + + const [form, setForm] = React.useState({ + title: '', + message: '', + audience: 'all', + guest_identifier: '', + cta_label: '', + cta_url: '', + expires_in_minutes: '', + priority: '1', + }); + + const inputStyle = React.useMemo(() => { + const border = String(theme.borderColor?.val ?? '#e5e7eb'); + const surface = String(theme.surface?.val ?? 'white'); + const text = String(theme.color?.val ?? '#111827'); + return { + width: '100%', + borderRadius: 10, + border: `1px solid ${border}`, + padding: '10px 12px', + fontSize: 13, + background: surface, + color: text, + }; + }, [theme]); + + React.useEffect(() => { + if (slugParam && activeEvent?.slug !== slugParam) { + selectEvent(slugParam); + } + }, [slugParam, activeEvent?.slug, selectEvent]); + + const loadHistory = React.useCallback(async () => { + if (!slug) { + if (!fallbackAttempted) { + setFallbackAttempted(true); + try { + const events = await getEvents({ force: true }); + const first = events[0] as TenantEvent | undefined; + if (first?.slug) { + selectEvent(first.slug); + navigate(adminPath(`/mobile/events/${first.slug}/guest-notifications`), { replace: true }); + } + } catch { + // ignore + } + } + setLoading(false); + setError(t('events.errors.missingSlug', 'No event selected.')); + return; + } + + setLoading(true); + setError(null); + try { + const notifications = await listGuestNotifications(slug); + setHistory(notifications); + } catch (err) { + if (!isAuthError(err)) { + const message = getApiErrorMessage(err, t('guestMessages.errorLoad', 'Messages could not be loaded.')); + setError(message); + toast.error(message); + } + } finally { + setLoading(false); + } + }, [slug, t, fallbackAttempted, selectEvent, navigate]); + + React.useEffect(() => { + void loadHistory(); + }, [loadHistory]); + + const canSend = + form.title.trim().length > 0 && + form.message.trim().length > 0 && + (form.audience === 'all' || form.guest_identifier.trim().length > 0); + + async function handleSend() { + if (!slug || sending) return; + + if (!canSend) { + const message = t('guestMessages.form.validation', 'Add a title, message, and target guest when needed.'); + setError(message); + toast.error(message); + return; + } + + const ctaLabel = form.cta_label.trim(); + const ctaUrl = form.cta_url.trim(); + if ((ctaLabel && !ctaUrl) || (!ctaLabel && ctaUrl)) { + const message = t('guestMessages.form.ctaError', 'CTA label and link are required together.'); + setError(message); + toast.error(message); + return; + } + + const payload: SendGuestNotificationPayload = { + title: form.title.trim(), + message: form.message.trim(), + audience: form.audience, + }; + + if (form.audience === 'guest' && form.guest_identifier.trim()) { + payload.guest_identifier = form.guest_identifier.trim(); + } + + if (ctaLabel && ctaUrl) { + payload.cta = { label: ctaLabel, url: ctaUrl }; + } + + if (form.expires_in_minutes.trim()) { + payload.expires_in_minutes = Number(form.expires_in_minutes); + } + + if (form.priority.trim()) { + payload.priority = Number(form.priority); + } + + setSending(true); + setError(null); + try { + const created = await sendGuestNotification(slug, payload); + setHistory((prev) => [created, ...prev]); + setForm((prev) => ({ + ...prev, + title: '', + message: '', + guest_identifier: '', + cta_label: '', + cta_url: '', + expires_in_minutes: '', + })); + toast.success(t('guestMessages.sendSuccess', 'Notification sent to guests.')); + } catch (err) { + if (!isAuthError(err)) { + const message = getApiErrorMessage(err, t('guestMessages.errorSend', 'Message could not be sent.')); + setError(message); + toast.error(message); + } + } finally { + setSending(false); + } + } + + const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; + const mutedText = String(theme.gray?.val ?? '#6b7280'); + + return ( + navigate(-1)} + headerActions={ + loadHistory()}> + + + } + > + {error ? ( + + + {error} + + + ) : null} + + + + {t('guestMessages.composeTitle', 'Send a message')} + + + + setForm((prev) => ({ ...prev, title: e.target.value }))} + placeholder={t('guestMessages.form.titlePlaceholder', 'Gallery reminder, upload nudge, ...')} + style={{ ...inputStyle, height: 40 }} + /> + + +