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:
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
65
resources/js/admin/mobile/components/MobileInstallBanner.tsx
Normal file
65
resources/js/admin/mobile/components/MobileInstallBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
resources/js/admin/mobile/hooks/useInstallPrompt.ts
Normal file
91
resources/js/admin/mobile/hooks/useInstallPrompt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
resources/js/admin/mobile/lib/installBanner.test.ts
Normal file
24
resources/js/admin/mobile/lib/installBanner.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
resources/js/admin/mobile/lib/installBanner.ts
Normal file
28
resources/js/admin/mobile/lib/installBanner.ts
Normal 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;
|
||||
}
|
||||
27
resources/js/admin/mobile/lib/installPrompt.test.ts
Normal file
27
resources/js/admin/mobile/lib/installPrompt.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
25
resources/js/admin/mobile/lib/installPrompt.ts
Normal file
25
resources/js/admin/mobile/lib/installPrompt.ts
Normal 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);
|
||||
}
|
||||
12
resources/js/admin/mobile/lib/mobileTour.test.ts
Normal file
12
resources/js/admin/mobile/lib/mobileTour.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
9
resources/js/admin/mobile/lib/mobileTour.ts
Normal file
9
resources/js/admin/mobile/lib/mobileTour.ts
Normal 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'];
|
||||
}
|
||||
Reference in New Issue
Block a user