Added onboarding + a lightweight install banner to both the mobile login screen and the settings screen, with Android/Chromium
install prompt support and iOS “Share → Add to Home Screen” guidance. Also added a small helper + tests to decide when/which banner variant should show, and shared copy in common.json.
This commit is contained in:
@@ -2,17 +2,31 @@ import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CheckCircle2, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, Users, Sparkles } from 'lucide-react';
|
||||
import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { MobileShell, renderEventLocation } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { adminPath } from '../constants';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
|
||||
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
|
||||
import { useDevicePermissions } from './hooks/useDevicePermissions';
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt';
|
||||
import { resolveTourStepKeys, type TourStepKey } from './lib/mobileTour';
|
||||
|
||||
type DeviceSetupProps = {
|
||||
installPrompt: ReturnType<typeof useInstallPrompt>;
|
||||
pushState: ReturnType<typeof useAdminPushSubscription>;
|
||||
devicePermissions: ReturnType<typeof useDevicePermissions>;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
const TOUR_STORAGE_KEY = 'admin-mobile-tour-v1';
|
||||
|
||||
export default function MobileDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,6 +35,11 @@ export default function MobileDashboardPage() {
|
||||
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [fallbackLoading, setFallbackLoading] = React.useState(false);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const [tourOpen, setTourOpen] = React.useState(false);
|
||||
const [tourStep, setTourStep] = React.useState(0);
|
||||
const installPrompt = useInstallPrompt();
|
||||
const pushState = useAdminPushSubscription();
|
||||
const devicePermissions = useDevicePermissions();
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
@@ -50,6 +69,189 @@ export default function MobileDashboardPage() {
|
||||
const effectiveHasEvents = hasEvents || Boolean(dashboardEvents?.length) || fallbackEvents.length > 0;
|
||||
const effectiveMultiple =
|
||||
hasMultipleEvents || (dashboardEvents?.length ?? 0) > 1 || fallbackEvents.length > 1;
|
||||
const tourTargetSlug = activeEvent?.slug ?? effectiveEvents[0]?.slug ?? null;
|
||||
const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(TOUR_STORAGE_KEY);
|
||||
if (stored) return;
|
||||
setTourOpen(true);
|
||||
} catch {
|
||||
setTourOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markTourSeen = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(TOUR_STORAGE_KEY, 'seen');
|
||||
} catch {
|
||||
// Ignore storage errors; the tour will just show again.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeTour = React.useCallback(() => {
|
||||
setTourOpen(false);
|
||||
markTourSeen();
|
||||
}, [markTourSeen]);
|
||||
|
||||
const tourSteps = React.useMemo(() => {
|
||||
const stepMap: Record<TourStepKey, {
|
||||
key: TourStepKey;
|
||||
icon: React.ComponentType<{ size?: number; color?: string }>;
|
||||
title: string;
|
||||
body: string;
|
||||
actionLabel?: string;
|
||||
action?: () => void;
|
||||
showAction?: boolean;
|
||||
}> = {
|
||||
event: {
|
||||
key: 'event',
|
||||
icon: Sparkles,
|
||||
title: t('mobileTour.eventTitle', 'Create your first event'),
|
||||
body: t('mobileTour.eventBody', 'Add the date, location, and package to unlock sharing.'),
|
||||
actionLabel: t('mobileTour.eventAction', 'Create event'),
|
||||
action: () => {
|
||||
closeTour();
|
||||
navigate(adminPath('/mobile/events/new'));
|
||||
},
|
||||
showAction: true,
|
||||
},
|
||||
qr: {
|
||||
key: 'qr',
|
||||
icon: QrCode,
|
||||
title: t('mobileTour.qrTitle', 'Share your QR code'),
|
||||
body: t('mobileTour.qrBody', 'Guests join instantly by scanning or using the invite link.'),
|
||||
actionLabel: t('mobileTour.qrAction', 'Open QR'),
|
||||
action: () => {
|
||||
if (!tourTargetSlug) {
|
||||
return;
|
||||
}
|
||||
closeTour();
|
||||
navigate(adminPath(`/mobile/events/${tourTargetSlug}/qr`));
|
||||
},
|
||||
showAction: Boolean(tourTargetSlug),
|
||||
},
|
||||
photos: {
|
||||
key: 'photos',
|
||||
icon: ImageIcon,
|
||||
title: t('mobileTour.photosTitle', 'Moderate uploads'),
|
||||
body: t('mobileTour.photosBody', 'Approve, feature, or hide photos with quick swipes.'),
|
||||
actionLabel: t('mobileTour.photosAction', 'Review photos'),
|
||||
action: () => {
|
||||
if (!tourTargetSlug) {
|
||||
return;
|
||||
}
|
||||
closeTour();
|
||||
navigate(adminPath(`/mobile/events/${tourTargetSlug}/photos`));
|
||||
},
|
||||
showAction: Boolean(tourTargetSlug),
|
||||
},
|
||||
push: {
|
||||
key: 'push',
|
||||
icon: Bell,
|
||||
title: t('mobileTour.pushTitle', 'Stay in the loop'),
|
||||
body: t('mobileTour.pushBody', 'Enable push alerts to catch limits and new uploads fast.'),
|
||||
actionLabel: t('mobileTour.pushAction', 'Notification settings'),
|
||||
action: () => {
|
||||
closeTour();
|
||||
navigate(adminPath('/mobile/settings'));
|
||||
},
|
||||
showAction: true,
|
||||
},
|
||||
};
|
||||
|
||||
return tourStepKeys.map((key) => stepMap[key]);
|
||||
}, [closeTour, navigate, t, tourStepKeys, tourTargetSlug]);
|
||||
|
||||
const activeTourStep = tourSteps[tourStep] ?? tourSteps[0];
|
||||
const totalTourSteps = tourSteps.length;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tourStep >= tourSteps.length) {
|
||||
setTourStep(0);
|
||||
}
|
||||
}, [tourStep, tourSteps.length]);
|
||||
|
||||
const handleTourNext = React.useCallback(() => {
|
||||
if (tourStep >= totalTourSteps - 1) {
|
||||
closeTour();
|
||||
return;
|
||||
}
|
||||
setTourStep((prev) => prev + 1);
|
||||
}, [closeTour, tourStep, totalTourSteps]);
|
||||
|
||||
const handleTourBack = React.useCallback(() => {
|
||||
setTourStep((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const tourSheet = activeTourStep ? (
|
||||
<MobileSheet open={tourOpen} title={t('mobileTour.title', 'Quick tour')} onClose={closeTour}>
|
||||
<YStack space="$2.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('mobileTour.progress', 'Step {{current}} of {{total}}', {
|
||||
current: tourStep + 1,
|
||||
total: totalTourSteps,
|
||||
})}
|
||||
</Text>
|
||||
<Pressable onPress={closeTour}>
|
||||
<Text fontSize="$xs" color={muted} textDecorationLine="underline">
|
||||
{t('mobileTour.skip', 'Skip')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</XStack>
|
||||
<YStack space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack
|
||||
width={40}
|
||||
height={40}
|
||||
borderRadius={14}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={String(theme.blue3?.val ?? '#e0f2fe')}
|
||||
>
|
||||
<activeTourStep.icon size={18} color={String(theme.blue10?.val ?? '#2563eb')} />
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||
{activeTourStep.title}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{activeTourStep.body}
|
||||
</Text>
|
||||
{activeTourStep.showAction && activeTourStep.action && activeTourStep.actionLabel ? (
|
||||
<CTAButton
|
||||
label={activeTourStep.actionLabel}
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
onPress={activeTourStep.action}
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
<XStack space="$2" justifyContent="space-between">
|
||||
{tourStep > 0 ? (
|
||||
<CTAButton label={t('mobileTour.back', 'Back')} tone="ghost" fullWidth={false} onPress={handleTourBack} />
|
||||
) : (
|
||||
<XStack />
|
||||
)}
|
||||
<CTAButton
|
||||
label={tourStep >= totalTourSteps - 1 ? t('mobileTour.done', 'Done') : t('mobileTour.next', 'Next')}
|
||||
fullWidth={false}
|
||||
onPress={handleTourNext}
|
||||
/>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</MobileSheet>
|
||||
) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (events.length || isLoading || fallbackLoading || fallbackAttempted) {
|
||||
@@ -78,6 +280,7 @@ export default function MobileDashboardPage() {
|
||||
<SkeletonCard key={`sk-${idx}`} height={110} />
|
||||
))}
|
||||
</YStack>
|
||||
{tourSheet}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +288,13 @@ export default function MobileDashboardPage() {
|
||||
if (!effectiveHasEvents) {
|
||||
return (
|
||||
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
|
||||
<OnboardingEmptyState />
|
||||
<OnboardingEmptyState
|
||||
installPrompt={installPrompt}
|
||||
pushState={pushState}
|
||||
devicePermissions={devicePermissions}
|
||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||
/>
|
||||
{tourSheet}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -98,6 +307,7 @@ export default function MobileDashboardPage() {
|
||||
subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')}
|
||||
>
|
||||
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
|
||||
{tourSheet}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -108,6 +318,12 @@ export default function MobileDashboardPage() {
|
||||
title={resolveEventDisplayName(activeEvent ?? undefined)}
|
||||
subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined}
|
||||
>
|
||||
<DeviceSetupCard
|
||||
installPrompt={installPrompt}
|
||||
pushState={pushState}
|
||||
devicePermissions={devicePermissions}
|
||||
onOpenSettings={() => navigate(adminPath('/mobile/settings'))}
|
||||
/>
|
||||
<FeaturedActions
|
||||
tasksEnabled={tasksEnabled}
|
||||
onReviewPhotos={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))}
|
||||
@@ -132,11 +348,150 @@ export default function MobileDashboardPage() {
|
||||
/>
|
||||
|
||||
<AlertsAndHints event={activeEvent} stats={stats} tasksEnabled={tasksEnabled} />
|
||||
{tourSheet}
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingEmptyState() {
|
||||
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#0f172a');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#6b7280');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const accent = String(theme.primary?.val ?? '#2563eb');
|
||||
const iconBg = String(theme.blue3?.val ?? '#e0f2fe');
|
||||
const iconColor = String(theme.blue10?.val ?? '#2563eb');
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
icon: React.ComponentType<{ size?: number; color?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
badge: string;
|
||||
badgeTone: 'success' | 'warning' | 'muted';
|
||||
actionLabel?: string;
|
||||
action?: () => void;
|
||||
actionDisabled?: boolean;
|
||||
}> = [];
|
||||
|
||||
const showInstall = !installPrompt.isInstalled && (installPrompt.canInstall || installPrompt.isIos);
|
||||
if (showInstall) {
|
||||
items.push({
|
||||
key: 'install',
|
||||
icon: Smartphone,
|
||||
title: t('mobileDashboard.setup.installTitle', 'Install app'),
|
||||
description: installPrompt.isIos
|
||||
? t('mobileDashboard.setup.installIosHint', 'On iOS: Share → Add to Home Screen.')
|
||||
: t('mobileDashboard.setup.installDescription', 'Get full-screen access and faster launch.'),
|
||||
badge: installPrompt.canInstall
|
||||
? t('mobileDashboard.setup.installReady', 'Ready')
|
||||
: t('mobileDashboard.setup.installManual', 'Manual'),
|
||||
badgeTone: installPrompt.canInstall ? 'warning' : 'muted',
|
||||
actionLabel: installPrompt.canInstall ? t('mobileDashboard.setup.installAction', 'Install now') : undefined,
|
||||
action: installPrompt.canInstall ? () => void installPrompt.promptInstall() : undefined,
|
||||
actionDisabled: !installPrompt.canInstall,
|
||||
});
|
||||
}
|
||||
|
||||
if (pushState.supported && !pushState.subscribed) {
|
||||
const pushBlocked = pushState.permission === 'denied';
|
||||
items.push({
|
||||
key: 'push',
|
||||
icon: Bell,
|
||||
title: t('mobileDashboard.setup.pushTitle', 'Enable push notifications'),
|
||||
description: pushBlocked
|
||||
? t('mobileDashboard.setup.pushBlockedDesc', 'Notifications are blocked in the browser settings.')
|
||||
: t('mobileDashboard.setup.pushDescription', 'Stay on top of uploads and quota alerts.'),
|
||||
badge: pushBlocked
|
||||
? t('mobileDashboard.setup.pushBlocked', 'Blocked')
|
||||
: t('mobileDashboard.setup.pushOff', 'Off'),
|
||||
badgeTone: 'warning',
|
||||
actionLabel: pushBlocked
|
||||
? t('mobileDashboard.setup.pushSettings', 'Open settings')
|
||||
: t('mobileDashboard.setup.pushAction', 'Enable push'),
|
||||
action: pushBlocked ? onOpenSettings : () => void pushState.enable(),
|
||||
actionDisabled: pushState.loading,
|
||||
});
|
||||
}
|
||||
|
||||
if (devicePermissions.storage === 'available') {
|
||||
items.push({
|
||||
key: 'storage',
|
||||
icon: ShieldCheck,
|
||||
title: t('mobileDashboard.setup.storageTitle', 'Protect offline data'),
|
||||
description: t('mobileDashboard.setup.storageDescription', 'Keep cached uploads available when you go offline.'),
|
||||
badge: t('mobileDashboard.setup.storageUnprotected', 'Not protected'),
|
||||
badgeTone: 'warning',
|
||||
actionLabel: t('mobileDashboard.setup.storageAction', 'Protect now'),
|
||||
action: () => void devicePermissions.requestPersistentStorage(),
|
||||
actionDisabled: devicePermissions.loading,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCard space="$3" borderColor={border}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$0.5">
|
||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||
{t('mobileDashboard.setupTitle', 'Set up this device')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('mobileDashboard.setupSubtitle', 'Finish these steps for the best mobile experience.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Download size={18} color={accent} />
|
||||
</XStack>
|
||||
<YStack space="$2">
|
||||
{items.map((item) => (
|
||||
<YStack key={item.key} space="$1.5" padding="$2" borderRadius={12} borderWidth={1} borderColor={`${border}aa`}>
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||
<XStack alignItems="center" space="$2" flex={1}>
|
||||
<XStack
|
||||
width={32}
|
||||
height={32}
|
||||
borderRadius={10}
|
||||
backgroundColor={iconBg}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<item.icon size={16} color={iconColor} />
|
||||
</XStack>
|
||||
<YStack flex={1} space="$0.5">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
<PillBadge tone={item.badgeTone}>{item.badge}</PillBadge>
|
||||
</XStack>
|
||||
{item.actionLabel && item.action ? (
|
||||
<Pressable onPress={item.action} disabled={item.actionDisabled}>
|
||||
<Text
|
||||
fontSize="$xs"
|
||||
fontWeight="700"
|
||||
color={item.actionDisabled ? muted : accent}
|
||||
textDecorationLine="underline"
|
||||
>
|
||||
{item.actionLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
@@ -177,6 +532,12 @@ function OnboardingEmptyState() {
|
||||
|
||||
return (
|
||||
<YStack space="$3">
|
||||
<DeviceSetupCard
|
||||
installPrompt={installPrompt}
|
||||
pushState={pushState}
|
||||
devicePermissions={devicePermissions}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
<MobileCard
|
||||
padding="$4"
|
||||
borderColor="transparent"
|
||||
|
||||
Reference in New Issue
Block a user