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}
-
- window.open(data.photo.image_urls.full, '_blank')}
- >
-
-
- {t('galleryPublic.download', 'Download')}
-
-
);
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