Das Abschalten des Aufgaben-Modus wird nun sauber in der App reflektiert- die UI passt sich an und der Admin erhält einen Hinweis, dass die Aufgabenverwaltung nicht verfügbar ist

This commit is contained in:
Codex Agent
2025-12-17 13:20:48 +01:00
parent 03e37d7e23
commit efe697f155
15 changed files with 297 additions and 62 deletions

View File

@@ -659,6 +659,25 @@ class EventPublicController extends BaseController
return $trimmed; return $trimmed;
} }
/**
* @return array<string, mixed>
*/
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 private function buildDeterministicSeed(?string $identifier): int
{ {
if ($identifier === null || trim($identifier) === '') { if ($identifier === null || trim($identifier) === '') {
@@ -1757,6 +1776,8 @@ class EventPublicController extends BaseController
]; ];
$branding = $this->buildGalleryBranding($event); $branding = $this->buildGalleryBranding($event);
$settings = $this->normalizeSettings($event->settings ?? []);
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
if ($joinToken) { if ($joinToken) {
$this->joinTokenService->incrementUsage($joinToken); $this->joinTokenService->incrementUsage($joinToken);
@@ -1774,6 +1795,7 @@ class EventPublicController extends BaseController
'photobooth_enabled' => (bool) $event->photobooth_enabled, 'photobooth_enabled' => (bool) $event->photobooth_enabled,
'branding' => $branding, 'branding' => $branding,
'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'), 'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'),
'engagement_mode' => $engagementMode,
])->header('Cache-Control', 'no-store'); ])->header('Cache-Control', 'no-store');
} }
@@ -2365,7 +2387,7 @@ class EventPublicController extends BaseController
public function stats(Request $request, string $token) public function stats(Request $request, string $token)
{ {
$result = $this->resolvePublishedEvent($request, $token, ['id']); $result = $this->resolvePublishedEvent($request, $token, ['id', 'settings']);
if ($result instanceof JsonResponse) { if ($result instanceof JsonResponse) {
return $result; return $result;
@@ -2374,6 +2396,8 @@ class EventPublicController extends BaseController
[$event, $joinToken] = $result; [$event, $joinToken] = $result;
$eventId = $event->id; $eventId = $event->id;
$eventModel = Event::with('storageAssignments.storageTarget')->findOrFail($eventId); $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. // Approximate online guests as distinct recent uploaders in last 10 minutes.
$tenMinutesAgo = CarbonImmutable::now()->subMinutes(10); $tenMinutesAgo = CarbonImmutable::now()->subMinutes(10);
@@ -2384,7 +2408,9 @@ class EventPublicController extends BaseController
->count('guest_name'); ->count('guest_name');
// Tasks solved as number of photos linked to a task (proxy metric). // 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'); $latestPhotoAt = DB::table('photos')->where('event_id', $eventId)->max('created_at');
@@ -2392,6 +2418,7 @@ class EventPublicController extends BaseController
'online_guests' => $onlineGuests, 'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved, 'tasks_solved' => $tasksSolved,
'latest_photo_at' => $latestPhotoAt, 'latest_photo_at' => $latestPhotoAt,
'engagement_mode' => $engagementMode,
]; ];
$etag = sha1(json_encode($payload)); $etag = sha1(json_encode($payload));
@@ -2472,7 +2499,7 @@ class EventPublicController extends BaseController
public function tasks(Request $request, string $token) 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) { if ($result instanceof JsonResponse) {
return $result; return $result;
@@ -2480,8 +2507,35 @@ class EventPublicController extends BaseController
[$event, $joinToken] = $result; [$event, $joinToken] = $result;
$settings = $this->normalizeSettings($event->settings ?? null);
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
[$resolvedLocale] = $this->resolveGuestLocale($request, $event); [$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) { $cached = $this->eventTasksCache->remember((int) $event->id, $resolvedLocale, function () use ($event, $resolvedLocale) {
return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null); return $this->buildLocalizedTasksPayload((int) $event->id, $resolvedLocale, $event->default_locale ?? null);
}); });
@@ -2489,9 +2543,6 @@ class EventPublicController extends BaseController
$tasks = $cached['tasks']; $tasks = $cached['tasks'];
$baseHash = $cached['hash'] ?? sha1(json_encode($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. // Shuffle per request for unpredictability; stable when seeded by guest/device or explicit seed.
$seedParam = $request->query('seed'); $seedParam = $request->query('seed');
$guestIdentifier = $this->determineGuestIdentifier($request); $guestIdentifier = $this->determineGuestIdentifier($request);

View File

@@ -1699,6 +1699,11 @@
"publish": { "publish": {
"label": "Event sofort veröffentlichen", "label": "Event sofort veröffentlichen",
"help": "Aktiviere diese Option, wenn Gäste das Event direkt sehen sollen. Du kannst den Status später ändern." "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": { "actions": {
@@ -1781,6 +1786,8 @@
"saveFailed": "Task konnte nicht gespeichert werden." "saveFailed": "Task konnte nicht gespeichert werden."
}, },
"tasks": { "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", "title": "Tasks & Checklisten",
"actions": "Aktionen", "actions": "Aktionen",
"assigned": "Task hinzugefügt", "assigned": "Task hinzugefügt",
@@ -1859,6 +1866,7 @@
"photosDesc": "Uploads und Highlights moderieren", "photosDesc": "Uploads und Highlights moderieren",
"tasksLabel": "Tasks & Challenges verwalten", "tasksLabel": "Tasks & Challenges verwalten",
"tasksDesc": "Zuweisen und Fortschritt verfolgen", "tasksDesc": "Zuweisen und Fortschritt verfolgen",
"tasksDisabledDesc": "Tasks werden Gästen nicht angezeigt (Task-Modus aus)",
"qrLabel": "QR-Code anzeigen/teilen", "qrLabel": "QR-Code anzeigen/teilen",
"qrDesc": "Poster, Karten und Links", "qrDesc": "Poster, Karten und Links",
"shortcutsTitle": "Shortcuts", "shortcutsTitle": "Shortcuts",

View File

@@ -1719,6 +1719,11 @@
"publish": { "publish": {
"label": "Publish immediately", "label": "Publish immediately",
"help": "Enable if guests should see the event right away. You can change the status later." "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": { "actions": {
@@ -1801,6 +1806,8 @@
"saveFailed": "Task could not be saved." "saveFailed": "Task could not be saved."
}, },
"tasks": { "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", "title": "Tasks & checklists",
"actions": "Actions", "actions": "Actions",
"assigned": "Task added", "assigned": "Task added",
@@ -1879,6 +1886,7 @@
"photosDesc": "Moderate uploads and highlights", "photosDesc": "Moderate uploads and highlights",
"tasksLabel": "Manage tasks & challenges", "tasksLabel": "Manage tasks & challenges",
"tasksDesc": "Assign and track progress", "tasksDesc": "Assign and track progress",
"tasksDisabledDesc": "Guests do not see tasks (task mode off)",
"qrLabel": "Show / share QR code", "qrLabel": "Show / share QR code",
"qrDesc": "Posters, cards, and links", "qrDesc": "Posters, cards, and links",
"shortcutsTitle": "Shortcuts", "shortcutsTitle": "Shortcuts",

View File

@@ -11,7 +11,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge } from './compone
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api'; 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'; import { useTheme } from '@tamagui/core';
export default function MobileDashboardPage() { export default function MobileDashboardPage() {
@@ -37,6 +37,8 @@ export default function MobileDashboardPage() {
return await getEventStats(activeEvent.slug); return await getEventStats(activeEvent.slug);
}, },
}); });
const tasksEnabled =
resolveEngagementMode(activeEvent ?? undefined) !== 'photo_only';
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE'; const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const { data: dashboardEvents } = useQuery<TenantEvent[]>({ const { data: dashboardEvents } = useQuery<TenantEvent[]>({
@@ -107,6 +109,7 @@ export default function MobileDashboardPage() {
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined} subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
> >
<FeaturedActions <FeaturedActions
tasksEnabled={tasksEnabled}
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))} onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))} onManageTasks={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`))}
onShowQr={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/qr`))} 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}`))} onSettings={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}`))}
/> />
<KpiStrip event={activeEvent} stats={stats} loading={statsLoading} locale={locale} /> <KpiStrip
event={activeEvent}
stats={stats}
loading={statsLoading}
locale={locale}
tasksEnabled={tasksEnabled}
/>
<AlertsAndHints event={activeEvent} stats={stats} /> <AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
</MobileShell> </MobileShell>
); );
} }
@@ -370,10 +379,12 @@ function EventPickerList({ events, locale, text, muted, border }: { events: Tena
} }
function FeaturedActions({ function FeaturedActions({
tasksEnabled,
onReviewPhotos, onReviewPhotos,
onManageTasks, onManageTasks,
onShowQr, onShowQr,
}: { }: {
tasksEnabled: boolean;
onReviewPhotos: () => void; onReviewPhotos: () => void;
onManageTasks: () => void; onManageTasks: () => void;
onShowQr: () => void; onShowQr: () => void;
@@ -394,7 +405,9 @@ function FeaturedActions({
{ {
key: 'tasks', key: 'tasks',
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'), 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, icon: ListTodo,
color: '#22c55e', color: '#22c55e',
action: onManageTasks, 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 { t } = useTranslation('management');
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc'); 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; if (!event) return null;
const kpis = [ const kpis = [
{
label: t('mobileDashboard.kpiTasks', 'Open tasks'),
value: event.tasks_count ?? '—',
icon: ListTodo,
},
{ {
label: t('mobileDashboard.kpiPhotos', 'Photos'), label: t('mobileDashboard.kpiPhotos', 'Photos'),
value: stats?.uploads_total ?? event.photo_count ?? '—', 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 ( return (
<YStack space="$2"> <YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}> <Text fontSize="$sm" fontWeight="800" color={text}>
@@ -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 { t } = useTranslation('management');
const theme = useTheme(); const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc'); 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) { if (stats?.pending_photos) {
alerts.push(t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: 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 })); alerts.push(t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count }));
} }

View File

@@ -13,7 +13,7 @@ import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { isPastEvent } from './eventDate'; import { isPastEvent } from './eventDate';
export default function MobileEventDetailPage() { export default function MobileEventDetailPage() {
@@ -66,12 +66,9 @@ export default function MobileEventDetailPage() {
})(); })();
}, [slug, t]); }, [slug, t]);
const tasksEnabled = resolveEngagementMode(event ?? activeEvent ?? null) !== 'photo_only';
const kpis = [ 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'), label: t('events.detail.kpi.guests', 'Guests Registered'),
value: toolkit?.invites?.summary.total ?? event?.active_invites_count ?? '—', 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 ( return (
<MobileShell <MobileShell
activeTab="home" activeTab="home"
@@ -217,7 +222,11 @@ export default function MobileEventDetailPage() {
<XStack flexWrap="wrap" space="$2"> <XStack flexWrap="wrap" space="$2">
<ActionTile <ActionTile
icon={Sparkles} icon={Sparkles}
label={t('events.quick.tasks', 'Tasks & Checklists')} label={
tasksEnabled
? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`
}
color="#60a5fa" color="#60a5fa"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))} onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
/> />

View File

@@ -20,6 +20,7 @@ type FormState = {
location: string; location: string;
published: boolean; published: boolean;
autoApproveUploads: boolean; autoApproveUploads: boolean;
tasksEnabled: boolean;
}; };
export default function MobileEventFormPage() { export default function MobileEventFormPage() {
@@ -37,6 +38,7 @@ export default function MobileEventFormPage() {
location: '', location: '',
published: false, published: false,
autoApproveUploads: true, autoApproveUploads: true,
tasksEnabled: true,
}); });
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]); const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false); const [typesLoading, setTypesLoading] = React.useState(false);
@@ -59,6 +61,9 @@ export default function MobileEventFormPage() {
published: data.status === 'published', published: data.status === 'published',
autoApproveUploads: autoApproveUploads:
(data.settings?.guest_upload_visibility as string | undefined) === 'immediate', (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); setError(null);
} catch (err) { } catch (err) {
@@ -102,6 +107,7 @@ export default function MobileEventFormPage() {
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
}, },
}); });
navigate(adminPath(`/mobile/events/${slug}`)); navigate(adminPath(`/mobile/events/${slug}`));
@@ -115,6 +121,7 @@ export default function MobileEventFormPage() {
settings: { settings: {
location: form.location, location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review', guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
engagement_mode: form.tasksEnabled ? 'tasks' : 'photo_only',
}, },
}; };
const { event } = await createEvent(payload as any); const { event } = await createEvent(payload as any);
@@ -228,6 +235,37 @@ export default function MobileEventFormPage() {
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text> <Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
</Field> </Field>
<Field label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}>
<XStack alignItems="center" space="$2">
<Switch
checked={form.tasksEnabled}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, tasksEnabled: Boolean(checked) }))
}
size="$3"
aria-label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
{form.tasksEnabled
? t('common:states.enabled', 'Enabled')
: t('common:states.disabled', 'Disabled')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{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.',
)}
</Text>
</Field>
<Field label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}> <Field label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
<Switch <Switch

View File

@@ -7,7 +7,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives'; import { MobileCard, CTAButton } from './components/Primitives';
import { useEventContext } from '../context/EventContext'; import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events'; import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { useTheme } from '@tamagui/core'; import { useTheme } from '@tamagui/core';
@@ -20,11 +20,31 @@ export default function MobileTasksTabPage() {
const muted = String(theme.gray?.val ?? '#4b5563'); const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb'); const border = String(theme.borderColor?.val ?? '#e5e7eb');
const primary = String(theme.primary?.val ?? '#007AFF'); const primary = String(theme.primary?.val ?? '#007AFF');
const tasksEnabled = resolveEngagementMode(activeEvent ?? null) !== 'photo_only';
if (activeEvent?.slug) { if (activeEvent?.slug && tasksEnabled) {
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/tasks`)} replace />; return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/tasks`)} replace />;
} }
if (activeEvent?.slug && !tasksEnabled) {
return (
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Tasks')}>
<MobileCard alignItems="flex-start" space="$3">
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('events.tasks.disabledTitle', 'Task mode is off for this event')}
</Text>
<Text fontSize="$sm" color={muted}>
{t('events.tasks.disabledBody', 'Guests see only the photo feed. Enable tasks in the event settings to show them again.')}
</Text>
<CTAButton
label={t('events.actions.settings', 'Event settings')}
onPress={() => navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))}
/>
</MobileCard>
</MobileShell>
);
}
if (!hasEvents) { if (!hasEvents) {
return ( return (
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Tasks')}> <MobileShell activeTab="tasks" title={t('events.tasks.title', 'Tasks')}>

View File

@@ -4,6 +4,7 @@ import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-rea
import { useEventData } from '../hooks/useEventData'; import { useEventData } from '../hooks/useEventData';
import { useTranslation } from '../i18n/useTranslation'; import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import { isTaskModeEnabled } from '../lib/engagement';
function TabLink({ function TabLink({
to, to,
@@ -64,6 +65,7 @@ export default function BottomNav() {
const base = `/e/${encodeURIComponent(token)}`; const base = `/e/${encodeURIComponent(token)}`;
const currentPath = location.pathname; const currentPath = location.pathname;
const tasksEnabled = isTaskModeEnabled(event);
const labels = { const labels = {
home: t('navigation.home'), home: t('navigation.home'),
@@ -102,6 +104,7 @@ export default function BottomNav() {
<span>{labels.home}</span> <span>{labels.home}</span>
</div> </div>
</TabLink> </TabLink>
{tasksEnabled ? (
<TabLink <TabLink
to={`${base}/tasks`} to={`${base}/tasks`}
isActive={isTasksActive} isActive={isTasksActive}
@@ -115,6 +118,7 @@ export default function BottomNav() {
<span>{labels.tasks}</span> <span>{labels.tasks}</span>
</div> </div>
</TabLink> </TabLink>
) : null}
</div> </div>
<Link <Link

View File

@@ -29,6 +29,7 @@ import { useOptionalNotificationCenter, type NotificationCenterValue } from '../
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress'; import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
import { usePushSubscription } from '../hooks/usePushSubscription'; import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance } from '../lib/color'; import { getContrastingTextColor, relativeLuminance } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = { const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
heart: Heart, heart: Heart,
@@ -150,6 +151,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new'); const [statusFilter, setStatusFilter] = React.useState<'new' | 'read' | 'dismissed' | 'all'>('new');
const taskProgress = useGuestTaskProgress(eventToken); const taskProgress = useGuestTaskProgress(eventToken);
const tasksEnabled = isTaskModeEnabled(event);
const panelRef = React.useRef<HTMLDivElement | null>(null); const panelRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => { React.useEffect(() => {
if (!notificationsOpen) { if (!notificationsOpen) {
@@ -220,7 +222,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
<div className="flex flex-col" style={headerFont ? { fontFamily: headerFont } : undefined}> <div className="flex flex-col" style={headerFont ? { fontFamily: headerFont } : undefined}>
<div className="font-semibold text-lg">{event.name}</div> <div className="font-semibold text-lg">{event.name}</div>
<div className="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}> <div className="flex items-center gap-2 text-xs text-white/70" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{stats && ( {stats && tasksEnabled && (
<> <>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
@@ -244,7 +246,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
open={notificationsOpen} open={notificationsOpen}
onToggle={() => setNotificationsOpen((prev) => !prev)} onToggle={() => setNotificationsOpen((prev) => !prev)}
panelRef={panelRef} panelRef={panelRef}
taskProgress={taskProgress?.hydrated ? taskProgress : undefined} taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
t={t} t={t}
/> />
)} )}

View File

@@ -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;
}

View File

@@ -21,6 +21,8 @@ import { Sparkles, Award, Trophy, Camera, Users, BarChart2, Flame } from 'lucide
import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import type { LocaleCode } from '../i18n/messages'; import type { LocaleCode } from '../i18n/messages';
import { localizeTaskLabel } from '../lib/localizeTaskLabel'; import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { useEventData } from '../hooks/useEventData';
import { isTaskModeEnabled } from '../lib/engagement';
const GENERIC_ERROR = 'GENERIC_ERROR'; const GENERIC_ERROR = 'GENERIC_ERROR';
@@ -311,9 +313,10 @@ function Highlights({ topPhoto, trendingEmotion, t, formatRelativeTime, locale,
type PersonalActionsProps = { type PersonalActionsProps = {
token: string; token: string;
t: TranslateFn; t: TranslateFn;
tasksEnabled: boolean;
}; };
function PersonalActions({ token, t }: PersonalActionsProps) { function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) {
return ( return (
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button asChild> <Button asChild>
@@ -322,12 +325,14 @@ function PersonalActions({ token, t }: PersonalActionsProps) {
{t('achievements.personal.actions.upload')} {t('achievements.personal.actions.upload')}
</Link> </Link>
</Button> </Button>
{tasksEnabled ? (
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2"> <Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" aria-hidden /> <Sparkles className="h-4 w-4" aria-hidden />
{t('achievements.personal.actions.tasks')} {t('achievements.personal.actions.tasks')}
</Link> </Link>
</Button> </Button>
) : null}
</div> </div>
); );
} }
@@ -336,6 +341,8 @@ export default function AchievementsPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const identity = useGuestIdentity(); const identity = useGuestIdentity();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const { event } = useEventData();
const tasksEnabled = isTaskModeEnabled(event);
const [data, setData] = useState<AchievementsPayload | null>(null); const [data, setData] = useState<AchievementsPayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -461,7 +468,7 @@ export default function AchievementsPage() {
})} })}
</CardDescription> </CardDescription>
</div> </div>
<PersonalActions token={token} t={t} /> <PersonalActions token={token} t={t} tasksEnabled={tasksEnabled} />
</CardHeader> </CardHeader>
</Card> </Card>

View File

@@ -24,6 +24,7 @@ import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/em
import { getDeviceId } from '../lib/device'; import { getDeviceId } from '../lib/device';
import { useDirectUpload } from '../hooks/useDirectUpload'; import { useDirectUpload } from '../hooks/useDirectUpload';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { isTaskModeEnabled } from '../lib/engagement';
export default function HomePage() { export default function HomePage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -72,6 +73,7 @@ export default function HomePage() {
const secondaryAccent = branding.secondaryColor; const secondaryAccent = branding.secondaryColor;
const uploadsRequireApproval = const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate'; (event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const tasksEnabled = isTaskModeEnabled(event);
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]); const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]); const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
@@ -232,8 +234,9 @@ export default function HomePage() {
} }
} }
poolIndexRef.current = restoredIndex; poolIndexRef.current = restoredIndex;
if (!tasksEnabled) return;
fetchTasksPage(1, true); fetchTasksPage(1, true);
}, [fetchTasksPage, locale, sliderStateKey, token]); }, [fetchTasksPage, locale, sliderStateKey, tasksEnabled, token]);
React.useEffect(() => { React.useEffect(() => {
if (missionPool.length === 0) return; if (missionPool.length === 0) return;
@@ -279,6 +282,34 @@ export default function HomePage() {
const introMessage = const introMessage =
introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : ''; introArray.length > 0 ? introArray[Math.floor(Math.random() * introArray.length)] : '';
if (!tasksEnabled) {
return (
<div className="space-y-3 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<section className="space-y-1 px-4">
<p className="text-sm font-semibold text-foreground">
{t('home.welcomeLine').replace('{name}', displayName)}
</p>
{introMessage && <p className="text-xs text-muted-foreground">{introMessage}</p>}
</section>
<section className="space-y-2 px-4">
<UploadActionCard
token={token}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
radius={radius}
bodyFont={bodyFont}
requiresApproval={uploadsRequireApproval}
/>
</section>
<Separator />
<GalleryPreview token={token} />
</div>
);
}
return ( return (
<div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}> <div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<section className="space-y-1 px-4"> <section className="space-y-1 px-4">

View File

@@ -40,6 +40,7 @@ import { useEventBranding } from '../context/EventBrandingContext';
import { compressPhoto, formatBytes } from '../lib/image'; import { compressPhoto, formatBytes } from '../lib/image';
import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData'; import { useEventData } from '../hooks/useEventData';
import { isTaskModeEnabled } from '../lib/engagement';
interface Task { interface Task {
id: number; id: number;
@@ -118,6 +119,8 @@ export default function UploadPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { markCompleted } = useGuestTaskProgress(token); const { markCompleted } = useGuestTaskProgress(token);
const identity = useGuestIdentity(); const identity = useGuestIdentity();
const { event } = useEventData();
const tasksEnabled = isTaskModeEnabled(event);
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const stats = useEventStats(); const stats = useEventStats();
const { branding } = useEventBranding(); const { branding } = useEventBranding();
@@ -233,7 +236,7 @@ const [canUpload, setCanUpload] = useState(true);
// Load task metadata // Load task metadata
useEffect(() => { useEffect(() => {
if (!token || taskId === null) { if (!token || taskId === null || !tasksEnabled) {
setLoadingTask(false); setLoadingTask(false);
return; return;
} }

View File

@@ -15,6 +15,7 @@ import type { EventBranding } from './types/event-branding';
import type { EventBrandingPayload, FetchEventErrorCode } from './services/eventApi'; import type { EventBrandingPayload, FetchEventErrorCode } from './services/eventApi';
import { NotificationCenterProvider } from './context/NotificationCenterContext'; import { NotificationCenterProvider } from './context/NotificationCenterContext';
import RouteErrorElement from '@/components/RouteErrorElement'; import RouteErrorElement from '@/components/RouteErrorElement';
import { isTaskModeEnabled } from './lib/engagement';
const LandingPage = React.lazy(() => import('./pages/LandingPage')); const LandingPage = React.lazy(() => import('./pages/LandingPage'));
const ProfileSetupPage = React.lazy(() => import('./pages/ProfileSetupPage')); const ProfileSetupPage = React.lazy(() => import('./pages/ProfileSetupPage'));
@@ -75,8 +76,8 @@ export const router = createBrowserRouter([
errorElement: <RouteErrorElement />, errorElement: <RouteErrorElement />,
children: [ children: [
{ index: true, element: <HomePage /> }, { index: true, element: <HomePage /> },
{ path: 'tasks', element: <TaskPickerPage /> }, { path: 'tasks', element: <TaskGuard><TaskPickerPage /></TaskGuard> },
{ path: 'tasks/:taskId', element: <TaskDetailPage /> }, { path: 'tasks/:taskId', element: <TaskGuard><TaskDetailPage /></TaskGuard> },
{ path: 'upload', element: <UploadPage /> }, { path: 'upload', element: <UploadPage /> },
{ path: 'queue', element: <UploadQueuePage /> }, { path: 'queue', element: <UploadQueuePage /> },
{ path: 'gallery', element: <GalleryPage /> }, { path: 'gallery', element: <GalleryPage /> },
@@ -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 <EventLoadingView />;
}
if (event && !isTaskModeEnabled(event)) {
return <Navigate to={`/e/${encodeURIComponent(token ?? '')}`} replace />;
}
return <>{children}</>;
}
function SetupLayout() { function SetupLayout() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
const { event } = useEventData(); const { event } = useEventData();

View File

@@ -54,6 +54,7 @@ export interface EventData {
slug: string; slug: string;
name: string; name: string;
default_locale: string; default_locale: string;
engagement_mode?: 'tasks' | 'photo_only';
created_at: string; created_at: string;
updated_at: string; updated_at: string;
join_token?: string | null; join_token?: string | null;
@@ -266,6 +267,7 @@ export async function fetchEvent(eventKey: string): Promise<EventData> {
default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== '' default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== ''
? json.default_locale ? json.default_locale
: DEFAULT_LOCALE, : DEFAULT_LOCALE,
engagement_mode: (json?.engagement_mode as 'tasks' | 'photo_only' | undefined) ?? 'tasks',
guest_upload_visibility: guest_upload_visibility:
json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review', json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review',
}; };