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

@@ -66,6 +66,12 @@
"viewAll": "Alle anzeigen",
"dismiss": "Hinweis ausblenden"
},
"installBanner": {
"title": "Fotospiel Admin installieren",
"body": "Lege die App auf den Homescreen für schnellen Zugriff und Offline-Support.",
"action": "Installieren",
"iosHint": "iOS: Teilen → Zum Home-Bildschirm."
},
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.",

View File

@@ -1904,6 +1904,27 @@
"emptySupportBody": "Wir unterstützen dich gern beim Start.",
"emptySupportDocs": "Docs: Erste Schritte",
"emptySupportEmail": "E-Mail an Support",
"setupTitle": "Dieses Gerät einrichten",
"setupSubtitle": "Schließe diese Schritte für die beste Mobile-Experience ab.",
"setup": {
"installTitle": "App installieren",
"installDescription": "Vollbildmodus und schnellerer Start.",
"installIosHint": "iOS: Teilen → Zum Home-Bildschirm.",
"installReady": "Bereit",
"installManual": "Manuell",
"installAction": "Jetzt installieren",
"pushTitle": "Push-Benachrichtigungen aktivieren",
"pushDescription": "Bleib bei Uploads und Limits immer informiert.",
"pushBlockedDesc": "Benachrichtigungen sind in den Browser-Einstellungen blockiert.",
"pushBlocked": "Blockiert",
"pushOff": "Aus",
"pushAction": "Push aktivieren",
"pushSettings": "Einstellungen öffnen",
"storageTitle": "Offline-Daten schützen",
"storageDescription": "Zwischengespeicherte Uploads bleiben offline verfügbar.",
"storageUnprotected": "Nicht geschützt",
"storageAction": "Jetzt schützen"
},
"pickEvent": "Event auswählen",
"status": {
"published": "Live",
@@ -1930,6 +1951,26 @@
"alertPending": "{{count}} neue Uploads warten auf Freigabe",
"alertTasks": "{{count}} Tasks offen oder fällig"
},
"mobileTour": {
"title": "Quick Tour",
"progress": "Schritt {{current}} von {{total}}",
"skip": "Überspringen",
"back": "Zurück",
"next": "Weiter",
"done": "Fertig",
"eventTitle": "Erstes Event anlegen",
"eventBody": "Datum, Location und Paket hinzufügen, um das Teilen zu aktivieren.",
"eventAction": "Event erstellen",
"qrTitle": "QR-Code teilen",
"qrBody": "Gäste treten sofort per QR oder Link bei.",
"qrAction": "QR öffnen",
"photosTitle": "Uploads moderieren",
"photosBody": "Fotos freigeben, hervorheben oder ausblenden.",
"photosAction": "Fotos prüfen",
"pushTitle": "Immer informiert bleiben",
"pushBody": "Aktiviere Push, um Limits und neue Uploads schnell zu sehen.",
"pushAction": "Benachrichtigungen"
},
"mobileUploads": {
"title": "Uploads",
"emptyTitle": "Lege zuerst ein Event an",
@@ -1940,6 +1981,9 @@
"mobilePhotos": {
"title": "Foto-Moderation",
"empty": "Keine Fotos gefunden.",
"emptyTitle": "Noch keine Uploads",
"emptyBody": "Teile den QR-Code, damit Gäste Fotos hochladen können.",
"emptyAction": "QR-Code teilen",
"count": "{{count}} Fotos",
"filtersTitle": "Filter",
"applyFilters": "Filter anwenden",
@@ -2229,6 +2273,9 @@
"mobileNotifications": {
"title": "Benachrichtigungen",
"empty": "Keine Benachrichtigungen vorhanden.",
"emptyTitle": "Alles erledigt",
"emptyBody": "Aktiviere Push, um Warnungen zu Uploads, Gästen und ablaufenden Galerien zu erhalten.",
"emptyAction": "Benachrichtigungen prüfen",
"filterByEvent": "Nach Event filtern",
"unknownEvent": "Event"
},

View File

@@ -66,6 +66,12 @@
"viewAll": "View all",
"dismiss": "Dismiss"
},
"installBanner": {
"title": "Install Fotospiel Admin",
"body": "Add the app to your home screen for faster access and offline support.",
"action": "Install",
"iosHint": "On iOS: Share → Add to Home Screen."
},
"errors": {
"generic": "Something went wrong. Please try again.",
"eventLimit": "Your current package has no remaining event slots.",

View File

@@ -1924,6 +1924,27 @@
"emptySupportBody": "We are here if you need a hand getting started.",
"emptySupportDocs": "Docs: Getting started",
"emptySupportEmail": "Email support",
"setupTitle": "Set up this device",
"setupSubtitle": "Finish these steps for the best mobile experience.",
"setup": {
"installTitle": "Install app",
"installDescription": "Get full-screen access and faster launch.",
"installIosHint": "On iOS: Share → Add to Home Screen.",
"installReady": "Ready",
"installManual": "Manual",
"installAction": "Install now",
"pushTitle": "Enable push notifications",
"pushDescription": "Stay on top of uploads and quota alerts.",
"pushBlockedDesc": "Notifications are blocked in the browser settings.",
"pushBlocked": "Blocked",
"pushOff": "Off",
"pushAction": "Enable push",
"pushSettings": "Open settings",
"storageTitle": "Protect offline data",
"storageDescription": "Keep cached uploads available when you go offline.",
"storageUnprotected": "Not protected",
"storageAction": "Protect now"
},
"pickEvent": "Select an event",
"status": {
"published": "Live",
@@ -1950,6 +1971,26 @@
"alertPending": "{{count}} new uploads awaiting moderation",
"alertTasks": "{{count}} tasks due or open"
},
"mobileTour": {
"title": "Quick tour",
"progress": "Step {{current}} of {{total}}",
"skip": "Skip",
"back": "Back",
"next": "Next",
"done": "Done",
"eventTitle": "Create your first event",
"eventBody": "Add the date, location, and package to unlock sharing.",
"eventAction": "Create event",
"qrTitle": "Share your QR code",
"qrBody": "Guests join instantly by scanning or using the invite link.",
"qrAction": "Open QR",
"photosTitle": "Moderate uploads",
"photosBody": "Approve, feature, or hide photos with quick swipes.",
"photosAction": "Review photos",
"pushTitle": "Stay in the loop",
"pushBody": "Enable push alerts to catch limits and new uploads fast.",
"pushAction": "Notification settings"
},
"mobileUploads": {
"title": "Uploads",
"emptyTitle": "Create an event first",
@@ -1960,6 +2001,9 @@
"mobilePhotos": {
"title": "Photo moderation",
"empty": "No photos found.",
"emptyTitle": "No uploads yet",
"emptyBody": "Share the QR code so guests can start uploading photos.",
"emptyAction": "Share QR code",
"count": "{{count}} photos",
"filtersTitle": "Filter",
"applyFilters": "Apply filters",
@@ -2249,6 +2293,9 @@
"mobileNotifications": {
"title": "Notifications",
"empty": "No notifications yet.",
"emptyTitle": "All caught up",
"emptyBody": "Enable push to receive alerts about uploads, guests, and expiring galleries.",
"emptyAction": "Check notification settings",
"filterByEvent": "Filter by event",
"unknownEvent": "Event"
},

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"

View File

@@ -744,9 +744,20 @@ export default function MobileEventPhotosPage() {
) : photos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<ImageIcon size={28} color={muted} />
<Text fontSize="$sm" color={muted}>
{t('mobilePhotos.empty', 'No photos found.')}
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobilePhotos.emptyTitle', 'No uploads yet')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('mobilePhotos.emptyBody', 'Share the QR code so guests can start uploading photos.')}
</Text>
{slug ? (
<CTAButton
label={t('mobilePhotos.emptyAction', 'Share QR code')}
tone="ghost"
fullWidth={false}
onPress={() => navigate(adminPath(`/mobile/events/${slug}/qr`))}
/>
) : null}
</MobileCard>
) : (
<YStack space="$3">

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, Lock, Mail } from 'lucide-react';
import { Download, Loader2, Lock, Mail, Share2 } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
import { adminPath, ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { useAuth } from '../auth/context';
import { resolveReturnTarget } from '../lib/returnTo';
import { useInstallPrompt } from './hooks/useInstallPrompt';
import { resolveInstallBannerState } from './lib/installBanner';
type LoginResponse = {
token: string;
@@ -43,8 +45,10 @@ async function performLogin(payload: { login: string; password: string; return_t
export default function MobileLoginPage() {
const { status, applyToken, abilities } = useAuth();
const { t } = useTranslation('auth');
const { t: tc } = useTranslation('common');
const location = useLocation();
const navigate = useNavigate();
const installPrompt = useInstallPrompt();
const safeAreaStyle: React.CSSProperties = {
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 16px)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 16px)',
@@ -76,6 +80,12 @@ export default function MobileLoginPage() {
const [login, setLogin] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const installBanner = resolveInstallBannerState({
isInstalled: installPrompt.isInstalled,
isStandalone: installPrompt.isStandalone,
canInstall: installPrompt.canInstall,
isIos: installPrompt.isIos,
});
const mutation = useMutation({
mutationKey: ['tenantAdminLoginMobile'],
@@ -179,6 +189,37 @@ export default function MobileLoginPage() {
</button>
</form>
{installBanner ? (
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10">
{installBanner.variant === 'prompt' ? (
<Download className="h-5 w-5 text-white/80" />
) : (
<Share2 className="h-5 w-5 text-white/80" />
)}
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold text-white">{tc('installBanner.title', 'Install Fotospiel Admin')}</p>
<p className="text-xs text-white/70">
{installBanner.variant === 'prompt'
? tc('installBanner.body', 'Add the app to your home screen for faster access and offline support.')
: tc('installBanner.iosHint', 'On iOS: Share → Add to Home Screen.')}
</p>
</div>
</div>
{installBanner.variant === 'prompt' ? (
<button
type="button"
onClick={() => void installPrompt.promptInstall()}
className="mt-3 inline-flex items-center justify-center rounded-full bg-white/10 px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/20"
>
{tc('installBanner.action', 'Install')}
</button>
) : null}
</div>
) : null}
<div className="text-center text-xs text-white/60">
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
</div>

View File

@@ -550,9 +550,18 @@ export default function MobileNotificationsPage() {
) : statusFiltered.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Bell size={24} color={String(theme.gray9?.val ?? '#9ca3af')} />
<Text fontSize="$sm" color={muted}>
{t('mobileNotifications.empty', 'Keine Benachrichtigungen vorhanden.')}
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('mobileNotifications.emptyTitle', 'All caught up')}
</Text>
<Text fontSize="$xs" color={muted} textAlign="center">
{t('mobileNotifications.emptyBody', 'Enable push to receive alerts about uploads, guests, and expiring galleries.')}
</Text>
<CTAButton
label={t('mobileNotifications.emptyAction', 'Check notification settings')}
tone="ghost"
fullWidth={false}
onPress={() => navigate(adminPath('/mobile/settings'))}
/>
</MobileCard>
) : (
<YStack space="$2">

View File

@@ -21,6 +21,9 @@ import { adminPath } from '../constants';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions';
import { useInstallPrompt } from './hooks/useInstallPrompt';
import { resolveInstallBannerState } from './lib/installBanner';
import { MobileInstallBanner } from './components/MobileInstallBanner';
type PreferenceKey = keyof NotificationPreferences;
@@ -54,6 +57,13 @@ export default function MobileSettingsPage() {
const [storageError, setStorageError] = React.useState<string | null>(null);
const pushState = useAdminPushSubscription();
const devicePermissions = useDevicePermissions();
const installPrompt = useInstallPrompt();
const installBanner = resolveInstallBannerState({
isInstalled: installPrompt.isInstalled,
isStandalone: installPrompt.isStandalone,
canInstall: installPrompt.canInstall,
isIos: installPrompt.isIos,
});
const pushDescription = React.useMemo(() => {
if (!pushState.supported) {
@@ -172,6 +182,10 @@ export default function MobileSettingsPage() {
</Text>
</MobileCard>
) : null}
<MobileInstallBanner
state={installBanner}
onInstall={installPrompt.canInstall ? () => void installPrompt.promptInstall() : undefined}
/>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Download, Share2 } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { useTheme } from '@tamagui/core';
import { InstallBannerState } from '../lib/installBanner';
import { CTAButton, MobileCard } from './Primitives';
import { useTranslation } from 'react-i18next';
type MobileInstallBannerProps = {
state: InstallBannerState | null;
onInstall?: () => void;
};
export function MobileInstallBanner({ state, onInstall }: MobileInstallBannerProps) {
const { t } = useTranslation('common');
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');
if (!state) {
return null;
}
const isPrompt = state.variant === 'prompt';
return (
<MobileCard space="$2" borderColor={border} backgroundColor={String(theme.blue2?.val ?? '#eff6ff')}>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" space="$2" flex={1}>
<XStack
width={36}
height={36}
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={String(theme.blue3?.val ?? '#dbeafe')}
>
{isPrompt ? <Download size={18} color={accent} /> : <Share2 size={18} color={accent} />}
</XStack>
<YStack flex={1} space="$0.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('installBanner.title', 'Install Fotospiel Admin')}
</Text>
<Text fontSize="$xs" color={muted}>
{isPrompt
? t('installBanner.body', 'Add the app to your home screen for faster access and offline support.')
: t('installBanner.iosHint', 'On iOS: Share → Add to Home Screen.')}
</Text>
</YStack>
</XStack>
</XStack>
{isPrompt && onInstall ? (
<CTAButton
label={t('installBanner.action', 'Install')}
onPress={onInstall}
fullWidth={false}
tone="ghost"
/>
) : null}
</MobileCard>
);
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import {
BeforeInstallPromptEvent,
getStandaloneStatus,
isIosDevice,
type InstallOutcome,
} from '../lib/installPrompt';
type InstallPromptState = {
canInstall: boolean;
isInstalled: boolean;
isStandalone: boolean;
isIos: boolean;
outcome: InstallOutcome | null;
promptInstall: () => Promise<InstallOutcome | null>;
};
export function useInstallPrompt(): InstallPromptState {
const [deferredPrompt, setDeferredPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = React.useState(false);
const [isStandalone, setIsStandalone] = React.useState(false);
const [isIos, setIsIos] = React.useState(false);
const [outcome, setOutcome] = React.useState<InstallOutcome | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const updateStandalone = () => {
const standalone = getStandaloneStatus();
setIsStandalone(standalone);
if (standalone) {
setIsInstalled(true);
}
};
setIsIos(isIosDevice(navigator.userAgent ?? ''));
updateStandalone();
const handleBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setDeferredPrompt(null);
setOutcome('accepted');
};
const mediaQuery = window.matchMedia?.('(display-mode: standalone)');
const handleDisplayModeChange = () => updateStandalone();
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
mediaQuery?.addEventListener?.('change', handleDisplayModeChange);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
mediaQuery?.removeEventListener?.('change', handleDisplayModeChange);
};
}, []);
const promptInstall = React.useCallback(async () => {
if (!deferredPrompt) {
return null;
}
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
setOutcome(choice.outcome ?? 'unknown');
if (choice.outcome === 'accepted') {
setIsInstalled(true);
setDeferredPrompt(null);
}
return choice.outcome ?? 'unknown';
}, [deferredPrompt]);
return {
canInstall: Boolean(deferredPrompt),
isInstalled,
isStandalone,
isIos,
outcome,
promptInstall,
};
}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { resolveInstallBannerState } from './installBanner';
describe('resolveInstallBannerState', () => {
it('returns null when already installed', () => {
expect(resolveInstallBannerState({ isInstalled: true, isStandalone: false, canInstall: true, isIos: true })).toBeNull();
});
it('returns null when running in standalone mode', () => {
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: true, canInstall: true, isIos: true })).toBeNull();
});
it('returns prompt when install prompt is available', () => {
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: false, canInstall: true, isIos: false })).toEqual({ variant: 'prompt' });
});
it('returns ios when on iOS without prompt', () => {
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: false, canInstall: false, isIos: true })).toEqual({ variant: 'ios' });
});
it('returns null when no install option exists', () => {
expect(resolveInstallBannerState({ isInstalled: false, isStandalone: false, canInstall: false, isIos: false })).toBeNull();
});
});

View File

@@ -0,0 +1,28 @@
export type InstallBannerVariant = 'prompt' | 'ios';
export type InstallBannerState = {
variant: InstallBannerVariant;
};
export type InstallBannerInput = {
isInstalled: boolean;
isStandalone: boolean;
canInstall: boolean;
isIos: boolean;
};
export function resolveInstallBannerState(input: InstallBannerInput): InstallBannerState | null {
if (input.isInstalled || input.isStandalone) {
return null;
}
if (input.canInstall) {
return { variant: 'prompt' };
}
if (input.isIos) {
return { variant: 'ios' };
}
return null;
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { isIosDevice, resolveStandaloneDisplayMode } from './installPrompt';
describe('isIosDevice', () => {
it('detects iOS user agents', () => {
expect(isIosDevice('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1')).toBe(true);
expect(isIosDevice('Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1')).toBe(true);
});
it('returns false for non-iOS user agents', () => {
expect(isIosDevice('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36')).toBe(false);
});
});
describe('resolveStandaloneDisplayMode', () => {
it('returns true when matchMedia says standalone', () => {
expect(resolveStandaloneDisplayMode(true, false)).toBe(true);
});
it('returns true when navigator.standalone is true', () => {
expect(resolveStandaloneDisplayMode(false, true)).toBe(true);
});
it('returns false when both are false', () => {
expect(resolveStandaloneDisplayMode(false, false)).toBe(false);
});
});

View File

@@ -0,0 +1,25 @@
export type InstallOutcome = 'accepted' | 'dismissed' | 'unknown';
export type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: InstallOutcome; platform: string }>;
};
export function isIosDevice(userAgent: string): boolean {
return /iphone|ipad|ipod/i.test(userAgent);
}
export function resolveStandaloneDisplayMode(matchMediaStandalone: boolean, navigatorStandalone?: boolean): boolean {
return matchMediaStandalone || navigatorStandalone === true;
}
export function getStandaloneStatus(): boolean {
if (typeof window === 'undefined') {
return false;
}
const matchMediaStandalone = window.matchMedia?.('(display-mode: standalone)')?.matches ?? false;
const navigatorStandalone = typeof navigator !== 'undefined' ? (navigator as Navigator & { standalone?: boolean }).standalone : undefined;
return resolveStandaloneDisplayMode(matchMediaStandalone, navigatorStandalone);
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { resolveTourStepKeys } from './mobileTour';
describe('resolveTourStepKeys', () => {
it('includes the event step when there are no events', () => {
expect(resolveTourStepKeys(false)).toEqual(['event', 'qr', 'photos', 'push']);
});
it('omits the event step when events exist', () => {
expect(resolveTourStepKeys(true)).toEqual(['qr', 'photos', 'push']);
});
});

View File

@@ -0,0 +1,9 @@
export type TourStepKey = 'event' | 'qr' | 'photos' | 'push';
export function resolveTourStepKeys(hasEvents: boolean): TourStepKey[] {
if (hasEvents) {
return ['qr', 'photos', 'push'];
}
return ['event', 'qr', 'photos', 'push'];
}