From 35ef8f1586ccd724d084a2a30ab4b2a7acd47f59 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 5 Jan 2026 15:29:59 +0100 Subject: [PATCH] Add guest Live Show opt-in toggle --- .../Controllers/Api/EventPublicController.php | 33 +++++++++++++++++++ resources/js/guest/i18n/messages.ts | 14 ++++++++ resources/js/guest/pages/UploadPage.tsx | 31 ++++++++++++++++- resources/js/guest/queue/queue.ts | 1 + resources/js/guest/queue/xhr.ts | 3 ++ resources/js/guest/services/eventApi.ts | 9 +++++ resources/js/guest/services/photosApi.ts | 4 +++ tests/Feature/GuestJoinTokenFlowTest.php | 22 +++++++++++++ 8 files changed, 116 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 65c7664..0448cdb 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -6,6 +6,7 @@ use App\Enums\GuestNotificationAudience; use App\Enums\GuestNotificationDeliveryStatus; use App\Enums\GuestNotificationState; use App\Enums\GuestNotificationType; +use App\Enums\PhotoLiveStatus; use App\Events\GuestPhotoUploaded; use App\Jobs\ProcessPhotoSecurityScan; use App\Models\Event; @@ -1899,6 +1900,8 @@ class EventPublicController extends BaseController $branding = $this->buildGalleryBranding($event); $settings = $this->normalizeSettings($event->settings ?? []); $engagementMode = $settings['engagement_mode'] ?? 'tasks'; + $liveShowSettings = Arr::get($settings, 'live_show', []); + $liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : []; $event->loadMissing('photoboothSetting'); $policy = $this->guestPolicy(); @@ -1921,6 +1924,9 @@ class EventPublicController extends BaseController 'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled), 'branding' => $branding, 'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility), + 'live_show' => [ + 'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual', + ], 'engagement_mode' => $engagementMode, ])->header('Cache-Control', 'no-store'); } @@ -2987,6 +2993,7 @@ class EventPublicController extends BaseController 'emotion_slug' => ['nullable', 'string'], 'task_id' => ['nullable', 'integer'], 'guest_name' => ['nullable', 'string', 'max:255'], + 'live_show_opt_in' => ['nullable', 'boolean'], ]); $file = $validated['photo']; @@ -3022,6 +3029,26 @@ class EventPublicController extends BaseController $url = $this->resolveDiskUrl($disk, $watermarkedPath); $thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb); + $liveShowSettings = Arr::get($eventModel->settings ?? [], 'live_show', []); + $liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : []; + $liveModerationMode = $liveShowSettings['moderation_mode'] ?? 'manual'; + $liveOptIn = $request->boolean('live_show_opt_in'); + $liveSubmittedAt = null; + $liveApprovedAt = null; + $liveReviewedAt = null; + $liveStatus = PhotoLiveStatus::NONE->value; + + if ($liveOptIn) { + $liveSubmittedAt = now(); + if ($liveModerationMode === 'off') { + $liveStatus = PhotoLiveStatus::APPROVED->value; + $liveApprovedAt = $liveSubmittedAt; + $liveReviewedAt = $liveSubmittedAt; + } else { + $liveStatus = PhotoLiveStatus::PENDING->value; + } + } + $photoId = DB::table('photos')->insertGetId([ 'event_id' => $eventId, 'tenant_id' => $tenantModel->id, @@ -3033,6 +3060,12 @@ class EventPublicController extends BaseController 'likes_count' => 0, 'ingest_source' => Photo::SOURCE_GUEST_PWA, 'status' => $autoApproveUploads ? 'approved' : 'pending', + 'live_status' => $liveStatus, + 'live_submitted_at' => $liveSubmittedAt, + 'live_approved_at' => $liveApprovedAt, + 'live_reviewed_at' => $liveReviewedAt, + 'live_reviewed_by' => null, + 'live_rejection_reason' => null, // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default 'emotion_id' => $this->resolveEmotionId($validated, $eventId), diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index 77042b1..5c4d7d6 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -522,6 +522,13 @@ export const messages: Record = { keep: 'Foto verwenden', readyAnnouncement: 'Foto aufgenommen. Bitte Vorschau prüfen.', }, + liveShow: { + title: 'Live-Show', + description: 'Zeige dieses Foto direkt auf der Live-Show.', + toggle: 'Live-Show aktivieren', + immediate: 'Erscheint sofort auf der Leinwand.', + reviewed: 'Wird nach Freigabe für die Live-Show angezeigt.', + }, status: { saving: 'Speichere Foto...', processing: 'Verarbeite Foto...', @@ -1220,6 +1227,13 @@ export const messages: Record = { keep: 'Use this photo', readyAnnouncement: 'Photo captured. Please review the preview.', }, + liveShow: { + title: 'Live Show', + description: 'Show this photo on the live screen.', + toggle: 'Enable Live Show', + immediate: 'Shows immediately on the big screen.', + reviewed: 'Shown after approval for the Live Show.', + }, status: { saving: 'Saving photo...', processing: 'Processing photo...', diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index a00d61d..189d29c 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Switch } from '@/components/ui/switch'; import { motion } from 'framer-motion'; import { Dialog, @@ -149,6 +150,7 @@ export default function UploadPage() { const uploadsRequireApproval = (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; const demoReadOnly = Boolean(event?.demo_read_only); + const liveShowModeration = event?.live_show?.moderation_mode ?? 'manual'; const motionEnabled = !prefersReducedMotion(); const overlayMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_SCALE } : {}; const fadeUpMotion = motionEnabled ? { initial: 'hidden', animate: 'show', variants: FADE_UP } : {}; @@ -187,6 +189,7 @@ const navSentinelRef = useRef(null); const [eventPackage, setEventPackage] = useState(null); const [canUpload, setCanUpload] = useState(true); +const [submitToLive, setSubmitToLive] = useState(true); const limitCards = useMemo( () => buildLimitSummaries(eventPackage?.limits ?? null, t), @@ -227,6 +230,11 @@ const [canUpload, setCanUpload] = useState(true); }; }, []); + useEffect(() => { + if (!event) return; + setSubmitToLive(true); + }, [event?.slug]); + const updateNavVisibility = useCallback(() => { if (typeof document === 'undefined') { return; @@ -776,6 +784,7 @@ const [canUpload, setCanUpload] = useState(true); const photoId = await uploadPhoto(eventKey, fileForUpload, task?.id, emotionSlug || undefined, { maxRetries: 2, guestName: identity.name || undefined, + liveShowOptIn: submitToLive, onProgress: (percent) => { setUploadProgress(Math.max(15, Math.min(98, percent))); setStatusMessage(t('upload.status.uploading')); @@ -840,7 +849,7 @@ const [canUpload, setCanUpload] = useState(true); } finally { setStatusMessage(''); } - }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti]); + }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name, triggerConfetti, submitToLive]); const handleGalleryPick = useCallback(async (event: React.ChangeEvent) => { if (!canUpload) return; @@ -1446,6 +1455,26 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[

{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}

) : null} +
+
+
+

{t('upload.liveShow.title', 'Live-Show')}

+

+ {t('upload.liveShow.description', 'Zeige dieses Foto direkt auf der Live-Show.')} +

+
+ setSubmitToLive(checked)} + aria-label={t('upload.liveShow.toggle', 'Live-Show aktivieren')} + /> +
+

+ {liveShowModeration === 'off' + ? t('upload.liveShow.immediate', 'Erscheint sofort auf der Leinwand.') + : t('upload.liveShow.reviewed', 'Wird nach Freigabe für die Live-Show angezeigt.')} +

+