From 1517eb8631473524db84f914663fd4e4a99a5af6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 16 Jan 2026 14:41:09 +0100 Subject: [PATCH] Add tasks setup nudge and prompt --- .../js/admin/i18n/locales/de/management.json | 12 ++ .../js/admin/i18n/locales/en/management.json | 12 ++ resources/js/admin/mobile/DashboardPage.tsx | 159 +++++++++++++++++- resources/js/admin/mobile/EventFormPage.tsx | 14 ++ .../mobile/__tests__/DashboardPage.test.tsx | 25 ++- .../js/admin/mobile/components/Primitives.tsx | 12 +- 6 files changed, 230 insertions(+), 4 deletions(-) diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index b5d95ac..f770091 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2222,6 +2222,18 @@ "bannerSubtitle": "{{name}} ist aktiv. Prüfe Limits & Features.", "bannerCta": "Ansehen" }, + "tasksSetupNote": "Setup nötig", + "taskDecision": { + "title": "Aufgaben einrichten?", + "body": "Dein Event ist aktiv und Aufgaben sind eingeschaltet, aber es sind noch keine Aufgaben zugewiesen. Lege jetzt Aufgaben fest oder deaktiviere Aufgaben für dieses Event.", + "promptTitle": "Nächster Schritt", + "promptBody": "Gäste sehen Missionen erst, wenn Aufgaben hinterlegt sind.", + "ctaManage": "Aufgaben hinzufügen", + "ctaDisable": "Aufgaben deaktivieren", + "dismiss": "Später", + "disabledToast": "Aufgaben wurden für dieses Event deaktiviert.", + "disableError": "Aufgaben konnten nicht deaktiviert werden." + }, "pickEvent": "Event auswählen", "status": { "published": "Live", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 6dfbe74..15992c0 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2226,6 +2226,18 @@ "bannerSubtitle": "{{name}} is active. Review limits & features.", "bannerCta": "View" }, + "tasksSetupNote": "Setup needed", + "taskDecision": { + "title": "Set up tasks?", + "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.", + "promptTitle": "Next step", + "promptBody": "Guests only see missions when tasks are assigned.", + "ctaManage": "Add tasks", + "ctaDisable": "Disable tasks", + "dismiss": "Later", + "disabledToast": "Tasks disabled for this event.", + "disableError": "Could not disable tasks." + }, "pickEvent": "Select an event", "status": { "published": "Live", diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 6226c15..9555c56 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -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([]); 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(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({ @@ -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 ? ( ) : null; + + const taskDecisionSheet = activeEvent && tasksNeedSetup ? ( + { + 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() { {tourSheet} {packageSummarySheet} + {taskDecisionSheet} ); } @@ -450,6 +526,7 @@ export default function MobileDashboardPage() { /> {tourSheet} {packageSummarySheet} + {taskDecisionSheet} ); } @@ -470,6 +547,7 @@ export default function MobileDashboardPage() { {tourSheet} {packageSummarySheet} + {taskDecisionSheet} ); } @@ -521,6 +599,7 @@ export default function MobileDashboardPage() { /> {tourSheet} {packageSummarySheet} + {taskDecisionSheet} 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 ( + + + + {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.' + )} + + + + {t('mobileDashboard.taskDecision.promptTitle', 'Next step')} + + + {t( + 'mobileDashboard.taskDecision.promptBody', + 'Guests only see missions when tasks are assigned.' + )} + + + + {canManageTasks ? ( + + ) : null} + {canDisable ? ( + + ) : null} + + + + + ); +} + 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" diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index e9840b3..88880a0 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -263,6 +263,13 @@ export default function MobileEventFormPage() { }, } as Parameters[0]; const { event } = await createEvent(payload); + if (typeof window !== 'undefined' && form.tasksEnabled) { + try { + window.sessionStorage.setItem(`tasksDecisionPrompt:${event.id}`, 'pending'); + } catch { + // ignore storage exceptions + } + } selectEvent(event.slug); void queryClient.invalidateQueries({ queryKey: ['tenant-events'] }); void refetch(); @@ -314,6 +321,13 @@ export default function MobileEventFormPage() { ...pendingPayload, accepted_waiver: consents.acceptedWaiver, }); + if (typeof window !== 'undefined' && form.tasksEnabled) { + try { + window.sessionStorage.setItem(`tasksDecisionPrompt:${event.id}`, 'pending'); + } catch { + // ignore storage exceptions + } + } selectEvent(event.slug); void queryClient.invalidateQueries({ queryKey: ['tenant-events'] }); void refetch(); diff --git a/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx b/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx index be1a43e..91a3e9a 100644 --- a/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx @@ -149,7 +149,12 @@ vi.mock('../components/Primitives', () => ({ {value} ), - ActionTile: ({ label }: { label: string }) =>
{label}
, + ActionTile: ({ label, note }: { label: string; note?: string }) => ( +
+ {label} + {note ? {note} : null} +
+ ), PillBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, SkeletonCard: () =>
Loading...
, })); @@ -242,7 +247,25 @@ describe('MobileDashboardPage', () => { fixtures.activePackage.package_type = 'reseller'; fixtures.activePackage.remaining_events = 3; + fixtures.event.tasks_count = 4; + fixtures.event.engagement_mode = undefined; navigateMock.mockClear(); + window.sessionStorage.clear(); + }); + + it('shows a tasks setup nudge and prompt when no tasks are assigned', () => { + fixtures.event.tasks_count = 0; + fixtures.event.engagement_mode = 'tasks'; + window.sessionStorage.setItem(`tasksDecisionPrompt:${fixtures.event.id}`, 'pending'); + + render(); + + expect(screen.getByText('Setup needed')).toBeInTheDocument(); + expect( + screen.getByText( + 'Your event is live with tasks enabled, but no tasks are assigned yet. Choose to add tasks now or disable tasks for this event.' + ) + ).toBeInTheDocument(); }); it('does not redirect endcustomer packages without remaining event quota', () => { diff --git a/resources/js/admin/mobile/components/Primitives.tsx b/resources/js/admin/mobile/components/Primitives.tsx index ac3abba..c02426b 100644 --- a/resources/js/admin/mobile/components/Primitives.tsx +++ b/resources/js/admin/mobile/components/Primitives.tsx @@ -191,6 +191,8 @@ export function SkeletonCard({ height = 80 }: { height?: number }) { export function ActionTile({ icon: IconCmp, label, + note, + noteTone = 'muted', color, onPress, disabled = false, @@ -199,13 +201,16 @@ export function ActionTile({ }: { icon: React.ComponentType<{ size?: number; color?: string }>; label: string; + note?: string; + noteTone?: 'warning' | 'muted'; color: string; onPress?: () => void; disabled?: boolean; variant?: 'grid' | 'cluster'; delayMs?: number; }) { - const { textStrong, glassSurface } = useAdminTheme(); + const { textStrong, glassSurface, muted, warningText } = useAdminTheme(); + const noteColor = noteTone === 'warning' ? warningText : muted; const isCluster = variant === 'cluster'; const backgroundColor = withAlpha(color, 0.12); const borderColor = withAlpha(color, 0.4); @@ -254,6 +259,11 @@ export function ActionTile({ {label} + {note ? ( + + {note} + + ) : null} );