Add tasks setup nudge and prompt
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -263,6 +263,13 @@ export default function MobileEventFormPage() {
|
||||
},
|
||||
} as Parameters<typeof createEvent>[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();
|
||||
|
||||
@@ -149,7 +149,12 @@ vi.mock('../components/Primitives', () => ({
|
||||
<span>{value}</span>
|
||||
</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>,
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
}));
|
||||
@@ -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(<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', () => {
|
||||
|
||||
@@ -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({
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong} textAlign="center">
|
||||
{label}
|
||||
</Text>
|
||||
{note ? (
|
||||
<Text fontSize="$xs" fontWeight="700" color={noteColor} textAlign="center">
|
||||
{note}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user