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:
Codex Agent
2025-12-28 18:26:17 +01:00
parent b780d82d62
commit d5f038d098
17 changed files with 831 additions and 8 deletions

View File

@@ -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"