diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 45994132..3194fbe2 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -1464,8 +1464,7 @@ class EventPublicController extends BaseController [ 'slug' => $shareLink->slug, 'variant' => $variant, - ], - absolute: false + ] ); } diff --git a/app/Http/Controllers/Api/LiveShowController.php b/app/Http/Controllers/Api/LiveShowController.php index c220319b..a818e1c6 100644 --- a/app/Http/Controllers/Api/LiveShowController.php +++ b/app/Http/Controllers/Api/LiveShowController.php @@ -212,6 +212,10 @@ class LiveShowController extends BaseController return Event::query() ->where('live_show_token', $token) + ->where(function (Builder $query) { + $query->whereNull('live_show_token_expires_at') + ->orWhere('live_show_token_expires_at', '>=', now()); + }) ->first(); } diff --git a/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php b/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php index 2a76e8ca..af14c627 100644 --- a/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php +++ b/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php @@ -53,6 +53,7 @@ class LiveShowLinkController extends Controller 'url' => $url, 'qr_code_data_url' => $this->buildQrCodeDataUrl($url), 'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(), + 'expires_at' => $event->live_show_token_expires_at?->toIso8601String(), ]; } diff --git a/app/Models/Event.php b/app/Models/Event.php index 1edce659..41ce382e 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -24,6 +24,7 @@ class Event extends Model 'name' => 'array', 'description' => 'array', 'live_show_token_rotated_at' => 'datetime', + 'live_show_token_expires_at' => 'datetime', ]; protected static function booted(): void @@ -47,6 +48,7 @@ class Event extends Model } app(EventJoinTokenService::class)->extendExpiryForEvent($event); + $event->refreshLiveShowTokenExpiry(); }); } @@ -164,6 +166,8 @@ class Event extends Model public function ensureLiveShowToken(): string { if (is_string($this->live_show_token) && $this->live_show_token !== '') { + $this->refreshLiveShowTokenExpiry(); + return $this->live_show_token; } @@ -179,11 +183,34 @@ class Event extends Model $this->forceFill([ 'live_show_token' => $token, 'live_show_token_rotated_at' => now(), + 'live_show_token_expires_at' => $this->computeLiveShowTokenExpiry(), ])->save(); return $token; } + public function refreshLiveShowTokenExpiry(): void + { + if (! is_string($this->live_show_token) || $this->live_show_token === '') { + return; + } + + $this->forceFill([ + 'live_show_token_expires_at' => $this->computeLiveShowTokenExpiry(), + ])->saveQuietly(); + } + + private function computeLiveShowTokenExpiry(): \Carbon\CarbonInterface + { + $eventDate = $this->date; + + if ($eventDate instanceof \Carbon\CarbonInterface) { + return $eventDate->copy()->addDay()->endOfDay(); + } + + return now()->addDay(); + } + public function getSettingsAttribute($value): array { if (is_array($value)) { diff --git a/database/migrations/2026_02_06_072321_add_live_show_token_expires_at_to_events_table.php b/database/migrations/2026_02_06_072321_add_live_show_token_expires_at_to_events_table.php new file mode 100644 index 00000000..8a536969 --- /dev/null +++ b/database/migrations/2026_02_06_072321_add_live_show_token_expires_at_to_events_table.php @@ -0,0 +1,34 @@ +timestamp('live_show_token_expires_at') + ->nullable() + ->after('live_show_token_rotated_at'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('events', function (Blueprint $table) { + if (Schema::hasColumn('events', 'live_show_token_expires_at')) { + $table->dropColumn('live_show_token_expires_at'); + } + }); + } +}; diff --git a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx index 39a4a7fe..d63fecd4 100644 --- a/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx +++ b/resources/js/guest-v2/__tests__/ScreensCopy.test.tsx @@ -43,7 +43,6 @@ vi.mock('lucide-react', () => ({ ListVideo: () => list, RefreshCcw: () => refresh, FlipHorizontal: () => flip, - X: () => close, Sparkles: () => sparkles, Trophy: () => trophy, Play: () => play, @@ -53,6 +52,8 @@ vi.mock('lucide-react', () => ({ ChevronLeft: () => chevron-left, ChevronRight: () => chevron-right, QrCode: () => qr, + Loader2: () => loader, + Maximize2: () => maximize, Link: () => link, Users: () => users, Heart: () => heart, diff --git a/resources/js/guest-v2/components/ShareSheet.tsx b/resources/js/guest-v2/components/ShareSheet.tsx index e788665f..974b56e2 100644 --- a/resources/js/guest-v2/components/ShareSheet.tsx +++ b/resources/js/guest-v2/components/ShareSheet.tsx @@ -47,24 +47,27 @@ export default function ShareSheet({ const { isDark } = useGuestThemeVariant(); const mutedSurface = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'; const mutedBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'; + const inlineActive = variant === 'inline'; const [inlineMounted, setInlineMounted] = React.useState(false); const [inlineVisible, setInlineVisible] = React.useState(false); React.useEffect(() => { - if (variant !== 'inline') return; + if (!inlineActive) { + setInlineMounted(false); + setInlineVisible(false); + return; + } + if (open) { setInlineMounted(true); - const raf = window.requestAnimationFrame(() => { - setInlineVisible(true); - }); - return () => window.cancelAnimationFrame(raf); + const frame = window.requestAnimationFrame(() => setInlineVisible(true)); + return () => window.cancelAnimationFrame(frame); } + setInlineVisible(false); - const timeout = window.setTimeout(() => { - setInlineMounted(false); - }, 220); - return () => window.clearTimeout(timeout); - }, [open, variant]); + const timer = window.setTimeout(() => setInlineMounted(false), 260); + return () => window.clearTimeout(timer); + }, [inlineActive, open]); const content = ( @@ -184,16 +187,14 @@ export default function ShareSheet({ - {url ? ( - - {url} - - ) : null} + + {url ?? ' '} + ); - if (variant === 'inline') { - if (!inlineMounted) { + if (inlineActive) { + if (!inlineMounted && !open) { return null; } @@ -203,16 +204,16 @@ export default function ShareSheet({ inset={0} zIndex={20} justifyContent="flex-end" - pointerEvents={open ? 'auto' : 'none'} + pointerEvents={inlineVisible ? 'auto' : 'none'} > - {deleteConfirmOpen ? ( + {deleteConfirmMounted ? ( diff --git a/resources/js/guest-v2/screens/SharedPhotoScreen.tsx b/resources/js/guest-v2/screens/SharedPhotoScreen.tsx index 887f83f9..66920d30 100644 --- a/resources/js/guest-v2/screens/SharedPhotoScreen.tsx +++ b/resources/js/guest-v2/screens/SharedPhotoScreen.tsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Button } from '@tamagui/button'; -import { AlertCircle, Download } from 'lucide-react'; +import { AlertCircle, Download, Maximize2, X } from 'lucide-react'; import StandaloneShell from '../components/StandaloneShell'; import SurfaceCard from '../components/SurfaceCard'; import EventLogo from '../components/EventLogo'; @@ -14,6 +14,7 @@ import { EventBrandingProvider } from '@/guest/context/EventBrandingContext'; import { mapEventBranding } from '../lib/eventBranding'; import { BrandingTheme } from '../lib/brandingTheme'; import { useGuestThemeVariant } from '../lib/guestTheme'; +import { getBentoSurfaceTokens } from '../lib/bento'; interface ShareResponse { slug: string; @@ -38,6 +39,7 @@ export default function SharedPhotoScreen() { error: null, data: null, }); + const [fullScreenOpen, setFullScreenOpen] = React.useState(false); const branding = React.useMemo(() => { if (!state.data?.branding) { return null; @@ -46,6 +48,7 @@ export default function SharedPhotoScreen() { }, [state.data]); const { isDark } = useGuestThemeVariant(branding); const mutedText = isDark ? 'rgba(248, 250, 252, 0.7)' : 'rgba(15, 23, 42, 0.65)'; + const bento = getBentoSurfaceTokens(isDark); React.useEffect(() => { let active = true; @@ -105,66 +108,182 @@ export default function SharedPhotoScreen() { const chips = buildChips(data, t); const content = ( - - - - - - - {t('share.title', 'Geteiltes Foto')} - - - {data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} - - - - {data.photo.title ? ( - - {data.photo.title} - - ) : null} - - - + + + + + + + {t('share.title', 'Geteiltes Foto')} + + + {data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')} + + + + {data.photo.title ? ( + + {data.photo.title} + + ) : null} + + + + + + + + + {chips.length > 0 ? ( + + {chips.map((chip) => ( + + + {chip.icon ? {chip.icon} : null} + + {chip.label} + + + {chip.value} + + + + ))} + + ) : null} + + + + + + {fullScreenOpen ? ( + - - - {chips.length > 0 ? ( - - {chips.map((chip) => ( - - - {chip.icon ? {chip.icon} : null} - - {chip.label} - - - {chip.value} - - - - ))} - + > + + + ) : null} - - ); diff --git a/tests/Feature/Api/PhotoShareLinkTest.php b/tests/Feature/Api/PhotoShareLinkTest.php index 59d31ed0..84d49c21 100644 --- a/tests/Feature/Api/PhotoShareLinkTest.php +++ b/tests/Feature/Api/PhotoShareLinkTest.php @@ -10,6 +10,7 @@ use App\Models\Tenant; use App\Services\EventJoinTokenService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class PhotoShareLinkTest extends TestCase @@ -64,13 +65,18 @@ class PhotoShareLinkTest extends TestCase public function test_share_payload_exposes_public_photo_data(): void { + Config::set('filesystems.default', 'public'); + Storage::fake('public'); $tenant = Tenant::factory()->create(); $event = Event::factory()->for($tenant)->create(['status' => 'published']); $task = Task::factory()->for($tenant)->create(); $photo = Photo::factory()->for($event)->create([ 'status' => 'approved', 'task_id' => $task->id, + 'file_path' => 'photos/share-test.jpg', + 'thumbnail_path' => 'photos/thumbnails/share-test.jpg', ]); + Storage::disk('public')->put('photos/share-test.jpg', 'photo'); $share = PhotoShareLink::factory()->for($photo)->create([ 'expires_at' => now()->addDay(), @@ -95,5 +101,16 @@ class PhotoShareLinkTest extends TestCase 'buttons', ], ]); + + $assetUrl = $response->json('photo.image_urls.full'); + $this->assertIsString($assetUrl); + $this->assertNotNull(parse_url($assetUrl, PHP_URL_SCHEME)); + + $parsed = parse_url($assetUrl); + $path = (string) ($parsed['path'] ?? ''); + $query = $parsed['query'] ?? null; + + $assetResponse = $this->get($path.($query ? "?{$query}" : '')); + $assetResponse->assertOk(); } } diff --git a/tests/Feature/LiveShowDataModelTest.php b/tests/Feature/LiveShowDataModelTest.php index 953eba26..f7b5bb4c 100644 --- a/tests/Feature/LiveShowDataModelTest.php +++ b/tests/Feature/LiveShowDataModelTest.php @@ -15,7 +15,8 @@ class LiveShowDataModelTest extends TestCase public function test_event_can_ensure_and_rotate_live_show_token(): void { - $event = Event::factory()->create(); + $eventDate = now()->addDays(1)->startOfDay(); + $event = Event::factory()->create(['date' => $eventDate]); $token = $event->ensureLiveShowToken(); @@ -23,6 +24,7 @@ class LiveShowDataModelTest extends TestCase $this->assertSame(64, strlen($token)); $this->assertSame($token, $event->refresh()->live_show_token); $this->assertNotNull($event->live_show_token_rotated_at); + $this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String()); $rotated = $event->rotateLiveShowToken(); @@ -30,6 +32,24 @@ class LiveShowDataModelTest extends TestCase $this->assertSame(64, strlen($rotated)); $this->assertNotSame($token, $rotated); $this->assertSame($rotated, $event->refresh()->live_show_token); + $this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String()); + } + + public function test_live_show_token_expiry_updates_when_event_date_changes(): void + { + $eventDate = now()->addDays(3)->startOfDay(); + $event = Event::factory()->create(['date' => $eventDate]); + + $event->ensureLiveShowToken(); + $event->refresh(); + + $this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String()); + + $newDate = now()->addDays(7)->startOfDay(); + $event->update(['date' => $newDate]); + + $event->refresh(); + $this->assertSame($newDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String()); } public function test_photo_live_status_is_cast_and_defaults_to_none(): void diff --git a/tests/Feature/Tenant/LiveShowLinkControllerTest.php b/tests/Feature/Tenant/LiveShowLinkControllerTest.php index 3e8478bb..47d6d014 100644 --- a/tests/Feature/Tenant/LiveShowLinkControllerTest.php +++ b/tests/Feature/Tenant/LiveShowLinkControllerTest.php @@ -8,11 +8,13 @@ class LiveShowLinkControllerTest extends TenantTestCase { public function test_live_show_link_response_includes_qr_code_and_url(): void { + $eventDate = now()->addDays(2)->startOfDay(); $event = Event::factory() ->for($this->tenant) ->create([ 'name' => ['de' => 'Live-Show Test', 'en' => 'Live Show Test'], 'slug' => 'live-show-link-test', + 'date' => $eventDate, ]); $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/link"); @@ -26,15 +28,18 @@ class LiveShowLinkControllerTest extends TenantTestCase $this->assertArrayHasKey('url', $data); $this->assertArrayHasKey('qr_code_data_url', $data); $this->assertArrayHasKey('rotated_at', $data); + $this->assertArrayHasKey('expires_at', $data); $this->assertIsString($data['token']); $this->assertIsString($data['url']); $this->assertIsString($data['qr_code_data_url']); $this->assertStringStartsWith('data:image/png;base64,', $data['qr_code_data_url']); $this->assertNotNull($data['rotated_at']); + $this->assertNotNull($data['expires_at']); $expectedBase = rtrim((string) config('app.url'), '/'); $this->assertSame("{$expectedBase}/show/{$data['token']}", $data['url']); + $this->assertSame($eventDate->copy()->addDay()->endOfDay()->toIso8601String(), $data['expires_at']); } public function test_rotate_live_show_link_changes_token(): void