From 2f9a700e0012b15120fc6e34185886740453bf31 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 21 Jan 2026 21:35:40 +0100 Subject: [PATCH] Fix guest demo UX and enforce guest limits --- .../Controllers/Api/EventPublicController.php | 55 ++++++++- app/Services/EventJoinTokenService.php | 62 +++++++++- .../Packages/PackageLimitEvaluator.php | 44 ++++++- app/Services/Packages/PackageUsageTracker.php | 6 +- config/package-limits.php | 1 + database/seeders/DemoEventSeeder.php | 2 +- database/seeders/DemoPhotosSeeder.php | 31 +++-- database/seeders/SuperAdminSeeder.php | 5 + resources/js/admin/mobile/BrandingPage.tsx | 71 +++++++----- .../mobile/__tests__/packageShop.test.ts | 18 +++ resources/js/admin/mobile/lib/packageShop.ts | 18 ++- .../guest/components/DemoReadOnlyNotice.tsx | 57 ++++++++++ resources/js/guest/components/Header.tsx | 60 +++++++--- .../js/guest/context/EventBrandingContext.tsx | 107 +++++++++++++++--- .../__tests__/EventBrandingContext.test.tsx | 25 ++++ resources/js/guest/i18n/messages.ts | 8 ++ resources/js/guest/main.tsx | 22 ++-- resources/js/guest/pages/AchievementsPage.tsx | 8 +- resources/js/guest/pages/UploadPage.tsx | 94 ++++++++++++--- .../guest/pages/__tests__/BadgesGrid.test.tsx | 4 +- .../__tests__/UploadPageDemoMode.test.tsx | 80 +++++++++++++ .../guest/services/__tests__/eventApi.test.ts | 23 ++++ resources/js/guest/services/eventApi.ts | 10 +- resources/lang/de/api.php | 4 + resources/lang/en/api.php | 4 + tests/Feature/GuestJoinTokenFlowTest.php | 77 +++++++++++++ .../Services/PackageLimitEvaluatorTest.php | 30 +++++ .../Unit/Services/PackageUsageTrackerTest.php | 4 +- 28 files changed, 812 insertions(+), 118 deletions(-) create mode 100644 resources/js/guest/components/DemoReadOnlyNotice.tsx create mode 100644 resources/js/guest/pages/__tests__/UploadPageDemoMode.test.tsx create mode 100644 resources/js/guest/services/__tests__/eventApi.test.ts diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 457fcbd..381b92c 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -185,6 +185,57 @@ class EventPublicController extends BaseController ); } + $deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', '')); + $deviceId = $deviceId !== '' ? $deviceId : null; + + if ($event->id ?? null) { + $eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id); + if ($eventModel && $eventModel->tenant) { + $eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload( + $eventModel->tenant, + $eventModel->id, + $eventModel + ); + $maxGuests = $eventPackage?->effectiveGuestLimit(); + + if ($eventPackage && $maxGuests !== null) { + $grace = (int) config('package-limits.guest_grace', 10); + $hardLimit = $maxGuests + max(0, $grace); + $usedGuests = (int) $eventPackage->used_guests; + $isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip()); + + if ($usedGuests >= $hardLimit && ! $isReturningGuest) { + $this->recordTokenEvent( + $joinToken, + $request, + 'guest_limit_exceeded', + [ + 'event_id' => $eventModel->id, + 'used' => $usedGuests, + 'limit' => $maxGuests, + 'hard_limit' => $hardLimit, + ], + $token, + Response::HTTP_PAYMENT_REQUIRED + ); + + return ApiError::response( + 'guest_limit_exceeded', + __('api.packages.guest_limit_exceeded.title'), + __('api.packages.guest_limit_exceeded.message'), + Response::HTTP_PAYMENT_REQUIRED, + [ + 'event_id' => $eventModel->id, + 'used' => $usedGuests, + 'limit' => $maxGuests, + 'hard_limit' => $hardLimit, + ] + ); + } + } + } + } + RateLimiter::clear($rateLimiterKey); if (isset($event->status)) { @@ -1906,7 +1957,9 @@ class EventPublicController extends BaseController $policy = $this->guestPolicy(); if ($joinToken) { - $this->joinTokenService->incrementUsage($joinToken); + $deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', '')); + $deviceId = $deviceId !== '' ? $deviceId : null; + $this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip()); } $demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false); diff --git a/app/Services/EventJoinTokenService.php b/app/Services/EventJoinTokenService.php index 294835b..7d862c1 100644 --- a/app/Services/EventJoinTokenService.php +++ b/app/Services/EventJoinTokenService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\Event; use App\Models\EventJoinToken; +use App\Models\EventJoinTokenEvent; use App\Models\GuestPolicySetting; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; @@ -63,7 +64,7 @@ class EventJoinTokenService return $joinToken; } - public function incrementUsage(EventJoinToken $joinToken): void + public function incrementUsage(EventJoinToken $joinToken, ?string $deviceId = null, ?string $ipAddress = null): void { $joinToken->increment('usage_count'); @@ -78,6 +79,12 @@ class EventJoinTokenService $eventPackage = $limitEvaluator->resolveEventPackageForPhotoUpload($event->tenant, $event->id, $event); if ($eventPackage && $eventPackage->package?->max_guests !== null) { + $normalizedDeviceId = $this->normalizeDeviceId($deviceId); + $normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null; + if (! $this->shouldCountGuest($event->id, $normalizedDeviceId, $normalizedIp)) { + return; + } + $previous = (int) $eventPackage->used_guests; $eventPackage->increment('used_guests'); $eventPackage->refresh(); @@ -87,6 +94,28 @@ class EventJoinTokenService } } + public function hasSeenGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool + { + $normalizedDeviceId = $this->normalizeDeviceId($deviceId); + $normalizedIp = is_string($ipAddress) && trim($ipAddress) !== '' ? $ipAddress : null; + + if (! $normalizedDeviceId && ! $normalizedIp) { + return false; + } + + $query = EventJoinTokenEvent::query() + ->where('event_id', $eventId) + ->where('event_type', 'access_granted'); + + if ($normalizedDeviceId) { + $query->where('device_id', $normalizedDeviceId); + } else { + $query->where('ip_address', $normalizedIp); + } + + return $query->exists(); + } + public function findToken(string $token, bool $includeInactive = false): ?EventJoinToken { $hash = $this->hashToken($token); @@ -132,4 +161,35 @@ class EventJoinTokenService { return hash('sha256', $token); } + + private function normalizeDeviceId(?string $deviceId): ?string + { + if (! is_string($deviceId) || trim($deviceId) === '') { + return null; + } + + $cleaned = preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId) ?? ''; + $cleaned = substr($cleaned, 0, 64); + + return $cleaned !== '' ? $cleaned : null; + } + + private function shouldCountGuest(int $eventId, ?string $deviceId, ?string $ipAddress): bool + { + if (! $deviceId && ! $ipAddress) { + return false; + } + + $query = EventJoinTokenEvent::query() + ->where('event_id', $eventId) + ->where('event_type', 'access_granted'); + + if ($deviceId) { + $query->where('device_id', $deviceId); + } else { + $query->where('ip_address', $ipAddress); + } + + return $query->count() <= 1; + } } diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index 64674b5..98ae5d7 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -233,10 +233,13 @@ class PackageLimitEvaluator config('package-limits.photo_thresholds', []) ); - $guestSummary = $this->buildUsageSummary( - (int) $eventPackage->used_guests, - $limits['max_guests'], - config('package-limits.guest_thresholds', []) + $guestSummary = $this->applyGuestGrace( + $this->buildUsageSummary( + (int) $eventPackage->used_guests, + $limits['max_guests'], + config('package-limits.guest_thresholds', []) + ), + (int) $eventPackage->used_guests ); $gallerySummary = $this->buildGallerySummary( @@ -429,4 +432,37 @@ class PackageLimitEvaluator return $value; } + + /** + * @param array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} $summary + * @return array{limit: ?int, used: int, remaining: ?int, percentage: ?float, state: string, threshold_reached: ?float, next_threshold: ?float, thresholds: array} + */ + private function applyGuestGrace(array $summary, int $used): array + { + $limit = $summary['limit'] ?? null; + if ($limit === null || $limit <= 0) { + return $summary; + } + + $grace = (int) config('package-limits.guest_grace', 10); + $hardLimit = $limit + max(0, $grace); + + if ($used >= $hardLimit) { + $summary['state'] = 'limit_reached'; + $summary['threshold_reached'] = 1.0; + $summary['next_threshold'] = null; + $summary['remaining'] = 0; + + return $summary; + } + + if ($used >= $limit) { + $summary['state'] = 'warning'; + $summary['threshold_reached'] = 1.0; + $summary['next_threshold'] = null; + $summary['remaining'] = 0; + } + + return $summary; + } } diff --git a/app/Services/Packages/PackageUsageTracker.php b/app/Services/Packages/PackageUsageTracker.php index 7609940..ebb1691 100644 --- a/app/Services/Packages/PackageUsageTracker.php +++ b/app/Services/Packages/PackageUsageTracker.php @@ -58,6 +58,8 @@ class PackageUsageTracker } $newUsed = $eventPackage->used_guests; + $grace = (int) config('package-limits.guest_grace', 10); + $hardLimit = $limit + max(0, $grace); $thresholds = collect(config('package-limits.guest_thresholds', [])) ->filter(fn (float $value) => $value > 0 && $value < 1) @@ -80,8 +82,8 @@ class PackageUsageTracker } } - if ($newUsed >= $limit && ($previousUsed < $limit)) { - $this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $limit)); + if ($newUsed >= $hardLimit && ($previousUsed < $hardLimit)) { + $this->dispatcher->dispatch(new EventPackageGuestLimitReached($eventPackage, $hardLimit)); } } } diff --git a/config/package-limits.php b/config/package-limits.php index 3264341..4d03531 100644 --- a/config/package-limits.php +++ b/config/package-limits.php @@ -9,6 +9,7 @@ return [ 0.8, 0.95, ], + 'guest_grace' => 10, 'gallery_warning_days' => [ 7, 1, diff --git a/database/seeders/DemoEventSeeder.php b/database/seeders/DemoEventSeeder.php index 48e10b5..7fbe4cd 100644 --- a/database/seeders/DemoEventSeeder.php +++ b/database/seeders/DemoEventSeeder.php @@ -36,7 +36,7 @@ class DemoEventSeeder extends Seeder $events = [ [ 'slug' => 'demo-wedding-2025', - 'name' => ['de' => 'Demo Hochzeit 2025', 'en' => 'Demo Wedding 2025'], + 'name' => ['de' => 'Hochzeit von Klara & Ben', 'en' => "Klara & Ben's Wedding"], 'description' => ['de' => 'Demo-Event', 'en' => 'Demo event'], 'date' => Carbon::now()->addMonths(3), 'event_type' => $weddingType, diff --git a/database/seeders/DemoPhotosSeeder.php b/database/seeders/DemoPhotosSeeder.php index f245f9c..ceb5405 100644 --- a/database/seeders/DemoPhotosSeeder.php +++ b/database/seeders/DemoPhotosSeeder.php @@ -11,6 +11,7 @@ use App\Models\Tenant; use Carbon\Carbon; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -98,7 +99,7 @@ class DemoPhotosSeeder extends Seeder } $guestNames = $config['guest_names']; - $photosToSeed = min($photoFiles->count(), count($guestNames)); + $photosToSeed = min($photoFiles->count(), count($guestNames), 20); if ($photosToSeed === 0) { continue; @@ -143,23 +144,29 @@ class DemoPhotosSeeder extends Seeder $taskId = $taskIds ? $taskIds[array_rand($taskIds)] : null; $emotionId = $emotions->random()->id; + $photoData = [ + 'task_id' => $taskId, + 'emotion_id' => $emotionId, + 'guest_name' => $guestName, + 'thumbnail_path' => $thumbDest, + 'likes_count' => $likes, + 'is_featured' => $i === 0, + 'metadata' => ['demo' => true], + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]; + + if (Schema::hasColumn('photos', 'status')) { + $photoData['status'] = 'approved'; + } + $photo = Photo::updateOrCreate( [ 'tenant_id' => $tenant->id, 'event_id' => $event->id, 'file_path' => $destPath, ], - [ - 'task_id' => $taskId, - 'emotion_id' => $emotionId, - 'guest_name' => $guestName, - 'thumbnail_path' => $thumbDest, - 'likes_count' => $likes, - 'is_featured' => $i === 0, - 'metadata' => ['demo' => true], - 'created_at' => $timestamp, - 'updated_at' => $timestamp, - ] + $photoData ); PhotoLike::where('photo_id', $photo->id)->delete(); diff --git a/database/seeders/SuperAdminSeeder.php b/database/seeders/SuperAdminSeeder.php index 47d752e..98317ec 100644 --- a/database/seeders/SuperAdminSeeder.php +++ b/database/seeders/SuperAdminSeeder.php @@ -19,6 +19,7 @@ class SuperAdminSeeder extends Seeder 'last_name' => 'Admin', 'password' => Hash::make($password), 'role' => 'super_admin', + 'email_verified_at' => now(), ]); $tenantSlug = env('OWNER_TENANT_SLUG', 'owner-tenant'); @@ -58,5 +59,9 @@ class SuperAdminSeeder extends Seeder if ($user->tenant_id !== $tenant->id) { $user->forceFill(['tenant_id' => $tenant->id])->save(); } + + if (! $user->email_verified_at) { + $user->forceFill(['email_verified_at' => now()])->save(); + } } } diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index 58836cd..49734f3 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -17,25 +17,26 @@ import { MobileSheet } from './components/Sheet'; import toast from 'react-hot-toast'; import { adminPath } from '../constants'; import { useBackNavigation } from './hooks/useBackNavigation'; -import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme'; +import { ADMIN_GRADIENTS, useAdminTheme } from './theme'; import { extractBrandingForm, type BrandingFormValues } from '../lib/brandingForm'; -import { getContrastingTextColor } from '@/guest/lib/color'; +import { DEFAULT_EVENT_BRANDING } from '@/guest/context/EventBrandingContext'; +import { getContrastingTextColor, relativeLuminance } from '@/guest/lib/color'; const BRANDING_FORM_DEFAULTS = { - primary: ADMIN_COLORS.primary, - accent: ADMIN_COLORS.accent, - background: '#ffffff', - surface: '#ffffff', - mode: 'auto' as const, - buttonStyle: 'filled' as const, - buttonRadius: 12, - buttonPrimary: ADMIN_COLORS.primary, - buttonSecondary: ADMIN_COLORS.accent, - linkColor: ADMIN_COLORS.accent, - fontSize: 'm' as const, - logoMode: 'upload' as const, - logoPosition: 'left' as const, - logoSize: 'm' as const, + primary: DEFAULT_EVENT_BRANDING.primaryColor, + accent: DEFAULT_EVENT_BRANDING.secondaryColor, + background: DEFAULT_EVENT_BRANDING.backgroundColor, + surface: DEFAULT_EVENT_BRANDING.palette?.surface ?? DEFAULT_EVENT_BRANDING.backgroundColor, + mode: DEFAULT_EVENT_BRANDING.mode ?? 'auto', + buttonStyle: DEFAULT_EVENT_BRANDING.buttons?.style ?? 'filled', + buttonRadius: DEFAULT_EVENT_BRANDING.buttons?.radius ?? 12, + buttonPrimary: DEFAULT_EVENT_BRANDING.buttons?.primary ?? DEFAULT_EVENT_BRANDING.primaryColor, + buttonSecondary: DEFAULT_EVENT_BRANDING.buttons?.secondary ?? DEFAULT_EVENT_BRANDING.secondaryColor, + linkColor: DEFAULT_EVENT_BRANDING.buttons?.linkColor ?? DEFAULT_EVENT_BRANDING.secondaryColor, + fontSize: DEFAULT_EVENT_BRANDING.typography?.sizePreset ?? 'm', + logoMode: DEFAULT_EVENT_BRANDING.logo?.mode ?? 'emoticon', + logoPosition: DEFAULT_EVENT_BRANDING.logo?.position ?? 'left', + logoSize: DEFAULT_EVENT_BRANDING.logo?.size ?? 'm', }; const BRANDING_FORM_BASE: BrandingFormValues = { @@ -176,10 +177,22 @@ export default function MobileBrandingPage() { }, [tenantBrandingLoaded]); const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form; + const previewBackground = previewForm.background; + const previewSurfaceCandidate = previewForm.surface || previewBackground; + const backgroundLuminance = relativeLuminance(previewBackground); + const surfaceLuminance = relativeLuminance(previewSurfaceCandidate); + const previewSurface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06 + ? backgroundLuminance >= 0.6 + ? '#ffffff' + : '#0f172a' + : previewSurfaceCandidate; + const previewForeground = backgroundLuminance >= 0.6 ? '#1f2937' : '#f8fafc'; + const previewMutedForeground = backgroundLuminance >= 0.6 ? '#6b7280' : '#cbd5e1'; + const previewBorder = backgroundLuminance >= 0.6 ? '#e6d9d6' : '#334155'; const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event'); - const previewHeadingFont = previewForm.headingFont || 'Archivo Black'; - const previewBodyFont = previewForm.bodyFont || 'Manrope'; - const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a'); + const previewHeadingFont = previewForm.headingFont || DEFAULT_EVENT_BRANDING.typography?.heading || DEFAULT_EVENT_BRANDING.fontFamily; + const previewBodyFont = previewForm.bodyFont || DEFAULT_EVENT_BRANDING.typography?.body || DEFAULT_EVENT_BRANDING.fontFamily; + const previewSurfaceText = previewForeground; const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1; const previewButtonColor = previewForm.buttonPrimary || previewForm.primary; const previewButtonText = getContrastingTextColor(previewButtonColor, '#ffffff', '#0f172a'); @@ -560,8 +573,8 @@ export default function MobileBrandingPage() { {t('events.branding.previewTitle', 'Guest App Preview')} - - + + {t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')} - - - - + + + +
- + {label} diff --git a/resources/js/admin/mobile/__tests__/packageShop.test.ts b/resources/js/admin/mobile/__tests__/packageShop.test.ts index 9dedb2b..6190607 100644 --- a/resources/js/admin/mobile/__tests__/packageShop.test.ts +++ b/resources/js/admin/mobile/__tests__/packageShop.test.ts @@ -34,6 +34,24 @@ describe('classifyPackageChange', () => { const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any; expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true }); }); + + it('treats unlimited sharing as an upgrade over limited sharing', () => { + const starter = { ...active, id: 5, features: ['limited_sharing'], max_photos: 100 } as any; + const standard = { ...active, id: 6, features: ['unlimited_sharing'], max_photos: 100 } as any; + expect(classifyPackageChange(standard, starter)).toEqual({ isUpgrade: true, isDowngrade: false }); + }); + + it('treats watermark removal as an upgrade over base watermark', () => { + const base = { ...active, id: 7, watermark_allowed: false, features: [] } as any; + const removal = { ...active, id: 8, watermark_allowed: true, features: ['no_watermark'] } as any; + expect(classifyPackageChange(removal, base)).toEqual({ isUpgrade: true, isDowngrade: false }); + }); + + it('keeps watermark custom as higher than watermark removal', () => { + const custom = { ...active, id: 9, watermark_allowed: true, features: [] } as any; + const removal = { ...active, id: 10, watermark_allowed: true, features: ['no_watermark'] } as any; + expect(classifyPackageChange(removal, custom)).toEqual({ isUpgrade: false, isDowngrade: true }); + }); }); describe('selectRecommendedPackageId', () => { diff --git a/resources/js/admin/mobile/lib/packageShop.ts b/resources/js/admin/mobile/lib/packageShop.ts index b4bfb3a..cc7df51 100644 --- a/resources/js/admin/mobile/lib/packageShop.ts +++ b/resources/js/admin/mobile/lib/packageShop.ts @@ -63,6 +63,20 @@ function collectFeatures(pkg: Package | null): Set { return new Set(getEnabledPackageFeatures(pkg)); } +const FEATURE_COMPATIBILITY: Record = { + limited_sharing: ['limited_sharing', 'unlimited_sharing'], + watermark_base: ['watermark_base', 'watermark_custom', 'no_watermark'], +}; + +function featureSatisfied(feature: string, candidateFeatures: Set): boolean { + const compatible = FEATURE_COMPATIBILITY[feature]; + if (compatible) { + return compatible.some((value) => candidateFeatures.has(value)); + } + + return candidateFeatures.has(feature); +} + function compareLimit(candidate: number | null, active: number | null): number { if (active === null) { return candidate === null ? 0 : -1; @@ -89,8 +103,8 @@ export function classifyPackageChange(pkg: Package, active: Package | null): Pac const activeFeatures = collectFeatures(active); const candidateFeatures = collectFeatures(pkg); - const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature)); - const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature)); + const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !featureSatisfied(feature, activeFeatures)); + const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !featureSatisfied(feature, candidateFeatures)); const limitKeys: Array = ['max_photos', 'max_guests', 'gallery_days']; let hasLimitUpgrade = false; diff --git a/resources/js/guest/components/DemoReadOnlyNotice.tsx b/resources/js/guest/components/DemoReadOnlyNotice.tsx new file mode 100644 index 0000000..bc32bed --- /dev/null +++ b/resources/js/guest/components/DemoReadOnlyNotice.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { motion, type HTMLMotionProps } from 'framer-motion'; +import { ZapOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export type DemoReadOnlyNoticeProps = { + title: string; + copy: string; + hint?: string; + ctaLabel?: string; + onCta?: () => void; + radius?: number; + bodyFont?: string; + motionProps?: HTMLMotionProps<'div'>; +}; + +export default function DemoReadOnlyNotice({ + title, + copy, + hint, + ctaLabel, + onCta, + radius, + bodyFont, + motionProps, +}: DemoReadOnlyNoticeProps) { + return ( + +
+
+ +
+
+

{title}

+

{copy}

+ {hint ?

{hint}

: null} +
+
+ {ctaLabel && onCta ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/resources/js/guest/components/Header.tsx b/resources/js/guest/components/Header.tsx index 0bac9ad..c780d6d 100644 --- a/resources/js/guest/components/Header.tsx +++ b/resources/js/guest/components/Header.tsx @@ -40,10 +40,10 @@ const EVENT_ICON_COMPONENTS: Record = { - s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4' }, - m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5' }, - l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6' }, +const LOGO_SIZE_CLASSES: Record = { + s: { container: 'h-8 w-8', image: 'h-7 w-7', emoji: 'text-lg', icon: 'h-4 w-4', initials: 'text-[11px]' }, + m: { container: 'h-10 w-10', image: 'h-9 w-9', emoji: 'text-xl', icon: 'h-5 w-5', initials: 'text-sm' }, + l: { container: 'h-12 w-12', image: 'h-11 w-11', emoji: 'text-2xl', icon: 'h-6 w-6', initials: 'text-base' }, }; function getLogoClasses(size?: LogoSize) { @@ -81,18 +81,36 @@ function getInitials(name: string): string { return name.substring(0, 2).toUpperCase(); } -function renderEventAvatar( - name: string, - icon: unknown, - accentColor: string, - textColor: string, - logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize } -) { +function EventAvatar({ + name, + icon, + accentColor, + textColor, + logo, +}: { + name: string; + icon: unknown; + accentColor: string; + textColor: string; + logo?: { mode: 'emoticon' | 'upload'; value: string | null; size?: LogoSize }; +}) { + const logoValue = logo?.mode === 'upload' ? (logo.value?.trim() || null) : null; + const [logoFailed, setLogoFailed] = React.useState(false); + + React.useEffect(() => { + setLogoFailed(false); + }, [logoValue]); + const sizes = getLogoClasses(logo?.size); - if (logo?.mode === 'upload' && logo.value) { + if (logo?.mode === 'upload' && logoValue && !logoFailed) { return (
- {name} + {name} setLogoFailed(true)} + />
); } @@ -140,7 +158,7 @@ function renderEventAvatar( return (
{getInitials(name)} @@ -248,20 +266,26 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string : 'flex items-center gap-3' } > - {renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)} +
{event.name}
-
+
{stats && tasksEnabled && ( <> {`${stats.onlineGuests} ${t('header.stats.online')}`} - | + | {stats.tasksSolved}{' '} {t('header.stats.tasksSolved')} @@ -356,7 +380,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt ref={buttonRef} type="button" onClick={onToggle} - className="relative rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30" + className="relative rounded-full bg-white/15 p-2 text-current transition hover:bg-white/30" aria-label={open ? t('header.notifications.close', 'Benachrichtigungen schließen') : t('header.notifications.open', 'Benachrichtigungen anzeigen')} > diff --git a/resources/js/guest/context/EventBrandingContext.tsx b/resources/js/guest/context/EventBrandingContext.tsx index b745906..ac9611a 100644 --- a/resources/js/guest/context/EventBrandingContext.tsx +++ b/resources/js/guest/context/EventBrandingContext.tsx @@ -1,5 +1,7 @@ import React, { createContext, useContext, useEffect, useMemo } from 'react'; import type { EventBranding } from '../types/event-branding'; +import { useAppearance } from '@/hooks/use-appearance'; +import { getContrastingTextColor, relativeLuminance } from '../lib/color'; type EventBrandingContextValue = { branding: EventBranding; @@ -7,16 +9,16 @@ type EventBrandingContextValue = { }; export const DEFAULT_EVENT_BRANDING: EventBranding = { - primaryColor: '#FF5A5F', - secondaryColor: '#FFF8F5', - backgroundColor: '#FFF8F5', + primaryColor: '#E94B5A', + secondaryColor: '#F7C7CF', + backgroundColor: '#FFF6F2', fontFamily: 'Montserrat, Inter, "Helvetica Neue", system-ui, -apple-system, BlinkMacSystemFont, sans-serif', logoUrl: null, palette: { - primary: '#FF5A5F', - secondary: '#FFF8F5', - background: '#FFF8F5', - surface: '#FFF8F5', + primary: '#E94B5A', + secondary: '#F7C7CF', + background: '#FFF6F2', + surface: '#FFFFFF', }, typography: { heading: 'Playfair Display, "Times New Roman", serif', @@ -114,15 +116,46 @@ function applyCssVariables(branding: EventBranding) { } const root = document.documentElement; + const background = branding.backgroundColor; + const surfaceCandidate = branding.palette?.surface ?? background; + const backgroundLuminance = relativeLuminance(background); + const surfaceLuminance = relativeLuminance(surfaceCandidate); + const surface = Math.abs(surfaceLuminance - backgroundLuminance) < 0.06 + ? backgroundLuminance >= 0.6 + ? '#ffffff' + : '#0f172a' + : surfaceCandidate; + const isLight = backgroundLuminance >= 0.6; + const foreground = isLight ? '#1f2937' : '#f8fafc'; + const mutedForeground = isLight ? '#6b7280' : '#cbd5e1'; + const muted = isLight ? '#f6efec' : '#1f2937'; + const border = isLight ? '#e6d9d6' : '#334155'; + const input = isLight ? '#eadfda' : '#273247'; + const primaryForeground = getContrastingTextColor(branding.primaryColor, '#ffffff', '#0f172a'); + const secondaryForeground = getContrastingTextColor(branding.secondaryColor, '#ffffff', '#0f172a'); root.style.setProperty('--guest-primary', branding.primaryColor); root.style.setProperty('--guest-secondary', branding.secondaryColor); - root.style.setProperty('--guest-background', branding.backgroundColor); - root.style.setProperty('--guest-surface', branding.palette?.surface ?? branding.backgroundColor); + root.style.setProperty('--guest-background', background); + root.style.setProperty('--guest-surface', surface); root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`); root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`); root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor); root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled'); root.style.setProperty('--guest-font-scale', String(FONT_SCALE_MAP[branding.typography?.sizePreset ?? 'm'] ?? 1)); + root.style.setProperty('--foreground', foreground); + root.style.setProperty('--card-foreground', foreground); + root.style.setProperty('--popover-foreground', foreground); + root.style.setProperty('--muted', muted); + root.style.setProperty('--muted-foreground', mutedForeground); + root.style.setProperty('--border', border); + root.style.setProperty('--input', input); + root.style.setProperty('--primary', branding.primaryColor); + root.style.setProperty('--primary-foreground', primaryForeground); + root.style.setProperty('--secondary', branding.secondaryColor); + root.style.setProperty('--secondary-foreground', secondaryForeground); + root.style.setProperty('--accent', branding.secondaryColor); + root.style.setProperty('--accent-foreground', secondaryForeground); + root.style.setProperty('--ring', branding.primaryColor); const headingFont = branding.typography?.heading ?? branding.fontFamily; const bodyFont = branding.typography?.body ?? branding.fontFamily; @@ -160,9 +193,27 @@ function resetCssVariables() { root.style.removeProperty('--guest-font-family'); root.style.removeProperty('--guest-body-font'); root.style.removeProperty('--guest-heading-font'); + root.style.removeProperty('--foreground'); + root.style.removeProperty('--card-foreground'); + root.style.removeProperty('--popover-foreground'); + root.style.removeProperty('--muted'); + root.style.removeProperty('--muted-foreground'); + root.style.removeProperty('--border'); + root.style.removeProperty('--input'); + root.style.removeProperty('--primary'); + root.style.removeProperty('--primary-foreground'); + root.style.removeProperty('--secondary'); + root.style.removeProperty('--secondary-foreground'); + root.style.removeProperty('--accent'); + root.style.removeProperty('--accent-foreground'); + root.style.removeProperty('--ring'); } -function applyThemeMode(mode: EventBranding['mode']) { +function applyThemeMode( + mode: EventBranding['mode'], + backgroundColor: string, + appearanceOverride: 'light' | 'dark' | null, +) { if (typeof document === 'undefined') { return; } @@ -171,6 +222,12 @@ function applyThemeMode(mode: EventBranding['mode']) { const prefersDark = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)').matches : false; + const backgroundLuminance = relativeLuminance(backgroundColor || DEFAULT_EVENT_BRANDING.backgroundColor); + const backgroundPrefers = backgroundLuminance >= 0.65 + ? 'light' + : backgroundLuminance <= 0.35 + ? 'dark' + : null; const applyDark = () => root.classList.add('dark'); const applyLight = () => root.classList.remove('dark'); @@ -186,6 +243,28 @@ function applyThemeMode(mode: EventBranding['mode']) { return; } + if (appearanceOverride) { + if (appearanceOverride === 'dark') { + applyDark(); + root.style.colorScheme = 'dark'; + } else { + applyLight(); + root.style.colorScheme = 'light'; + } + return; + } + + if (backgroundPrefers) { + if (backgroundPrefers === 'dark') { + applyDark(); + root.style.colorScheme = 'dark'; + } else { + applyLight(); + root.style.colorScheme = 'light'; + } + return; + } + if (prefersDark) { applyDark(); root.style.colorScheme = 'dark'; @@ -204,6 +283,8 @@ export function EventBrandingProvider({ children: React.ReactNode; }) { const resolved = useMemo(() => resolveBranding(branding), [branding]); + const { appearance } = useAppearance(); + const appearanceOverride = appearance === 'light' || appearance === 'dark' ? appearance : null; useEffect(() => { if (typeof document !== 'undefined') { @@ -211,7 +292,7 @@ export function EventBrandingProvider({ } applyCssVariables(resolved); const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false; - applyThemeMode(resolved.mode ?? 'auto'); + applyThemeMode(resolved.mode ?? 'auto', resolved.backgroundColor, appearanceOverride); return () => { if (typeof document !== 'undefined') { @@ -224,9 +305,9 @@ export function EventBrandingProvider({ } resetCssVariables(); applyCssVariables(DEFAULT_EVENT_BRANDING); - applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto'); + applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto', DEFAULT_EVENT_BRANDING.backgroundColor, appearanceOverride); }; - }, [resolved]); + }, [appearanceOverride, resolved]); const value = useMemo(() => ({ branding: resolved, diff --git a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx index 7e3d73c..0562be9 100644 --- a/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx +++ b/resources/js/guest/context/__tests__/EventBrandingContext.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { EventBrandingProvider } from '../EventBrandingContext'; +import { AppearanceProvider } from '@/hooks/use-appearance'; import type { EventBranding } from '../../types/event-branding'; const sampleBranding: EventBranding = { @@ -23,6 +24,7 @@ describe('EventBrandingProvider', () => { document.documentElement.style.removeProperty('color-scheme'); document.documentElement.style.removeProperty('--guest-background'); document.documentElement.style.removeProperty('--guest-font-scale'); + localStorage.removeItem('theme'); }); it('applies guest theme classes and variables', async () => { @@ -44,4 +46,27 @@ describe('EventBrandingProvider', () => { expect(document.documentElement.classList.contains('guest-theme')).toBe(false); }); + + it('respects appearance override in auto mode', async () => { + localStorage.setItem('theme', 'dark'); + const autoBranding: EventBranding = { + ...sampleBranding, + mode: 'auto', + backgroundColor: '#fff7ed', + }; + + const { unmount } = render( + + +
Guest
+
+
+ ); + + await waitFor(() => { + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + unmount(); + }); }); diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 63d4e0a..f3a938b 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -106,6 +106,10 @@ export const messages: Record = { description: 'Es gab sehr viele Aufrufe in kurzer Zeit. Warte kurz und versuche es erneut.', hint: 'Tipp: Du kannst es gleich noch einmal versuchen.', }, + guest_limit_exceeded: { + title: 'Gäste-Limit erreicht', + description: 'Dieses Event hat sein Gäste-Limit erreicht. Bitte wende dich an die Veranstalter:innen.', + }, gallery_expired: { title: 'Galerie nicht mehr verfügbar', description: 'Die Galerie zu diesem Event ist nicht mehr zugänglich.', @@ -846,6 +850,10 @@ export const messages: Record = { description: 'There were too many requests in a short time. Please wait a moment and try again.', hint: 'Tip: You can retry shortly.', }, + guest_limit_exceeded: { + title: 'Guest limit reached', + description: 'This event has reached its guest allowance. Please contact the organisers.', + }, gallery_expired: { title: 'Gallery unavailable', description: 'The gallery for this event is no longer accessible.', diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx index 5efe91e..6c445b3 100644 --- a/resources/js/guest/main.tsx +++ b/resources/js/guest/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import '../../css/app.css'; import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode'; import { Sentry, initSentry } from '@/lib/sentry'; +import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance'; const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
@@ -11,6 +12,7 @@ const GuestFallback: React.FC<{ message: string }> = ({ message }) => ( ); initSentry('guest'); +initializeTheme(); if (shouldEnableGuestDemoMode()) { enableGuestDemoMode(); } @@ -47,15 +49,17 @@ const appRoot = async () => { createRoot(rootEl).render( }> - - - - - }> - - - - + + + + + + }> + + + + + ); diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx index 07d7639..3fcad8f 100644 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ b/resources/js/guest/pages/AchievementsPage.tsx @@ -71,7 +71,7 @@ function Leaderboard({ title, description, icon: Icon, entries, emptyCopy, forma ) : (
    {entries.map((entry, index) => ( -
  1. +
  2. #{index + 1} {entry.guest || t('achievements.leaderboard.guestFallback')} @@ -129,7 +129,7 @@ export function BadgesGrid({ badges, t }: BadgesGridProps) { 'relative overflow-hidden rounded-2xl border px-4 py-3 shadow-sm transition', badge.earned ? 'border-emerald-400/40 bg-gradient-to-br from-emerald-500/20 via-emerald-500/5 to-white text-emerald-900 dark:border-emerald-400/30 dark:from-emerald-400/20 dark:via-emerald-400/10 dark:to-slate-950/70 dark:text-emerald-50' - : 'border-border/60 bg-white/80 dark:border-slate-800/70 dark:bg-slate-950/60', + : 'border-border/60 bg-card/90', )} >
    @@ -165,7 +165,7 @@ function Timeline({ points, t, formatNumber }: TimelineProps) { {points.map((point) => ( -
    +
    {point.date} {t('achievements.timeline.row', { photos: formatNumber(point.photos), guests: formatNumber(point.guests) })} @@ -210,7 +210,7 @@ function Feed({ feed, t, formatRelativeTime, locale, formatNumber }: FeedProps) {feed.map((item) => { const taskLabel = localizeTaskLabel(item.task ?? null, locale); return ( -
    +
    {item.thumbnail ? ( {t('achievements.feed.thumbnailAlt')} ) : ( diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 42c5d6e..9b38511 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -34,11 +34,13 @@ import { ZapOff, } from 'lucide-react'; import { getEventPackage, type EventPackage } from '../services/eventApi'; +import { isGuestDemoModeEnabled } from '../demo/demoMode'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries'; import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog'; import { useEventStats } from '../context/EventStatsContext'; import { useEventBranding } from '../context/EventBrandingContext'; +import DemoReadOnlyNotice from '../components/DemoReadOnlyNotice'; import { compressPhoto, formatBytes } from '../lib/image'; import { FADE_SCALE, FADE_UP, prefersReducedMotion } from '../lib/motion'; import { useGuestIdentity } from '../context/GuestIdentityContext'; @@ -149,7 +151,8 @@ export default function UploadPage() { const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const uploadsRequireApproval = (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; - const demoReadOnly = Boolean(event?.demo_read_only); + const demoModeActive = isGuestDemoModeEnabled(); + const demoReadOnly = Boolean(event?.demo_read_only) || demoModeActive; const liveShowModeration = event?.live_show?.moderation_mode ?? 'manual'; const motionEnabled = !prefersReducedMotion(); const overlayMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_SCALE } : {}; @@ -283,6 +286,10 @@ const [submitToLive, setSubmitToLive] = useState(true); if (!eventKey) return '/tasks'; return `/e/${encodeURIComponent(eventKey)}/tasks`; }, [eventKey]); + const demoGalleryUrl = useMemo(() => { + if (!eventKey) return '/gallery'; + return `/e/${encodeURIComponent(eventKey)}/gallery`; + }, [eventKey]); // Load preferences from storage useEffect(() => { @@ -406,8 +413,8 @@ const [submitToLive, setSubmitToLive] = useState(true); const checkLimits = async () => { if (demoReadOnly) { - setCanUpload(false); - setUploadError(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.')); + setCanUpload(true); + setUploadError(null); setUploadWarning(null); return; } @@ -491,6 +498,12 @@ const [submitToLive, setSubmitToLive] = useState(true); ); const startCamera = useCallback(async () => { + if (demoReadOnly) { + setPermissionState('idle'); + setPermissionMessage(null); + stopStream(); + return; + } if (!supportsCamera) { setPermissionState('unsupported'); setPermissionMessage(t('upload.cameraUnsupported.message')); @@ -530,7 +543,7 @@ const [submitToLive, setSubmitToLive] = useState(true); setPermissionMessage(t('upload.cameraError.explanation')); } } - }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]); + }, [attachStreamToVideo, createConstraint, demoReadOnly, mode, preferences.facingMode, stopStream, supportsCamera, t]); const handleRecheckCamera = useCallback(() => { if (isCameraBlockedByPolicy()) { @@ -741,7 +754,13 @@ const [submitToLive, setSubmitToLive] = useState(true); ); const handleUsePhoto = useCallback(async () => { - if (!eventKey || !reviewPhoto || !canUpload) return; + if (!eventKey || !reviewPhoto) return; + if (demoReadOnly) { + setUploadWarning(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.')); + setUploadError(null); + return; + } + if (!canUpload) return; setMode('uploading'); setUploadProgress(2); setUploadError(null); @@ -849,9 +868,14 @@ const [submitToLive, setSubmitToLive] = useState(true); } finally { setStatusMessage(''); } - }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti, submitToLive]); + }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti, submitToLive, demoReadOnly]); const handleGalleryPick = useCallback(async (event: React.ChangeEvent) => { + if (demoReadOnly) { + setUploadWarning(t('upload.demoReadOnly', 'Uploads sind in der Demo deaktiviert.')); + setUploadError(null); + return; + } if (!canUpload) return; const file = event.target.files?.[0]; if (!file) return; @@ -892,7 +916,7 @@ const [submitToLive, setSubmitToLive] = useState(true); setMode('review'); setStatusMessage(''); event.target.value = ''; - }, [canUpload, t]); + }, [canUpload, t, demoReadOnly]); const emotionLabel = useMemo(() => { if (task?.emotion?.name) return task.emotion.name; @@ -914,7 +938,7 @@ const [submitToLive, setSubmitToLive] = useState(true); } }, [task]); - const isCameraActive = permissionState === 'granted' && mode !== 'uploading'; + const isCameraActive = !demoReadOnly && permissionState === 'granted' && mode !== 'uploading'; const showTaskOverlay = task && mode !== 'uploading'; const relativeLastUpload = useMemo( @@ -964,12 +988,15 @@ const [submitToLive, setSubmitToLive] = useState(true); const handlePrimaryAction = useCallback(() => { setShowHeroOverlay(false); + if (demoReadOnly) { + return; + } if (!isCameraActive) { startCamera(); return; } beginCapture(); - }, [beginCapture, isCameraActive, startCamera]); + }, [beginCapture, demoReadOnly, isCameraActive, startCamera]); const taskFloatingCard = showTaskOverlay && task ? ( ); @@ -1116,7 +1144,7 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ ); const renderPermissionNotice = () => { - if (permissionState === 'granted') return null; + if (demoReadOnly || permissionState === 'granted') return null; const titles: Record = { idle: t('upload.cameraDenied.title'), @@ -1233,12 +1261,27 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[ maxHeight: '88vh', }} > + {demoReadOnly && ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + {t('upload.demoReadOnly.label', 'Demo')} +
    +
    + )}