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

@@ -2222,6 +2222,18 @@
"bannerSubtitle": "{{name}} ist aktiv. Prüfe Limits & Features.", "bannerSubtitle": "{{name}} ist aktiv. Prüfe Limits & Features.",
"bannerCta": "Ansehen" "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", "pickEvent": "Event auswählen",
"status": { "status": {
"published": "Live", "published": "Live",

View File

@@ -2226,6 +2226,18 @@
"bannerSubtitle": "{{name}} is active. Review limits & features.", "bannerSubtitle": "{{name}} is active. Review limits & features.",
"bannerCta": "View" "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", "pickEvent": "Select an event",
"status": { "status": {
"published": "Live", "published": "Live",

View File

@@ -14,7 +14,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } f
import { MobileSheet } from './components/Sheet'; import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants'; import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext'; 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 { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions'; 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 { collectPackageFeatures, formatPackageLimit, getPackageFeatureLabel, getPackageLimitEntries } from './lib/packageSummary';
import { trackOnboarding } from '../api'; import { trackOnboarding } from '../api';
import { useAuth } from '../auth/context'; import { useAuth } from '../auth/context';
import toast from 'react-hot-toast';
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme'; import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
import { isPastEvent } from './eventDate'; import { isPastEvent } from './eventDate';
@@ -51,7 +52,7 @@ export default function MobileDashboardPage() {
const location = useLocation(); const location = useLocation();
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
const { t, i18n } = useTranslation('management'); 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 { status, user } = useAuth();
const isMember = user?.role === 'member'; const isMember = user?.role === 'member';
const memberPermissions = React.useMemo(() => { const memberPermissions = React.useMemo(() => {
@@ -64,6 +65,10 @@ export default function MobileDashboardPage() {
() => allowPermission(memberPermissions, 'events:manage'), () => allowPermission(memberPermissions, 'events:manage'),
[memberPermissions] [memberPermissions]
); );
const canManageTasks = React.useMemo(
() => allowPermission(memberPermissions, 'tasks:manage'),
[memberPermissions]
);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]); const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [fallbackLoading, setFallbackLoading] = React.useState(false); const [fallbackLoading, setFallbackLoading] = React.useState(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
@@ -72,6 +77,8 @@ export default function MobileDashboardPage() {
const [summaryOpen, setSummaryOpen] = React.useState(false); const [summaryOpen, setSummaryOpen] = React.useState(false);
const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null); const [summarySeenOverride, setSummarySeenOverride] = React.useState<number | null>(null);
const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false); const [eventSwitcherOpen, setEventSwitcherOpen] = React.useState(false);
const [taskDecisionOpen, setTaskDecisionOpen] = React.useState(false);
const [taskDecisionBusy, setTaskDecisionBusy] = React.useState(false);
const onboardingTrackedRef = React.useRef(false); const onboardingTrackedRef = React.useRef(false);
const installPrompt = useInstallPrompt(); const installPrompt = useInstallPrompt();
const pushState = useAdminPushSubscription(); const pushState = useAdminPushSubscription();
@@ -90,6 +97,8 @@ export default function MobileDashboardPage() {
}); });
const tasksEnabled = const tasksEnabled =
resolveEngagementMode(activeEvent ?? undefined) !== 'photo_only'; 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 locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const { data: dashboardEvents } = useQuery<TenantEvent[]>({ const { data: dashboardEvents } = useQuery<TenantEvent[]>({
@@ -114,6 +123,24 @@ export default function MobileDashboardPage() {
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null; const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]); 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(() => { React.useEffect(() => {
if (!slugParam || slugParam === activeEvent?.slug) { if (!slugParam || slugParam === activeEvent?.slug) {
return; return;
@@ -374,6 +401,17 @@ export default function MobileDashboardPage() {
} }
}, [activePackage?.id]); }, [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 ? ( const packageSummarySheet = activePackage ? (
<PackageSummarySheet <PackageSummarySheet
open={summaryOpen} open={summaryOpen}
@@ -393,6 +431,43 @@ export default function MobileDashboardPage() {
locale={locale} locale={locale}
/> />
) : null; ) : 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 = const showPackageSummaryBanner =
Boolean(activePackage && summarySeenPackageId === activePackage.id) && Boolean(activePackage && summarySeenPackageId === activePackage.id) &&
!summaryOpen && !summaryOpen &&
@@ -428,6 +503,7 @@ export default function MobileDashboardPage() {
</YStack> </YStack>
{tourSheet} {tourSheet}
{packageSummarySheet} {packageSummarySheet}
{taskDecisionSheet}
</MobileShell> </MobileShell>
); );
} }
@@ -450,6 +526,7 @@ export default function MobileDashboardPage() {
/> />
{tourSheet} {tourSheet}
{packageSummarySheet} {packageSummarySheet}
{taskDecisionSheet}
</MobileShell> </MobileShell>
); );
} }
@@ -470,6 +547,7 @@ export default function MobileDashboardPage() {
<EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} /> <EventPickerList events={effectiveEvents} locale={locale} navigateOnSelect={false} />
{tourSheet} {tourSheet}
{packageSummarySheet} {packageSummarySheet}
{taskDecisionSheet}
</MobileShell> </MobileShell>
); );
} }
@@ -521,6 +599,7 @@ export default function MobileDashboardPage() {
/> />
{tourSheet} {tourSheet}
{packageSummarySheet} {packageSummarySheet}
{taskDecisionSheet}
<EventSwitcherSheet <EventSwitcherSheet
open={eventSwitcherOpen} open={eventSwitcherOpen}
onClose={() => setEventSwitcherOpen(false)} 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 }) { function SummaryRow({ label, value }: { label: string; value: string }) {
const { textStrong, muted } = useAdminTheme(); const { textStrong, muted } = useAdminTheme();
return ( return (
@@ -1333,6 +1483,7 @@ function EventManagementGrid({
const { textStrong, border, surface, surfaceMuted, shadow } = useAdminTheme(); const { textStrong, border, surface, surfaceMuted, shadow } = useAdminTheme();
const slug = event?.slug ?? null; const slug = event?.slug ?? null;
const brandingAllowed = isBrandingAllowed(event ?? null); const brandingAllowed = isBrandingAllowed(event ?? null);
const tasksNeedSetup = tasksEnabled && (event?.tasks_count ?? 0) === 0;
if (!event) { if (!event) {
return null; return null;
@@ -1354,6 +1505,8 @@ function EventManagementGrid({
? t('events.quick.tasks', 'Tasks & Checklists') ? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`, : `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
color: ADMIN_ACTION_COLORS.tasks, color: ADMIN_ACTION_COLORS.tasks,
note: tasksNeedSetup ? t('mobileDashboard.tasksSetupNote', 'Setup needed') : undefined,
noteTone: tasksNeedSetup ? 'warning' : 'muted',
requiredPermission: 'tasks:manage', requiredPermission: 'tasks:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
disabled: !tasksEnabled || !slug, disabled: !tasksEnabled || !slug,
@@ -1504,6 +1657,8 @@ function EventManagementGrid({
icon={tile.icon} icon={tile.icon}
label={tile.label} label={tile.label}
color={tile.color} color={tile.color}
note={tile.note}
noteTone={tile.noteTone}
onPress={tile.onPress} onPress={tile.onPress}
disabled={tile.disabled} disabled={tile.disabled}
variant="cluster" variant="cluster"

View File

@@ -263,6 +263,13 @@ export default function MobileEventFormPage() {
}, },
} as Parameters<typeof createEvent>[0]; } as Parameters<typeof createEvent>[0];
const { event } = await createEvent(payload); 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); selectEvent(event.slug);
void queryClient.invalidateQueries({ queryKey: ['tenant-events'] }); void queryClient.invalidateQueries({ queryKey: ['tenant-events'] });
void refetch(); void refetch();
@@ -314,6 +321,13 @@ export default function MobileEventFormPage() {
...pendingPayload, ...pendingPayload,
accepted_waiver: consents.acceptedWaiver, 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); selectEvent(event.slug);
void queryClient.invalidateQueries({ queryKey: ['tenant-events'] }); void queryClient.invalidateQueries({ queryKey: ['tenant-events'] });
void refetch(); void refetch();

View File

@@ -149,7 +149,12 @@ vi.mock('../components/Primitives', () => ({
<span>{value}</span> <span>{value}</span>
</div> </div>
), ),
ActionTile: ({ label }: { label: string }) => <div>{label}</div>, ActionTile: ({ label, note }: { label: string; note?: string }) => (
<div>
<span>{label}</span>
{note ? <span>{note}</span> : null}
</div>
),
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>, SkeletonCard: () => <div>Loading...</div>,
})); }));
@@ -242,7 +247,25 @@ describe('MobileDashboardPage', () => {
fixtures.activePackage.package_type = 'reseller'; fixtures.activePackage.package_type = 'reseller';
fixtures.activePackage.remaining_events = 3; fixtures.activePackage.remaining_events = 3;
fixtures.event.tasks_count = 4;
fixtures.event.engagement_mode = undefined;
navigateMock.mockClear(); 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(<MobileDashboardPage />);
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', () => { it('does not redirect endcustomer packages without remaining event quota', () => {

View File

@@ -191,6 +191,8 @@ export function SkeletonCard({ height = 80 }: { height?: number }) {
export function ActionTile({ export function ActionTile({
icon: IconCmp, icon: IconCmp,
label, label,
note,
noteTone = 'muted',
color, color,
onPress, onPress,
disabled = false, disabled = false,
@@ -199,13 +201,16 @@ export function ActionTile({
}: { }: {
icon: React.ComponentType<{ size?: number; color?: string }>; icon: React.ComponentType<{ size?: number; color?: string }>;
label: string; label: string;
note?: string;
noteTone?: 'warning' | 'muted';
color: string; color: string;
onPress?: () => void; onPress?: () => void;
disabled?: boolean; disabled?: boolean;
variant?: 'grid' | 'cluster'; variant?: 'grid' | 'cluster';
delayMs?: number; delayMs?: number;
}) { }) {
const { textStrong, glassSurface } = useAdminTheme(); const { textStrong, glassSurface, muted, warningText } = useAdminTheme();
const noteColor = noteTone === 'warning' ? warningText : muted;
const isCluster = variant === 'cluster'; const isCluster = variant === 'cluster';
const backgroundColor = withAlpha(color, 0.12); const backgroundColor = withAlpha(color, 0.12);
const borderColor = withAlpha(color, 0.4); const borderColor = withAlpha(color, 0.4);
@@ -254,6 +259,11 @@ export function ActionTile({
<Text fontSize="$sm" fontWeight="800" color={textStrong} textAlign="center"> <Text fontSize="$sm" fontWeight="800" color={textStrong} textAlign="center">
{label} {label}
</Text> </Text>
{note ? (
<Text fontSize="$xs" fontWeight="700" color={noteColor} textAlign="center">
{note}
</Text>
) : null}
</YStack> </YStack>
</Pressable> </Pressable>
); );