diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index efac588..d11aa4f 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -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.", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 5cd5d23..3182230 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -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" }, diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index d71f860..bfcd8fa 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -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.", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index ae35b14..7c7c374 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -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" }, diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index f4c5564..0e900b0 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -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; + pushState: ReturnType; + devicePermissions: ReturnType; + 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([]); 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; + 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 ? ( + + + + + {t('mobileTour.progress', 'Step {{current}} of {{total}}', { + current: tourStep + 1, + total: totalTourSteps, + })} + + + + {t('mobileTour.skip', 'Skip')} + + + + + + + + + + {activeTourStep.title} + + + + {activeTourStep.body} + + {activeTourStep.showAction && activeTourStep.action && activeTourStep.actionLabel ? ( + + ) : null} + + + {tourStep > 0 ? ( + + ) : ( + + )} + = totalTourSteps - 1 ? t('mobileTour.done', 'Done') : t('mobileTour.next', 'Next')} + fullWidth={false} + onPress={handleTourNext} + /> + + + + ) : null; React.useEffect(() => { if (events.length || isLoading || fallbackLoading || fallbackAttempted) { @@ -78,6 +280,7 @@ export default function MobileDashboardPage() { ))} + {tourSheet} ); } @@ -85,7 +288,13 @@ export default function MobileDashboardPage() { if (!effectiveHasEvents) { return ( - + navigate(adminPath('/mobile/settings'))} + /> + {tourSheet} ); } @@ -98,6 +307,7 @@ export default function MobileDashboardPage() { subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')} > + {tourSheet} ); } @@ -108,6 +318,12 @@ export default function MobileDashboardPage() { title={resolveEventDisplayName(activeEvent ?? undefined)} subtitle={formatEventDate(activeEvent?.event_date, locale) ?? undefined} > + navigate(adminPath('/mobile/settings'))} + /> activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/photos`))} @@ -132,11 +348,150 @@ export default function MobileDashboardPage() { /> + {tourSheet} ); } -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 ( + + + + + {t('mobileDashboard.setupTitle', 'Set up this device')} + + + {t('mobileDashboard.setupSubtitle', 'Finish these steps for the best mobile experience.')} + + + + + + {items.map((item) => ( + + + + + + + + + {item.title} + + + {item.description} + + + + {item.badge} + + {item.actionLabel && item.action ? ( + + + {item.actionLabel} + + + ) : null} + + ))} + + + ); +} + +function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) { const { t } = useTranslation('management'); const navigate = useNavigate(); const theme = useTheme(); @@ -177,6 +532,12 @@ function OnboardingEmptyState() { return ( + - - {t('mobilePhotos.empty', 'No photos found.')} + + {t('mobilePhotos.emptyTitle', 'No uploads yet')} + + {t('mobilePhotos.emptyBody', 'Share the QR code so guests can start uploading photos.')} + + {slug ? ( + navigate(adminPath(`/mobile/events/${slug}/qr`))} + /> + ) : null} ) : ( diff --git a/resources/js/admin/mobile/LoginPage.tsx b/resources/js/admin/mobile/LoginPage.tsx index 7cec89e..0985b5b 100644 --- a/resources/js/admin/mobile/LoginPage.tsx +++ b/resources/js/admin/mobile/LoginPage.tsx @@ -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(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() { + {installBanner ? ( +
+
+
+ {installBanner.variant === 'prompt' ? ( + + ) : ( + + )} +
+
+

{tc('installBanner.title', 'Install Fotospiel Admin')}

+

+ {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.')} +

+
+
+ {installBanner.variant === 'prompt' ? ( + + ) : null} +
+ ) : null} +
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')}
diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 3ad8c50..360dfc1 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -550,9 +550,18 @@ export default function MobileNotificationsPage() { ) : statusFiltered.length === 0 ? ( - - {t('mobileNotifications.empty', 'Keine Benachrichtigungen vorhanden.')} + + {t('mobileNotifications.emptyTitle', 'All caught up')} + + {t('mobileNotifications.emptyBody', 'Enable push to receive alerts about uploads, guests, and expiring galleries.')} + + navigate(adminPath('/mobile/settings'))} + /> ) : ( diff --git a/resources/js/admin/mobile/SettingsPage.tsx b/resources/js/admin/mobile/SettingsPage.tsx index 360ccf1..ddf4422 100644 --- a/resources/js/admin/mobile/SettingsPage.tsx +++ b/resources/js/admin/mobile/SettingsPage.tsx @@ -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(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() { ) : null} + void installPrompt.promptInstall() : undefined} + /> diff --git a/resources/js/admin/mobile/components/MobileInstallBanner.tsx b/resources/js/admin/mobile/components/MobileInstallBanner.tsx new file mode 100644 index 0000000..d351cb4 --- /dev/null +++ b/resources/js/admin/mobile/components/MobileInstallBanner.tsx @@ -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 ( + + + + + {isPrompt ? : } + + + + {t('installBanner.title', 'Install Fotospiel Admin')} + + + {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.')} + + + + + {isPrompt && onInstall ? ( + + ) : null} + + ); +} diff --git a/resources/js/admin/mobile/hooks/useInstallPrompt.ts b/resources/js/admin/mobile/hooks/useInstallPrompt.ts new file mode 100644 index 0000000..fc3b1f6 --- /dev/null +++ b/resources/js/admin/mobile/hooks/useInstallPrompt.ts @@ -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; +}; + +export function useInstallPrompt(): InstallPromptState { + const [deferredPrompt, setDeferredPrompt] = React.useState(null); + const [isInstalled, setIsInstalled] = React.useState(false); + const [isStandalone, setIsStandalone] = React.useState(false); + const [isIos, setIsIos] = React.useState(false); + const [outcome, setOutcome] = React.useState(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, + }; +} diff --git a/resources/js/admin/mobile/lib/installBanner.test.ts b/resources/js/admin/mobile/lib/installBanner.test.ts new file mode 100644 index 0000000..dc6d6dc --- /dev/null +++ b/resources/js/admin/mobile/lib/installBanner.test.ts @@ -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(); + }); +}); diff --git a/resources/js/admin/mobile/lib/installBanner.ts b/resources/js/admin/mobile/lib/installBanner.ts new file mode 100644 index 0000000..b96390d --- /dev/null +++ b/resources/js/admin/mobile/lib/installBanner.ts @@ -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; +} diff --git a/resources/js/admin/mobile/lib/installPrompt.test.ts b/resources/js/admin/mobile/lib/installPrompt.test.ts new file mode 100644 index 0000000..125780d --- /dev/null +++ b/resources/js/admin/mobile/lib/installPrompt.test.ts @@ -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); + }); +}); diff --git a/resources/js/admin/mobile/lib/installPrompt.ts b/resources/js/admin/mobile/lib/installPrompt.ts new file mode 100644 index 0000000..cfe244b --- /dev/null +++ b/resources/js/admin/mobile/lib/installPrompt.ts @@ -0,0 +1,25 @@ +export type InstallOutcome = 'accepted' | 'dismissed' | 'unknown'; + +export type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + 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); +} diff --git a/resources/js/admin/mobile/lib/mobileTour.test.ts b/resources/js/admin/mobile/lib/mobileTour.test.ts new file mode 100644 index 0000000..a0e018c --- /dev/null +++ b/resources/js/admin/mobile/lib/mobileTour.test.ts @@ -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']); + }); +}); diff --git a/resources/js/admin/mobile/lib/mobileTour.ts b/resources/js/admin/mobile/lib/mobileTour.ts new file mode 100644 index 0000000..6893d55 --- /dev/null +++ b/resources/js/admin/mobile/lib/mobileTour.ts @@ -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']; +}