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) =>