diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 5561464..356f82c 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -659,6 +659,25 @@ class EventPublicController extends BaseController return $trimmed; } + /** + * @return array + */ + private function normalizeSettings(array|string|null $settings): array + { + if (is_array($settings)) { + return $settings; + } + + if (is_string($settings)) { + $decoded = json_decode($settings, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return []; + } + private function buildDeterministicSeed(?string $identifier): int { if ($identifier === null || trim($identifier) === '') { @@ -1757,6 +1776,8 @@ class EventPublicController extends BaseController ]; $branding = $this->buildGalleryBranding($event); + $settings = $this->normalizeSettings($event->settings ?? []); + $engagementMode = $settings['engagement_mode'] ?? 'tasks'; if ($joinToken) { $this->joinTokenService->incrementUsage($joinToken); @@ -1774,6 +1795,7 @@ class EventPublicController extends BaseController 'photobooth_enabled' => (bool) $event->photobooth_enabled, 'branding' => $branding, 'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'), + 'engagement_mode' => $engagementMode, ])->header('Cache-Control', 'no-store'); } @@ -2365,7 +2387,7 @@ class EventPublicController extends BaseController public function stats(Request $request, string $token) { - $result = $this->resolvePublishedEvent($request, $token, ['id']); + $result = $this->resolvePublishedEvent($request, $token, ['id', 'settings']); if ($result instanceof JsonResponse) { return $result; @@ -2374,6 +2396,8 @@ class EventPublicController extends BaseController [$event, $joinToken] = $result; $eventId = $event->id; $eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId); + $settings = $this->normalizeSettings($event->settings ?? null); + $engagementMode = $settings['engagement_mode'] ?? 'tasks'; // Approximate online guests as distinct recent uploaders in last 10 minutes. $tenMinutesAgo = CarbonImmutable::now()->subMinutes(10); @@ -2384,7 +2408,9 @@ class EventPublicController extends BaseController ->count('guest_name'); // Tasks solved as number of photos linked to a task (proxy metric). - $tasksSolved = DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count(); + $tasksSolved = $engagementMode === 'photo_only' + ? 0 + : DB::table('photos')->where('event_id', $eventId)->whereNotNull('task_id')->count(); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at'); @@ -2392,6 +2418,7 @@ class EventPublicController extends BaseController 'online_guests' => $onlineGuests, 'tasks_solved' => $tasksSolved, 'latest_photo_at' => $latestPhotoAt, + 'engagement_mode' => $engagementMode, ]; $etag = sha1(json_encode($payload)); @@ -2472,7 +2499,7 @@ class EventPublicController extends BaseController public function tasks(Request $request, string $token) { - $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale']); + $result = $this->resolvePublishedEvent($request, $token, ['id', 'default_locale', 'settings']); if ($result instanceof JsonResponse) { return $result; @@ -2480,8 +2507,35 @@ class EventPublicController extends BaseController [$event, $joinToken] = $result; + $settings = $this->normalizeSettings($event->settings ?? null); + $engagementMode = $settings['engagement_mode'] ?? 'tasks'; + [$resolvedLocale] = $this->resolveGuestLocale($request, $event); + $page = max(1, (int) $request->query('page', 1)); + $perPage = max(1, min(100, (int) $request->query('per_page', 20))); + + if ($engagementMode === 'photo_only') { + $payload = [ + 'data' => [], + 'meta' => [ + 'total' => 0, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => 1, + 'has_more' => false, + 'seed' => null, + ], + 'engagement_mode' => $engagementMode, + ]; + + return response()->json($payload) + ->header('Cache-Control', 'public, max-age=120') + ->header('Vary', 'Accept-Language, X-Locale') + ->header('X-Content-Locale', $resolvedLocale) + ->header('X-Engagement-Mode', $engagementMode); + } + $cached = $this->eventTasksCache->remember((int) $event->id, $resolvedLocale, function () use ($event, $resolvedLocale) { return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null); }); @@ -2489,9 +2543,6 @@ class EventPublicController extends BaseController $tasks = $cached['tasks']; $baseHash = $cached['hash'] ?? sha1(json_encode($tasks)); - $page = max(1, (int) $request->query('page', 1)); - $perPage = max(1, min(100, (int) $request->query('per_page', 20))); - // Shuffle per request for unpredictability; stable when seeded by guest/device or explicit seed. $seedParam = $request->query('seed'); $guestIdentifier = $this->determineGuestIdentifier($request); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 1dd92fb..dc17129 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1699,6 +1699,11 @@ "publish": { "label": "Event sofort veröffentlichen", "help": "Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern." + }, + "tasksMode": { + "label": "Tasks & Challenges", + "helpOn": "Gäste sehen Aufgaben, Challenges und Achievements.", + "helpOff": "Task-Modus aus: Gäste sehen nur den Fotofeed." } }, "actions": { @@ -1781,6 +1786,8 @@ "saveFailed": "Task konnte nicht gespeichert werden." }, "tasks": { + "disabledTitle": "Task-Modus ist für dieses Event aus", + "disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.", "title": "Tasks & Checklisten", "actions": "Aktionen", "assigned": "Task hinzugefügt", @@ -1859,6 +1866,7 @@ "photosDesc": "Uploads und Highlights moderieren", "tasksLabel": "Tasks & Challenges verwalten", "tasksDesc": "Zuweisen und Fortschritt verfolgen", + "tasksDisabledDesc": "Tasks werden Gästen nicht angezeigt (Task-Modus aus)", "qrLabel": "QR-Code anzeigen/teilen", "qrDesc": "Poster, Karten und Links", "shortcutsTitle": "Shortcuts", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 062596e..df345f1 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1719,6 +1719,11 @@ "publish": { "label": "Publish immediately", "help": "Enable if guests should see the event right away. You can change the status later." + }, + "tasksMode": { + "label": "Tasks & challenges", + "helpOn": "Guests can see tasks, challenges and achievements.", + "helpOff": "Task mode is off: guests only see the photo feed." } }, "actions": { @@ -1801,6 +1806,8 @@ "saveFailed": "Task could not be saved." }, "tasks": { + "disabledTitle": "Task mode is off for this event", + "disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.", "title": "Tasks & checklists", "actions": "Actions", "assigned": "Task added", @@ -1879,6 +1886,7 @@ "photosDesc": "Moderate uploads and highlights", "tasksLabel": "Manage tasks & challenges", "tasksDesc": "Assign and track progress", + "tasksDisabledDesc": "Guests do not see tasks (task mode off)", "qrLabel": "Show / share QR code", "qrDesc": "Posters, cards, and links", "shortcutsTitle": "Shortcuts", diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 4c3ee72..a260ba6 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -11,7 +11,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge } from './compone import { adminPath } from '../constants'; import { useEventContext } from '../context/EventContext'; import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; -import { formatEventDate, resolveEventDisplayName } from '../lib/events'; +import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { useTheme } from '@tamagui/core'; export default function MobileDashboardPage() { @@ -37,6 +37,8 @@ export default function MobileDashboardPage() { return await getEventStats(activeEvent.slug); }, }); + const tasksEnabled = + resolveEngagementMode(activeEvent ?? undefined) !== 'photo_only'; const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const { data: dashboardEvents } = useQuery({ @@ -107,6 +109,7 @@ export default function MobileDashboardPage() { subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined} > activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))} onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))} onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))} @@ -120,9 +123,15 @@ export default function MobileDashboardPage() { onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))} /> - + - + ); } @@ -370,10 +379,12 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena } function FeaturedActions({ + tasksEnabled, onReviewPhotos, onManageTasks, onShowQr, }: { + tasksEnabled: boolean; onReviewPhotos: () => void; onManageTasks: () => void; onShowQr: () => void; @@ -394,7 +405,9 @@ function FeaturedActions({ { key: 'tasks', label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'), - desc: t('mobileDashboard.tasksDesc', 'Assign and track progress'), + desc: tasksEnabled + ? t('mobileDashboard.tasksDesc', 'Assign and track progress') + : t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'), icon: ListTodo, color: '#22c55e', action: onManageTasks, @@ -522,7 +535,19 @@ function SecondaryGrid({ ); } -function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null; stats: EventStats | null | undefined; loading: boolean; locale: string }) { +function KpiStrip({ + event, + stats, + loading, + locale, + tasksEnabled, +}: { + event: TenantEvent | null; + stats: EventStats | null | undefined; + loading: boolean; + locale: string; + tasksEnabled: boolean; +}) { const { t } = useTranslation('management'); const theme = useTheme(); const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc'); @@ -530,11 +555,6 @@ function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null if (!event) return null; const kpis = [ - { - label: t('mobileDashboard.kpiTasks', 'Open tasks'), - value: event.tasks_count ?? '—', - icon: ListTodo, - }, { label: t('mobileDashboard.kpiPhotos', 'Photos'), value: stats?.uploads_total ?? event.photo_count ?? '—', @@ -547,6 +567,14 @@ function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null }, ]; + if (tasksEnabled) { + kpis.unshift({ + label: t('mobileDashboard.kpiTasks', 'Open tasks'), + value: event.tasks_count ?? '—', + icon: ListTodo, + }); + } + return ( @@ -572,7 +600,7 @@ function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null ); } -function AlertsAndHints({ event, stats }: { event: TenantEvent | null; stats: EventStats | null | undefined }) { +function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) { const { t } = useTranslation('management'); const theme = useTheme(); const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc'); @@ -585,7 +613,7 @@ function AlertsAndHints({ event, stats }: { event: TenantEvent | null; stats: Ev if (stats?.pending_photos) { alerts.push(t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos })); } - if (event.tasks_count) { + if (tasksEnabled && event.tasks_count) { alerts.push(t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count })); } diff --git a/resources/js/admin/mobile/EventDetailPage.tsx b/resources/js/admin/mobile/EventDetailPage.tsx index 0e7c654..ef4b07d 100644 --- a/resources/js/admin/mobile/EventDetailPage.tsx +++ b/resources/js/admin/mobile/EventDetailPage.tsx @@ -13,7 +13,7 @@ import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import { MobileSheet } from './components/Sheet'; import { useEventContext } from '../context/EventContext'; -import { formatEventDate, resolveEventDisplayName } from '../lib/events'; +import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events'; import { isPastEvent } from './eventDate'; export default function MobileEventDetailPage() { @@ -66,12 +66,9 @@ export default function MobileEventDetailPage() { })(); }, [slug, t]); + const tasksEnabled = resolveEngagementMode(event ?? activeEvent ?? null) !== 'photo_only'; + const kpis = [ - { - label: t('events.detail.kpi.tasks', 'Active Tasks'), - value: event?.tasks_count ?? toolkit?.tasks?.summary?.total ?? '—', - icon: Sparkles, - }, { label: t('events.detail.kpi.guests', 'Guests Registered'), value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—', @@ -84,6 +81,14 @@ export default function MobileEventDetailPage() { }, ]; + if (tasksEnabled) { + kpis.unshift({ + label: t('events.detail.kpi.tasks', 'Active Tasks'), + value: event?.tasks_count ?? toolkit?.tasks?.summary?.total ?? '—', + icon: Sparkles, + }); + } + return ( navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))} /> diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index 0473aca..49021ae 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -20,6 +20,7 @@ type FormState = { location: string; published: boolean; autoApproveUploads: boolean; + tasksEnabled: boolean; }; export default function MobileEventFormPage() { @@ -37,6 +38,7 @@ export default function MobileEventFormPage() { location: '', published: false, autoApproveUploads: true, + tasksEnabled: true, }); const [eventTypes, setEventTypes] = React.useState([]); const [typesLoading, setTypesLoading] = React.useState(false); @@ -59,6 +61,9 @@ export default function MobileEventFormPage() { published: data.status === 'published', autoApproveUploads: (data.settings?.guest_upload_visibility as string | undefined) === 'immediate', + tasksEnabled: + (data.settings?.engagement_mode as string | undefined) !== 'photo_only' && + (data.engagement_mode as string | undefined) !== 'photo_only', }); setError(null); } catch (err) { @@ -102,6 +107,7 @@ export default function MobileEventFormPage() { settings: { location: form.location, guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', + engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', }, }); navigate(adminPath(`/mobile/events/${slug}`)); @@ -115,6 +121,7 @@ export default function MobileEventFormPage() { settings: { location: form.location, guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', + engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only', }, }; const { event } = await createEvent(payload as any); @@ -228,6 +235,37 @@ export default function MobileEventFormPage() { {t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')} + + + + setForm((prev) => ({ ...prev, tasksEnabled: Boolean(checked) })) + } + size="$3" + aria-label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')} + > + + + + {form.tasksEnabled + ? t('common:states.enabled', 'Enabled') + : t('common:states.disabled', 'Disabled')} + + + + {form.tasksEnabled + ? t( + 'eventForm.fields.tasksMode.helpOn', + 'Guests can see tasks, challenges and achievements.', + ) + : t( + 'eventForm.fields.tasksMode.helpOff', + 'Task mode is off: guests only see the photo feed.', + )} + + + ; } + if (activeEvent?.slug && !tasksEnabled) { + return ( + + + + {t('events.tasks.disabledTitle', 'Task mode is off for this event')} + + + {t('events.tasks.disabledBody', 'Guests see only the photo feed. Enable tasks in the event settings to show them again.')} + + navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))} + /> + + + ); + } + if (!hasEvents) { return ( diff --git a/resources/js/guest/components/BottomNav.tsx b/resources/js/guest/components/BottomNav.tsx index 7d1da71..123324f 100644 --- a/resources/js/guest/components/BottomNav.tsx +++ b/resources/js/guest/components/BottomNav.tsx @@ -4,6 +4,7 @@ import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-rea import { useEventData } from '../hooks/useEventData'; import { useTranslation } from '../i18n/useTranslation'; import { useEventBranding } from '../context/EventBrandingContext'; +import { isTaskModeEnabled } from '../lib/engagement'; function TabLink({ to, @@ -64,6 +65,7 @@ export default function BottomNav() { const base = `/e/${encodeURIComponent(token)}`; const currentPath = location.pathname; + const tasksEnabled = isTaskModeEnabled(event); const labels = { home: t('navigation.home'), @@ -102,19 +104,21 @@ export default function BottomNav() { {labels.home} - -
- - {labels.tasks} -
-
+ {tasksEnabled ? ( + +
+ + {labels.tasks} +
+
+ ) : null} > = { heart: Heart, @@ -150,6 +151,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new'); const taskProgress = useGuestTaskProgress(eventToken); + const tasksEnabled = isTaskModeEnabled(event); const panelRef = React.useRef(null); React.useEffect(() => { if (!notificationsOpen) { @@ -220,7 +222,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
{event.name}
- {stats && ( + {stats && tasksEnabled && ( <> @@ -244,7 +246,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string open={notificationsOpen} onToggle={() => setNotificationsOpen((prev) => !prev)} panelRef={panelRef} - taskProgress={taskProgress?.hydrated ? taskProgress : undefined} + taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined} t={t} /> )} diff --git a/resources/js/guest/lib/engagement.ts b/resources/js/guest/lib/engagement.ts new file mode 100644 index 0000000..a5e38fe --- /dev/null +++ b/resources/js/guest/lib/engagement.ts @@ -0,0 +1,8 @@ +import type { EventData } from '../services/eventApi'; + +export function isTaskModeEnabled(event?: EventData | null): boolean { + if (!event) return true; + const mode = event.engagement_mode; + if (mode === 'photo_only') return false; + return true; +} diff --git a/resources/js/guest/pages/AchievementsPage.tsx b/resources/js/guest/pages/AchievementsPage.tsx index 85ea075..de9f923 100644 --- a/resources/js/guest/pages/AchievementsPage.tsx +++ b/resources/js/guest/pages/AchievementsPage.tsx @@ -21,6 +21,8 @@ import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import type { LocaleCode } from '../i18n/messages'; import { localizeTaskLabel } from '../lib/localizeTaskLabel'; +import { useEventData } from '../hooks/useEventData'; +import { isTaskModeEnabled } from '../lib/engagement'; const GENERIC_ERROR = 'GENERIC_ERROR'; @@ -311,9 +313,10 @@ function Highlights({ topPhoto, trendingEmotion, t, formatRelativeTime, locale, type PersonalActionsProps = { token: string; t: TranslateFn; + tasksEnabled: boolean; }; -function PersonalActions({ token, t }: PersonalActionsProps) { +function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) { return (
- + {tasksEnabled ? ( + + ) : null}
); } @@ -336,6 +341,8 @@ export default function AchievementsPage() { const { token } = useParams<{ token: string }>(); const identity = useGuestIdentity(); const { t, locale } = useTranslation(); + const { event } = useEventData(); + const tasksEnabled = isTaskModeEnabled(event); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -453,15 +460,15 @@ export default function AchievementsPage() { {t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })} - - {t('achievements.personal.stats', { - photos: formatNumber(data.personal.photos), - tasks: formatNumber(data.personal.tasks), - likes: formatNumber(data.personal.likes), - })} - + + {t('achievements.personal.stats', { + photos: formatNumber(data.personal.photos), + tasks: formatNumber(data.personal.tasks), + likes: formatNumber(data.personal.likes), + })} +
- + diff --git a/resources/js/guest/pages/HomePage.tsx b/resources/js/guest/pages/HomePage.tsx index e2817c5..8691200 100644 --- a/resources/js/guest/pages/HomePage.tsx +++ b/resources/js/guest/pages/HomePage.tsx @@ -24,6 +24,7 @@ import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/em import { getDeviceId } from '../lib/device'; import { useDirectUpload } from '../hooks/useDirectUpload'; import { useNavigate } from 'react-router-dom'; +import { isTaskModeEnabled } from '../lib/engagement'; export default function HomePage() { const { token } = useParams<{ token: string }>(); @@ -72,6 +73,7 @@ export default function HomePage() { const secondaryAccent = branding.secondaryColor; const uploadsRequireApproval = (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; + const tasksEnabled = isTaskModeEnabled(event); const [missionDeck, setMissionDeck] = React.useState([]); const [missionPool, setMissionPool] = React.useState([]); @@ -232,8 +234,9 @@ export default function HomePage() { } } poolIndexRef.current = restoredIndex; + if (!tasksEnabled) return; fetchTasksPage(1, true); - }, [fetchTasksPage, locale, sliderStateKey, token]); + }, [fetchTasksPage, locale, sliderStateKey, tasksEnabled, token]); React.useEffect(() => { if (missionPool.length === 0) return; @@ -279,6 +282,34 @@ export default function HomePage() { const introMessage = introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : ''; + if (!tasksEnabled) { + return ( +
+
+

+ {t('home.welcomeLine').replace('{name}', displayName)} +

+ {introMessage &&

{introMessage}

} +
+ +
+ +
+ + + + +
+ ); + } + return (
diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 0751829..5c4e124 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -40,6 +40,7 @@ import { useEventBranding } from '../context/EventBrandingContext'; import { compressPhoto, formatBytes } from '../lib/image'; import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useEventData } from '../hooks/useEventData'; +import { isTaskModeEnabled } from '../lib/engagement'; interface Task { id: number; @@ -118,6 +119,8 @@ export default function UploadPage() { const [searchParams] = useSearchParams(); const { markCompleted } = useGuestTaskProgress(token); const identity = useGuestIdentity(); + const { event } = useEventData(); + const tasksEnabled = isTaskModeEnabled(event); const { t, locale } = useTranslation(); const stats = useEventStats(); const { branding } = useEventBranding(); @@ -233,7 +236,7 @@ const [canUpload, setCanUpload] = useState(true); // Load task metadata useEffect(() => { - if (!token || taskId === null) { + if (!token || taskId === null || !tasksEnabled) { setLoadingTask(false); return; } @@ -249,7 +252,7 @@ const [canUpload, setCanUpload] = useState(true); const fallbackInstructions = t('upload.taskInfo.fallbackInstructions'); try { - setLoadingTask(true); + setLoadingTask(true); const res = await fetch( `/api/v1/events/${encodeURIComponent(eventKey)}/tasks?locale=${encodeURIComponent(locale)}`, diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index 5e4fc23..1cb36eb 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -15,6 +15,7 @@ import type { EventBranding } from './types/event-branding'; import type { EventBrandingPayload, FetchEventErrorCode } from './services/eventApi'; import { NotificationCenterProvider } from './context/NotificationCenterContext'; import RouteErrorElement from '@/components/RouteErrorElement'; +import { isTaskModeEnabled } from './lib/engagement'; const LandingPage = React.lazy(() => import('./pages/LandingPage')); const ProfileSetupPage = React.lazy(() => import('./pages/ProfileSetupPage')); @@ -75,8 +76,8 @@ export const router = createBrowserRouter([ errorElement: , children: [ { index: true, element: }, - { path: 'tasks', element: }, - { path: 'tasks/:taskId', element: }, + { path: 'tasks', element: }, + { path: 'tasks/:taskId', element: }, { path: 'upload', element: }, { path: 'queue', element: }, { path: 'gallery', element: }, @@ -133,6 +134,21 @@ function EventBoundary({ token }: { token: string }) { ); } +function TaskGuard({ children }: { children: React.ReactNode }) { + const { token } = useParams<{ token: string }>(); + const { event, status } = useEventData(); + + if (status === 'loading') { + return ; + } + + if (event && !isTaskModeEnabled(event)) { + return ; + } + + return <>{children}; +} + function SetupLayout() { const { token } = useParams<{ token: string }>(); const { event } = useEventData(); diff --git a/resources/js/guest/services/eventApi.ts b/resources/js/guest/services/eventApi.ts index fa0ed65..74d0165 100644 --- a/resources/js/guest/services/eventApi.ts +++ b/resources/js/guest/services/eventApi.ts @@ -54,6 +54,7 @@ export interface EventData { slug: string; name: string; default_locale: string; + engagement_mode?: 'tasks' | 'photo_only'; created_at: string; updated_at: string; join_token?: string | null; @@ -266,6 +267,7 @@ export async function fetchEvent(eventKey: string): Promise { default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== '' ? json.default_locale : DEFAULT_LOCALE, + engagement_mode: (json?.engagement_mode as 'tasks' | 'photo_only' | undefined) ?? 'tasks', guest_upload_visibility: json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review', };