diff --git a/app/Models/Event.php b/app/Models/Event.php
index 41ce382e..1494c5a3 100644
--- a/app/Models/Event.php
+++ b/app/Models/Event.php
@@ -166,7 +166,9 @@ class Event extends Model
public function ensureLiveShowToken(): string
{
if (is_string($this->live_show_token) && $this->live_show_token !== '') {
- $this->refreshLiveShowTokenExpiry();
+ if (! ($this->live_show_token_expires_at instanceof \Carbon\CarbonInterface)) {
+ $this->refreshLiveShowTokenExpiry();
+ }
return $this->live_show_token;
}
diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts
index 83d4830e..c895ca4d 100644
--- a/resources/js/admin/api.ts
+++ b/resources/js/admin/api.ts
@@ -91,6 +91,7 @@ export type LiveShowLink = {
url: string;
qr_code_data_url: string | null;
rotated_at: string | null;
+ expires_at: string | null;
};
export type TenantEvent = {
@@ -1766,6 +1767,7 @@ function normalizeLiveShowLink(payload: JsonValue | LiveShowLink | null | undefi
url: '',
qr_code_data_url: null,
rotated_at: null,
+ expires_at: null,
};
}
@@ -1776,6 +1778,7 @@ function normalizeLiveShowLink(payload: JsonValue | LiveShowLink | null | undefi
url: typeof record.url === 'string' ? record.url : '',
qr_code_data_url: typeof record.qr_code_data_url === 'string' ? record.qr_code_data_url : null,
rotated_at: typeof record.rotated_at === 'string' ? record.rotated_at : null,
+ expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
};
}
diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json
index 74441c73..bfc1e416 100644
--- a/resources/js/admin/i18n/locales/de/management.json
+++ b/resources/js/admin/i18n/locales/de/management.json
@@ -2603,6 +2603,11 @@
"rotateSuccess": "Live-Show-Link neu generiert.",
"rotateFailed": "Live-Show-Link konnte nicht neu generiert werden.",
"rotatedAt": "Zuletzt erneuert {{time}}",
+ "expiresAt": "Gültig bis {{time}}",
+ "expiresSoonAt": "Läuft bald ab: {{time}}",
+ "expiredAt": "Abgelaufen am {{time}}",
+ "expiredHint": "Generiere den Link neu, um wieder eine aktive URL und einen aktiven QR-Code zu erhalten.",
+ "rotateNow": "Jetzt neu generieren",
"noExpiry": "Dauerhaft gültig, bis von Dir erneuert.",
"loadFailed": "Live-Show-Link konnte nicht geladen werden.",
"copySuccess": "Link kopiert",
diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json
index 4b4207ba..dc4faee3 100644
--- a/resources/js/admin/i18n/locales/en/management.json
+++ b/resources/js/admin/i18n/locales/en/management.json
@@ -2605,6 +2605,11 @@
"rotateSuccess": "Live Show link rotated.",
"rotateFailed": "Live Show link could not be rotated.",
"rotatedAt": "Last rotated {{time}}",
+ "expiresAt": "Valid until {{time}}",
+ "expiresSoonAt": "Expires soon: {{time}}",
+ "expiredAt": "Expired at {{time}}",
+ "expiredHint": "Rotate the link to generate a new active URL and QR code.",
+ "rotateNow": "Rotate now",
"noExpiry": "No expiry date (valid until you rotate it).",
"loadFailed": "Live Show link could not be loaded.",
"copySuccess": "Link copied",
diff --git a/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx b/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx
index b6eed088..41668944 100644
--- a/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx
+++ b/resources/js/admin/mobile/EventLiveShowSettingsPage.tsx
@@ -146,6 +146,8 @@ export default function MobileEventLiveShowSettingsPage() {
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
+ const linkExpiryInfo = React.useMemo(() => parseLinkExpiry(liveShowLink?.expires_at ?? null, locale), [liveShowLink?.expires_at, locale]);
+ const linkExpired = linkExpiryInfo?.isExpired ?? false;
const loadLink = React.useCallback(async () => {
if (!slug) return;
@@ -316,21 +318,21 @@ export default function MobileEventLiveShowSettingsPage() {
liveShowLink?.url && copyToClipboard(liveShowLink.url, t)}
>
liveShowLink?.url && shareLink(liveShowLink.url, event, t)}
>
liveShowLink?.url && openLink(liveShowLink.url)}
>
@@ -360,10 +362,28 @@ export default function MobileEventLiveShowSettingsPage() {
) : null}
{liveShowLink ? (
-
- {t('liveShowSettings.link.noExpiry', 'No expiry date set.')}
+
+ {linkExpiryInfo
+ ? linkExpiryInfo.isExpired
+ ? t('liveShowSettings.link.expiredAt', 'Expired at {{time}}', { time: linkExpiryInfo.formatted })
+ : linkExpiryInfo.isSoon
+ ? t('liveShowSettings.link.expiresSoonAt', 'Expires soon: {{time}}', { time: linkExpiryInfo.formatted })
+ : t('liveShowSettings.link.expiresAt', 'Valid until {{time}}', { time: linkExpiryInfo.formatted })
+ : t('liveShowSettings.link.noExpiry', 'No expiry date set.')}
) : null}
+ {linkExpired ? (
+
+
+ {t('liveShowSettings.link.expiredHint', 'Rotate the link to generate a new active URL and QR code.')}
+
+ handleRotateLink()}
+ disabled={linkBusy}
+ />
+
+ ) : null}
{liveShowLink?.rotated_at ? (
{t('liveShowSettings.link.rotatedAt', 'Last rotated {{time}}', {
@@ -623,6 +643,35 @@ function formatTimestamp(value: string, locale: string): string {
return date.toLocaleString(locale, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
+function parseLinkExpiry(
+ value: string | null,
+ locale: string
+): {
+ formatted: string;
+ isExpired: boolean;
+ isSoon: boolean;
+} | null {
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+
+ const now = Date.now();
+ const expiresAt = date.getTime();
+ const isExpired = expiresAt < now;
+ const isSoon = !isExpired && expiresAt - now <= 24 * 60 * 60 * 1000;
+
+ return {
+ formatted: formatTimestamp(value, locale),
+ isExpired,
+ isSoon,
+ };
+}
+
function resolveOption(value: unknown, options: T[], fallback: T): T {
if (typeof value !== 'string') return fallback;
return options.includes(value as T) ? (value as T) : fallback;
diff --git a/resources/js/admin/mobile/__tests__/EventLiveShowSettingsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventLiveShowSettingsPage.test.tsx
new file mode 100644
index 00000000..da684845
--- /dev/null
+++ b/resources/js/admin/mobile/__tests__/EventLiveShowSettingsPage.test.tsx
@@ -0,0 +1,197 @@
+import React from 'react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+const fixtures = vi.hoisted(() => ({
+ event: {
+ id: 1,
+ name: 'Demo Event',
+ slug: 'demo-event',
+ event_date: '2026-02-12',
+ event_type_id: 1,
+ event_type: { id: 1, name: 'Wedding' },
+ status: 'published',
+ settings: {
+ live_show: {
+ moderation_mode: 'manual',
+ },
+ },
+ },
+}));
+
+vi.mock('react-router-dom', () => ({
+ useParams: () => ({ slug: fixtures.event.slug }),
+}));
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string | Record) => {
+ if (typeof fallback === 'string') {
+ return fallback;
+ }
+ if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
+ return fallback.defaultValue;
+ }
+ return key;
+ },
+ i18n: { language: 'en' },
+ }),
+}));
+
+vi.mock('../../api', () => ({
+ getEvent: vi.fn(),
+ getLiveShowLink: vi.fn(),
+ rotateLiveShowLink: vi.fn(),
+ updateEvent: vi.fn(),
+}));
+
+vi.mock('../../auth/tokens', () => ({
+ isAuthError: () => false,
+}));
+
+vi.mock('../../lib/apiError', () => ({
+ getApiErrorMessage: () => 'error',
+}));
+
+vi.mock('../../lib/events', () => ({
+ resolveEventDisplayName: () => fixtures.event.name,
+}));
+
+vi.mock('../hooks/useBackNavigation', () => ({
+ useBackNavigation: () => undefined,
+}));
+
+vi.mock('../components/MobileShell', () => ({
+ MobileShell: ({ children }: { children: React.ReactNode }) => {children}
,
+ HeaderActionButton: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('../components/Primitives', () => ({
+ MobileCard: ({ children }: { children: React.ReactNode }) => {children}
,
+ CTAButton: ({ label, onPress, disabled }: { label: string; onPress?: () => void; disabled?: boolean }) => (
+
+ ),
+ SkeletonCard: () => Loading...
,
+}));
+
+vi.mock('../components/FormControls', () => ({
+ MobileField: ({ label, children }: { label: string; children: React.ReactNode }) => (
+
+ ),
+ MobileInput: (props: React.InputHTMLAttributes) => ,
+ MobileSelect: (props: React.SelectHTMLAttributes) => ,
+}));
+
+vi.mock('../components/ContextHelpLink', () => ({
+ ContextHelpLink: () => null,
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children }: { children: React.ReactNode }) => {children}
,
+ XStack: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({
+ children,
+ onPress,
+ disabled,
+ ...rest
+ }: {
+ children: React.ReactNode;
+ onPress?: () => void;
+ disabled?: boolean;
+ [key: string]: unknown;
+ }) => (
+
+ ),
+}));
+
+vi.mock('tamagui', () => ({
+ Slider: Object.assign(
+ ({ children }: { children: React.ReactNode }) => {children}
,
+ {
+ Track: ({ children }: { children: React.ReactNode }) => {children}
,
+ TrackActive: () => ,
+ Thumb: () => ,
+ }
+ ),
+}));
+
+vi.mock('../theme', () => ({
+ useAdminTheme: () => ({
+ textStrong: '#111827',
+ text: '#111827',
+ muted: '#6b7280',
+ danger: '#dc2626',
+ border: '#e5e7eb',
+ surface: '#ffffff',
+ primary: '#ff5a5f',
+ }),
+}));
+
+vi.mock('react-hot-toast', () => ({
+ default: {
+ error: vi.fn(),
+ success: vi.fn(),
+ },
+}));
+
+import MobileEventLiveShowSettingsPage from '../EventLiveShowSettingsPage';
+import { getEvent, getLiveShowLink } from '../../api';
+
+describe('MobileEventLiveShowSettingsPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getEvent).mockResolvedValue(fixtures.event as any);
+ });
+
+ it('shows a validity timestamp when link expiry is present', async () => {
+ vi.mocked(getLiveShowLink).mockResolvedValue({
+ token: 'token-1',
+ url: 'https://example.test/show/token-1',
+ qr_code_data_url: null,
+ rotated_at: null,
+ expires_at: '2099-01-01T12:00:00Z',
+ });
+
+ render();
+
+ expect(await screen.findByText('Live Show link')).toBeInTheDocument();
+ expect(await screen.findByText(/Valid until/)).toBeInTheDocument();
+ });
+
+ it('shows expired state and disables stale link actions', async () => {
+ vi.mocked(getLiveShowLink).mockResolvedValue({
+ token: 'token-2',
+ url: 'https://example.test/show/token-2',
+ qr_code_data_url: null,
+ rotated_at: null,
+ expires_at: '2000-01-01T00:00:00Z',
+ });
+
+ render();
+
+ expect(await screen.findByText(/Expired at/)).toBeInTheDocument();
+ expect(screen.getByText('Rotate the link to generate a new active URL and QR code.')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { name: 'Copy' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Share' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled();
+ });
+});
diff --git a/tests/Feature/LiveShowDataModelTest.php b/tests/Feature/LiveShowDataModelTest.php
index f7b5bb4c..8d266a20 100644
--- a/tests/Feature/LiveShowDataModelTest.php
+++ b/tests/Feature/LiveShowDataModelTest.php
@@ -52,6 +52,25 @@ class LiveShowDataModelTest extends TestCase
$this->assertSame($newDate->copy()->addDay()->endOfDay()->toIso8601String(), $event->live_show_token_expires_at?->toIso8601String());
}
+ public function test_ensuring_existing_live_show_token_does_not_mutate_existing_expiry(): void
+ {
+ $this->freezeTime(function (): void {
+ $eventDate = now()->addDays(5)->startOfDay();
+ $event = Event::factory()->create(['date' => $eventDate]);
+ $event->rotateLiveShowToken();
+ $customExpiry = now()->addHours(6)->toImmutable();
+ $event->forceFill(['live_show_token_expires_at' => $customExpiry])->saveQuietly();
+ $initialExpiry = $event->refresh()->live_show_token_expires_at?->toIso8601String();
+
+ $this->travel(6)->hours();
+ $event->refresh()->ensureLiveShowToken();
+ $expiryAfterEnsure = $event->refresh()->live_show_token_expires_at?->toIso8601String();
+
+ $this->assertNotNull($initialExpiry);
+ $this->assertSame($initialExpiry, $expiryAfterEnsure);
+ });
+ }
+
public function test_photo_live_status_is_cast_and_defaults_to_none(): void
{
$photo = Photo::factory()->create();
diff --git a/tests/Feature/Tenant/LiveShowLinkControllerTest.php b/tests/Feature/Tenant/LiveShowLinkControllerTest.php
index 47d6d014..3c98464e 100644
--- a/tests/Feature/Tenant/LiveShowLinkControllerTest.php
+++ b/tests/Feature/Tenant/LiveShowLinkControllerTest.php
@@ -62,4 +62,32 @@ class LiveShowLinkControllerTest extends TenantTestCase
$this->assertIsString($rotatedToken);
$this->assertNotSame($firstToken, $rotatedToken);
}
+
+ public function test_loading_live_show_link_does_not_mutate_existing_expiry(): void
+ {
+ $this->freezeTime(function (): void {
+ $event = Event::factory()
+ ->for($this->tenant)
+ ->create([
+ 'name' => ['de' => 'Live-Show Ablauf', 'en' => 'Live Show Expiry'],
+ 'slug' => 'live-show-fixed-expiry',
+ 'date' => now()->addDays(3)->startOfDay(),
+ ]);
+ $event->ensureLiveShowToken();
+ $customExpiry = now()->addHours(4)->toImmutable();
+ $event->forceFill(['live_show_token_expires_at' => $customExpiry])->saveQuietly();
+
+ $first = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/link");
+ $first->assertOk();
+ $firstExpiry = $first->json('data.expires_at');
+
+ $this->travel(8)->hours();
+
+ $second = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}/live-show/link");
+ $second->assertOk();
+
+ $this->assertIsString($firstExpiry);
+ $this->assertSame($firstExpiry, $second->json('data.expires_at'));
+ });
+ }
}