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

@@ -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() {
<span>{labels.home}</span>
</div>
</TabLink>
<TabLink
to={`${base}/tasks`}
isActive={isTasksActive}
accentColor={branding.primaryColor}
radius={radius}
compact={compact}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
>
<div className="flex flex-col items-center gap-1">
<CheckSquare className="h-5 w-5" aria-hidden />
<span>{labels.tasks}</span>
</div>
</TabLink>
{tasksEnabled ? (
<TabLink
to={`${base}/tasks`}
isActive={isTasksActive}
accentColor={branding.primaryColor}
radius={radius}
compact={compact}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
>
<div className="flex flex-col items-center gap-1">
<CheckSquare className="h-5 w-5" aria-hidden />
<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>
<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>
{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);
@@ -453,15 +460,15 @@ export default function AchievementsPage() {
<CardTitle className="text-lg font-semibold">
{t('achievements.personal.greeting', { name: data.personal.guestName || identity.name || t('achievements.leaderboard.guestFallback') })}
</CardTitle>
<CardDescription>
{t('achievements.personal.stats', {
photos: formatNumber(data.personal.photos),
tasks: formatNumber(data.personal.tasks),
likes: formatNumber(data.personal.likes),
})}
</CardDescription>
<CardDescription>
{t('achievements.personal.stats', {
photos: formatNumber(data.personal.photos),
tasks: formatNumber(data.personal.tasks),
likes: formatNumber(data.personal.likes),
})}
</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;
}
@@ -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)}`,

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