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 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
{
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);

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<TenantEvent[]>({
@@ -107,6 +109,7 @@ export default function MobileDashboardPage() {
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
>
<FeaturedActions
tasksEnabled={tasksEnabled}
onReviewPhotos={() => 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}`))}
/>
<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>
);
}
@@ -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 (
<YStack space="$2">
<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 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 }));
}

View File

@@ -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 (
<MobileShell
activeTab="home"
@@ -217,7 +222,11 @@ export default function MobileEventDetailPage() {
<XStack flexWrap="wrap" space="$2">
<ActionTile
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"
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
/>

View File

@@ -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<TenantEventType[]>([]);
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() {
<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 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')}>
<XStack alignItems="center" space="$2">
<Switch

View File

@@ -7,7 +7,7 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { useEventContext } from '../context/EventContext';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { adminPath } from '../constants';
import { useTheme } from '@tamagui/core';
@@ -20,11 +20,31 @@ export default function MobileTasksTabPage() {
const muted = String(theme.gray?.val ?? '#4b5563');
const border = String(theme.borderColor?.val ?? '#e5e7eb');
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 />;
}
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) {
return (
<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 { 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,6 +104,7 @@ export default function BottomNav() {
<span>{labels.home}</span>
</div>
</TabLink>
{tasksEnabled ? (
<TabLink
to={`${base}/tasks`}
isActive={isTasksActive}
@@ -115,6 +118,7 @@ export default function BottomNav() {
<span>{labels.tasks}</span>
</div>
</TabLink>
) : null}
</div>
<Link

View File

@@ -29,6 +29,7 @@ import { useOptionalNotificationCenter, type NotificationCenterValue } from '../
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
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<HTMLDivElement | null>(null);
React.useEffect(() => {
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="font-semibold text-lg">{event.name}</div>
<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">
<User className="h-3 w-3" />
@@ -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}
/>
)}

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 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 (
<div className="flex flex-wrap gap-3">
<Button asChild>
@@ -322,12 +325,14 @@ function PersonalActions({ token, t }: PersonalActionsProps) {
{t('achievements.personal.actions.upload')}
</Link>
</Button>
{tasksEnabled ? (
<Button variant="outline" asChild>
<Link to={`/e/${encodeURIComponent(token)}/tasks`} className="flex items-center gap-2">
<Sparkles className="h-4 w-4" aria-hidden />
{t('achievements.personal.actions.tasks')}
</Link>
</Button>
) : null}
</div>
);
}
@@ -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<AchievementsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -461,7 +468,7 @@ export default function AchievementsPage() {
})}
</CardDescription>
</div>
<PersonalActions token={token} t={t} />
<PersonalActions token={token} t={t} tasksEnabled={tasksEnabled} />
</CardHeader>
</Card>

View File

@@ -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<MissionPreview[]>([]);
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
@@ -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 (
<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 (
<div className="space-y-0.5 pb-24" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<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 { 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;
}

View File

@@ -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: <RouteErrorElement />,
children: [
{ index: true, element: <HomePage /> },
{ path: 'tasks', element: <TaskPickerPage /> },
{ path: 'tasks/:taskId', element: <TaskDetailPage /> },
{ path: 'tasks', element: <TaskGuard><TaskPickerPage /></TaskGuard> },
{ path: 'tasks/:taskId', element: <TaskGuard><TaskDetailPage /></TaskGuard> },
{ path: 'upload', element: <UploadPage /> },
{ path: 'queue', element: <UploadQueuePage /> },
{ 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() {
const { token } = useParams<{ token: string }>();
const { event } = useEventData();

View File

@@ -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<EventData> {
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',
};