Add tasks setup nudge and prompt
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-16 14:41:09 +01:00
parent 9a4ece33bf
commit 1517eb8631
6 changed files with 230 additions and 4 deletions

View File

@@ -14,7 +14,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } f
import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext';
import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview, TenantPackageSummary, updateEvent } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
@@ -23,6 +23,7 @@ import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from
import { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, getPackageLimitEntries } from './lib/packageSummary';
import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context';
import toast from 'react-hot-toast';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
import { isPastEvent } from './eventDate';
@@ -51,7 +52,7 @@ export default function MobileDashboardPage() {
const location = useLocation();
const { slug: slugParam } = useParams<{ slug?: string }>();
const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext();
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent, refetch } = useEventContext();
const { status, user } = useAuth();
const isMember = user?.role === 'member';
const memberPermissions = React.useMemo(() => {
@@ -64,6 +65,10 @@ export default function MobileDashboardPage() {
() => allowPermission(memberPermissions, 'events:manage'),
[memberPermissions]
);
const canManageTasks = React.useMemo(
() => allowPermission(memberPermissions, 'tasks:manage'),
[memberPermissions]
);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [fallbackLoading, setFallbackLoading] = React.useState(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
@@ -72,6 +77,8 @@ export default function MobileDashboardPage() {
const [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
const [taskDecisionOpen, setTaskDecisionOpen] = React.useState(false);
const [taskDecisionBusy, setTaskDecisionBusy] = React.useState(false);
const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription();
@@ -90,6 +97,8 @@ export default function MobileDashboardPage() {
});
const tasksEnabled =
resolveEngagementMode(activeEvent ?? undefined) !== 'photo_only';
const tasksNeedSetup = tasksEnabled && (activeEvent?.tasks_count ?? 0) === 0;
const taskDecisionKey = activeEvent ? `tasksDecisionPrompt:${activeEvent.id}` : null;
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const { data: dashboardEvents } = useQuery<TenantEvent[]>({
@@ -114,6 +123,24 @@ export default function MobileDashboardPage() {
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
React.useEffect(() => {
if (isMember || !taskDecisionKey || !tasksNeedSetup) {
return;
}
if (typeof window === 'undefined') {
return;
}
try {
const status = window.sessionStorage.getItem(taskDecisionKey);
if (status === 'pending') {
setTaskDecisionOpen(true);
window.sessionStorage.setItem(taskDecisionKey, 'shown');
}
} catch {
// ignore storage exceptions
}
}, [isMember, taskDecisionKey, tasksNeedSetup]);
React.useEffect(() => {
if (!slugParam || slugParam === activeEvent?.slug) {
return;
@@ -374,6 +401,17 @@ export default function MobileDashboardPage() {
}
}, [activePackage?.id]);
const markTaskDecisionDismissed = React.useCallback(() => {
if (!taskDecisionKey || typeof window === 'undefined') {
return;
}
try {
window.sessionStorage.setItem(taskDecisionKey, 'dismissed');
} catch {
// ignore storage exceptions
}
}, [taskDecisionKey]);
const packageSummarySheet = activePackage ? (
<PackageSummarySheet
open={summaryOpen}
@@ -393,6 +431,43 @@ export default function MobileDashboardPage() {
locale={locale}
/>
) : null;
const taskDecisionSheet = activeEvent && tasksNeedSetup ? (
<TaskDecisionSheet
open={taskDecisionOpen}
onClose={() => {
setTaskDecisionOpen(false);
markTaskDecisionDismissed();
}}
onManageTasks={() => {
markTaskDecisionDismissed();
setTaskDecisionOpen(false);
if (activeEvent.slug) {
navigate(adminPath(`/mobile/events/${activeEvent.slug}/tasks`));
}
}}
onDisableTasks={async () => {
if (!activeEvent.slug || taskDecisionBusy) {
return;
}
setTaskDecisionBusy(true);
try {
await updateEvent(activeEvent.slug, { settings: { engagement_mode: 'photo_only' } });
void refetch();
toast.success(t('mobileDashboard.taskDecision.disabledToast', 'Tasks disabled for this event.'));
markTaskDecisionDismissed();
setTaskDecisionOpen(false);
} catch (error) {
toast.error(t('mobileDashboard.taskDecision.disableError', 'Could not disable tasks.'));
} finally {
setTaskDecisionBusy(false);
}
}}
busy={taskDecisionBusy}
canDisable={canManageEvents}
canManageTasks={canManageTasks}
/>
) : null;
const showPackageSummaryBanner =
Boolean(activePackage && summarySeenPackageId === activePackage.id) &&
!summaryOpen &&
@@ -428,6 +503,7 @@ export default function MobileDashboardPage() {
</YStack>
{tourSheet}
{packageSummarySheet}
{taskDecisionSheet}
</MobileShell>
);
}
@@ -450,6 +526,7 @@ export default function MobileDashboardPage() {
/>
{tourSheet}
{packageSummarySheet}
{taskDecisionSheet}
</MobileShell>
);
}
@@ -470,6 +547,7 @@ export default function MobileDashboardPage() {
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
{tourSheet}
{packageSummarySheet}
{taskDecisionSheet}
</MobileShell>
);
}
@@ -521,6 +599,7 @@ export default function MobileDashboardPage() {
/>
{tourSheet}
{packageSummarySheet}
{taskDecisionSheet}
<EventSwitcherSheet
open={eventSwitcherOpen}
onClose={() => setEventSwitcherOpen(false)}
@@ -654,6 +733,77 @@ function PackageSummarySheet({
);
}
function TaskDecisionSheet({
open,
onClose,
onManageTasks,
onDisableTasks,
busy,
canDisable,
canManageTasks,
}: {
open: boolean;
onClose: () => void;
onManageTasks: () => void;
onDisableTasks: () => void;
busy: boolean;
canDisable: boolean;
canManageTasks: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface } = useAdminTheme();
return (
<MobileSheet open={open} title={t('mobileDashboard.taskDecision.title', 'Set up tasks?')} onClose={onClose}>
<YStack space="$3">
<Text fontSize="$sm" color={muted}>
{t(
'mobileDashboard.taskDecision.body',
'Your event is live with tasks enabled, but no tasks are assigned yet. Choose to add tasks now or disable tasks for this event.'
)}
</Text>
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('mobileDashboard.taskDecision.promptTitle', 'Next step')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'mobileDashboard.taskDecision.promptBody',
'Guests only see missions when tasks are assigned.'
)}
</Text>
</MobileCard>
<XStack space="$2">
{canManageTasks ? (
<CTAButton
label={t('mobileDashboard.taskDecision.ctaManage', 'Add tasks')}
onPress={onManageTasks}
disabled={busy}
fullWidth={false}
/>
) : null}
{canDisable ? (
<CTAButton
label={t('mobileDashboard.taskDecision.ctaDisable', 'Disable tasks')}
tone="ghost"
onPress={onDisableTasks}
disabled={busy}
fullWidth={false}
/>
) : null}
<CTAButton
label={t('mobileDashboard.taskDecision.dismiss', 'Later')}
tone="ghost"
onPress={onClose}
disabled={busy}
fullWidth={false}
/>
</XStack>
</YStack>
</MobileSheet>
);
}
function SummaryRow({ label, value }: { label: string; value: string }) {
const { textStrong, muted } = useAdminTheme();
return (
@@ -1333,6 +1483,7 @@ function EventManagementGrid({
const { textStrong, border, surface, surfaceMuted, shadow } = useAdminTheme();
const slug = event?.slug ?? null;
const brandingAllowed = isBrandingAllowed(event ?? null);
const tasksNeedSetup = tasksEnabled && (event?.tasks_count ?? 0) === 0;
if (!event) {
return null;
@@ -1354,6 +1505,8 @@ function EventManagementGrid({
? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
color: ADMIN_ACTION_COLORS.tasks,
note: tasksNeedSetup ? t('mobileDashboard.tasksSetupNote', 'Setup needed') : undefined,
noteTone: tasksNeedSetup ? 'warning' : 'muted',
requiredPermission: 'tasks:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
disabled: !tasksEnabled || !slug,
@@ -1504,6 +1657,8 @@ function EventManagementGrid({
icon={tile.icon}
label={tile.label}
color={tile.color}
note={tile.note}
noteTone={tile.noteTone}
onPress={tile.onPress}
disabled={tile.disabled}
variant="cluster"