diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 3182230..ecdb5af 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1832,6 +1832,34 @@ "fileTooLarge": "Wasserzeichen muss kleiner als 3 MB sein." } }, + "members": { + "title": "Gästeverwaltung", + "inviteTitle": "Mitglied einladen", + "name": "Name", + "email": "E-Mail", + "role": "Rolle", + "roleMember": "Member", + "roleAdmin": "Admin", + "invite": "Einladung senden", + "inviteSuccess": "Einladung gesendet", + "inviteFailed": "Einladung fehlgeschlagen.", + "search": "Mitglieder suchen", + "listTitle": "Team & Gäste", + "copyInvite": "Einladungslink kopiert", + "copyInviteFailed": "Kopieren nicht möglich", + "copyInviteLabel": "Einladungslink kopieren", + "empty": "Noch keine Einladungen.", + "emptyTitle": "Team einladen", + "emptyBody": "Sende die erste Einladung, damit Helfer Zugriff erhalten.", + "emptyAction": "Erste Einladung senden", + "admin": "Admin", + "member": "Member", + "confirmRemove": "Mitglied entfernen?", + "remove": "Entfernen", + "removeHint": "Dieses Mitglied verliert den Zugang zum Event.", + "removeSuccess": "Mitglied entfernt", + "removeFailed": "Mitglied konnte nicht entfernt werden." + }, "tasks": { "disabledTitle": "Task-Modus ist für dieses Event aus", "disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.", @@ -1846,6 +1874,10 @@ "add": "Hinzufügen", "empty": "Noch keine Aufgaben zugewiesen.", "emptyHint": "Lege jetzt Tasks an oder importiere ein Paket.", + "emptyTitle": "Noch keine Tasks", + "emptyBody": "Lege Tasks an oder importiere ein Paket für dein Event.", + "emptyActionTask": "Task hinzufügen", + "emptyActionPack": "Paket importieren", "addTask": "Aufgabe hinzufügen", "addTaskHint": "Erstelle eine neue Aufgabe für dieses Event.", "import": "Aufgabenpaket importieren", @@ -2060,6 +2092,10 @@ "description": "Schützt zwischengespeicherte Daten vor Löschung." } }, + "experienceTitle": "Erlebnis", + "experienceBody": "Starte die Quick Tour neu oder aktiviere den Install-Banner.", + "experienceReplay": "Quick Tour starten", + "experienceResetInstall": "Install-Banner anzeigen", "pref": {} }, "events": { @@ -2288,6 +2324,9 @@ "sendSuccess": "Benachrichtigung an Gäste gesendet.", "historyTitle": "Neueste Nachrichten", "empty": "Noch keine Gästebenachrichtigungen.", + "emptyTitle": "Erste Gästebenachrichtigung senden", + "emptyBody": "Erinnere Gäste kurz oder teile ein Highlight.", + "emptyAction": "Nachricht verfassen", "status": { "active": "Aktiv", "draft": "Entwurf", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 7c7c374..b37b004 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1852,6 +1852,34 @@ "fileTooLarge": "Watermark must be under 3 MB." } }, + "members": { + "title": "Guest management", + "inviteTitle": "Invite member", + "name": "Name", + "email": "Email", + "role": "Role", + "roleMember": "Member", + "roleAdmin": "Admin", + "invite": "Send invite", + "inviteSuccess": "Invitation sent", + "inviteFailed": "Invitation failed.", + "search": "Search members", + "listTitle": "Team & guests", + "copyInvite": "Invite link copied", + "copyInviteFailed": "Copy failed", + "copyInviteLabel": "Copy invite link", + "empty": "No invitations yet.", + "emptyTitle": "Invite your team", + "emptyBody": "Send the first invite so helpers can access the event.", + "emptyAction": "Send first invite", + "admin": "Admin", + "member": "Member", + "confirmRemove": "Remove member?", + "remove": "Remove", + "removeHint": "This member will lose access to the event.", + "removeSuccess": "Member removed", + "removeFailed": "Member could not be removed." + }, "tasks": { "disabledTitle": "Task mode is off for this event", "disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.", @@ -1866,6 +1894,10 @@ "add": "Add", "empty": "No tasks assigned yet.", "emptyHint": "Add tasks or import a pack.", + "emptyTitle": "No tasks yet", + "emptyBody": "Create tasks or import a pack for your event.", + "emptyActionTask": "Add task", + "emptyActionPack": "Import pack", "addTask": "Add task", "addTaskHint": "Create a new task for this event.", "import": "Import pack", @@ -2080,6 +2112,10 @@ "description": "Protect cached data from eviction." } }, + "experienceTitle": "Experience", + "experienceBody": "Replay the quick tour or re-enable the install banner.", + "experienceReplay": "Replay quick tour", + "experienceResetInstall": "Show install banner", "pref": {} }, "events": { @@ -2308,6 +2344,9 @@ "sendSuccess": "Notification sent to guests.", "historyTitle": "Recent messages", "empty": "No guest messages yet.", + "emptyTitle": "Send your first guest message", + "emptyBody": "Share a quick reminder or highlight to keep guests engaged.", + "emptyAction": "Compose message", "status": { "active": "Active", "draft": "Draft", diff --git a/resources/js/admin/mobile/BrandingPage.tsx b/resources/js/admin/mobile/BrandingPage.tsx index c4a9b62..d3310a7 100644 --- a/resources/js/admin/mobile/BrandingPage.tsx +++ b/resources/js/admin/mobile/BrandingPage.tsx @@ -7,7 +7,7 @@ import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; -import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings } from '../api'; +import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont, WatermarkSettings, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { ApiError, getApiErrorMessage } from '../lib/apiError'; import { isBrandingAllowed } from '../lib/events'; @@ -189,6 +189,7 @@ export default function MobileBrandingPage() { settings, }); setEvent(updated); + void trackOnboarding('branding_configured', { event_id: updated.id }); toast.success(t('events.branding.saveSuccess', 'Branding gespeichert')); } catch (err) { if (!isAuthError(err)) { diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 0e900b0..ce46503 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { Bell, CheckCircle2, Download, Image as ImageIcon, ListTodo, MessageCircle, QrCode, Settings, ShieldCheck, Smartphone, Users, Sparkles } from 'lucide-react'; @@ -17,7 +17,9 @@ 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'; +import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour'; +import { trackOnboarding } from '../api'; +import { useAuth } from '../auth/context'; type DeviceSetupProps = { installPrompt: ReturnType; @@ -26,17 +28,18 @@ type DeviceSetupProps = { onOpenSettings: () => void; }; -const TOUR_STORAGE_KEY = 'admin-mobile-tour-v1'; - export default function MobileDashboardPage() { const navigate = useNavigate(); + const location = useLocation(); const { t, i18n } = useTranslation('management'); const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext(); + const { status } = useAuth(); 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 onboardingTrackedRef = React.useRef(false); const installPrompt = useInstallPrompt(); const pushState = useAdminPushSubscription(); const devicePermissions = useDevicePermissions(); @@ -73,29 +76,55 @@ export default function MobileDashboardPage() { const tourStepKeys = React.useMemo(() => resolveTourStepKeys(effectiveHasEvents), [effectiveHasEvents]); React.useEffect(() => { - if (typeof window === 'undefined') { + if (status !== 'authenticated' || onboardingTrackedRef.current) { return; } - try { - const stored = window.localStorage.getItem(TOUR_STORAGE_KEY); - if (stored) return; - setTourOpen(true); - } catch { - setTourOpen(false); + onboardingTrackedRef.current = true; + + if (typeof window !== 'undefined') { + try { + const stored = window.localStorage.getItem('admin-onboarding-opened-v1'); + if (stored) { + return; + } + + window.localStorage.setItem('admin-onboarding-opened-v1', '1'); + } catch { + // Ignore storage errors. + } } - }, []); + + void trackOnboarding('admin_app_opened'); + }, [status]); + + const forceTour = React.useMemo(() => { + const params = new URLSearchParams(location.search); + return params.get('tour') === '1'; + }, [location.search]); + + React.useEffect(() => { + if (forceTour) { + setTourStep(0); + setTourOpen(true); + setTourSeen(false); + navigate(location.pathname, { replace: true }); + return; + } + + if (getTourSeen()) { + return; + } + + setTourOpen(true); + }, [forceTour, location.pathname, navigate]); 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. - } + setTourSeen(true); }, []); const closeTour = React.useCallback(() => { diff --git a/resources/js/admin/mobile/EventFormPage.tsx b/resources/js/admin/mobile/EventFormPage.tsx index effa637..e631f03 100644 --- a/resources/js/admin/mobile/EventFormPage.tsx +++ b/resources/js/admin/mobile/EventFormPage.tsx @@ -9,7 +9,7 @@ import { MobileShell } from './components/MobileShell'; import { MobileCard, CTAButton } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls'; import { LegalConsentSheet } from './components/LegalConsentSheet'; -import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType } from '../api'; +import { createEvent, getEvent, updateEvent, getEventTypes, TenantEvent, TenantEventType, trackOnboarding } from '../api'; import { resolveEventSlugAfterUpdate } from './eventFormNavigation'; import { adminPath } from '../constants'; import { isAuthError } from '../auth/tokens'; @@ -133,6 +133,7 @@ export default function MobileEventFormPage() { }, } as Parameters[0]; const { event } = await createEvent(payload); + void trackOnboarding('event_created', { event_id: event.id }); navigate(adminPath(`/mobile/events/${event.slug}`)); } } catch (err) { @@ -178,6 +179,7 @@ export default function MobileEventFormPage() { ...pendingPayload, accepted_waiver: consents.acceptedWaiver, }); + void trackOnboarding('event_created', { event_id: event.id }); navigate(adminPath(`/mobile/events/${event.slug}`)); setConsentOpen(false); setPendingPayload(null); diff --git a/resources/js/admin/mobile/EventGuestNotificationsPage.tsx b/resources/js/admin/mobile/EventGuestNotificationsPage.tsx index 8638a00..75619fc 100644 --- a/resources/js/admin/mobile/EventGuestNotificationsPage.tsx +++ b/resources/js/admin/mobile/EventGuestNotificationsPage.tsx @@ -47,6 +47,7 @@ export default function MobileEventGuestNotificationsPage() { const [sending, setSending] = React.useState(false); const [error, setError] = React.useState(null); const [fallbackAttempted, setFallbackAttempted] = React.useState(false); + const formRef = React.useRef(null); const [form, setForm] = React.useState({ title: '', @@ -200,7 +201,8 @@ export default function MobileEventGuestNotificationsPage() { ) : null} - +
+ {t('guestMessages.composeTitle', 'Send a message')} @@ -294,7 +296,8 @@ export default function MobileEventGuestNotificationsPage() { ) : null} - + +
@@ -313,10 +316,18 @@ export default function MobileEventGuestNotificationsPage() { ))} ) : history.length === 0 ? ( - - - {t('guestMessages.empty', 'No guest messages yet.')} + + + {t('guestMessages.emptyTitle', 'Send your first guest message')} + + {t('guestMessages.emptyBody', 'Share a quick reminder or highlight to keep guests engaged.')} + + formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })} + /> ) : ( diff --git a/resources/js/admin/mobile/EventMembersPage.tsx b/resources/js/admin/mobile/EventMembersPage.tsx index 2f57fe1..fa06bbd 100644 --- a/resources/js/admin/mobile/EventMembersPage.tsx +++ b/resources/js/admin/mobile/EventMembersPage.tsx @@ -8,7 +8,7 @@ import { Pressable } from '@tamagui/react-native-web-lite'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; -import { EventMember, getEventMembers, inviteEventMember, removeEventMember } from '../api'; +import { EventMember, getEventMembers, inviteEventMember, removeEventMember, trackOnboarding } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage } from '../lib/apiError'; import toast from 'react-hot-toast'; @@ -26,6 +26,7 @@ export default function MobileEventMembersPage() { const [invite, setInvite] = React.useState({ name: '', email: '', role: 'member' as EventMember['role'] }); const [saving, setSaving] = React.useState(false); const [inviteLink, setInviteLink] = React.useState(null); + const emailInputRef = React.useRef(null); const [search, setSearch] = React.useState(''); const [confirmRemove, setConfirmRemove] = React.useState(null); @@ -67,6 +68,7 @@ export default function MobileEventMembersPage() { }); setMembers((prev) => [member, ...prev]); setInvite({ name: '', email: '', role: 'member' }); + void trackOnboarding('invite_created', { event_slug: slug }); toast.success(t('events.members.inviteSuccess', 'Einladung gesendet')); } catch (err) { if (!isAuthError(err)) { @@ -130,6 +132,7 @@ export default function MobileEventMembersPage() { value={invite.email} onChange={(e) => setInvite((prev) => ({ ...prev, email: e.target.value }))} placeholder="alex@example.com" + ref={emailInputRef} /> @@ -188,9 +191,19 @@ export default function MobileEventMembersPage() { ))} ) : members.length === 0 ? ( - - {t('events.members.empty', 'Noch keine Einladungen.')} - + + + {t('events.members.emptyTitle', 'Invite your team')} + + + {t('events.members.emptyBody', 'Send the first invite so helpers can access the event.')} + + emailInputRef.current?.focus()} + fullWidth={false} + /> + ) : ( {members diff --git a/resources/js/admin/mobile/EventTasksPage.tsx b/resources/js/admin/mobile/EventTasksPage.tsx index 0790b98..8e6eb0e 100644 --- a/resources/js/admin/mobile/EventTasksPage.tsx +++ b/resources/js/admin/mobile/EventTasksPage.tsx @@ -382,11 +382,24 @@ export default function MobileEventTasksPage() { - {t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')} + {t('events.tasks.emptyTitle', 'No tasks yet')} - {t('events.tasks.emptyHint', 'Lege jetzt Tasks an oder importiere ein Paket.')} + {t('events.tasks.emptyBody', 'Create tasks or import a pack for your event.')} + + setShowTaskSheet(true)} + fullWidth={false} + /> + setShowCollectionSheet(true)} + fullWidth={false} + /> + setShowTaskSheet(true)}> diff --git a/resources/js/admin/mobile/LoginPage.tsx b/resources/js/admin/mobile/LoginPage.tsx index 0985b5b..61a694b 100644 --- a/resources/js/admin/mobile/LoginPage.tsx +++ b/resources/js/admin/mobile/LoginPage.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Download, Loader2, Lock, Mail, Share2 } from 'lucide-react'; +import { Loader2, Lock, Mail } 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'; +import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstallBanner } from './lib/installBanner'; +import { MobileInstallBanner } from './components/MobileInstallBanner'; type LoginResponse = { token: string; @@ -45,7 +46,6 @@ 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(); @@ -80,12 +80,16 @@ 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 [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed()); + const installBanner = shouldShowInstallBanner( + { + isInstalled: installPrompt.isInstalled, + isStandalone: installPrompt.isStandalone, + canInstall: installPrompt.canInstall, + isIos: installPrompt.isIos, + }, + installBannerDismissed, + ); const mutation = useMutation({ mutationKey: ['tenantAdminLoginMobile'], @@ -189,36 +193,15 @@ 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} + void installPrompt.promptInstall() : undefined} + onDismiss={() => { + setInstallBannerDismissed(true); + setInstallBannerDismissedState(true); + }} + />
{t('login.support', 'Fragen? Schreib uns an support@fotospiel.de oder antworte direkt auf deine Einladung.')} diff --git a/resources/js/admin/mobile/SettingsPage.tsx b/resources/js/admin/mobile/SettingsPage.tsx index ddf4422..edd8052 100644 --- a/resources/js/admin/mobile/SettingsPage.tsx +++ b/resources/js/admin/mobile/SettingsPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Shield, Bell, User, Smartphone } from 'lucide-react'; +import { Shield, Bell, User, Smartphone, Sparkles } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { YGroup } from '@tamagui/group'; import { ListItem } from '@tamagui/list-item'; @@ -17,13 +17,14 @@ import { NotificationPreferences, } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; -import { adminPath } from '../constants'; +import { adminPath, ADMIN_HOME_PATH } 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 { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstallBanner } from './lib/installBanner'; import { MobileInstallBanner } from './components/MobileInstallBanner'; +import { setTourSeen } from './lib/mobileTour'; type PreferenceKey = keyof NotificationPreferences; @@ -58,12 +59,16 @@ export default function MobileSettingsPage() { 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 [installBannerDismissed, setInstallBannerDismissedState] = React.useState(() => getInstallBannerDismissed()); + const installBanner = shouldShowInstallBanner( + { + isInstalled: installPrompt.isInstalled, + isStandalone: installPrompt.isStandalone, + canInstall: installPrompt.canInstall, + isIos: installPrompt.isIos, + }, + installBannerDismissed, + ); const pushDescription = React.useMemo(() => { if (!pushState.supported) { @@ -132,6 +137,16 @@ export default function MobileSettingsPage() { } }, [devicePermissions.storage]); + const handleReplayTour = () => { + setTourSeen(false); + navigate(`${ADMIN_HOME_PATH}?tour=1`); + }; + + const handleResetInstallBanner = () => { + setInstallBannerDismissed(false); + setInstallBannerDismissedState(false); + }; + const togglePref = (key: PreferenceKey) => { setPreferences((prev) => ({ ...prev, @@ -185,6 +200,10 @@ export default function MobileSettingsPage() { void installPrompt.promptInstall() : undefined} + onDismiss={() => { + setInstallBannerDismissed(true); + setInstallBannerDismissedState(true); + }} /> @@ -370,6 +389,32 @@ export default function MobileSettingsPage() { ) : null} + + + + + {t('mobileSettings.experienceTitle', 'Experience')} + + + + {t('mobileSettings.experienceBody', 'Replay the quick tour or re-enable the install banner.')} + + + + + + + diff --git a/resources/js/admin/mobile/components/MobileInstallBanner.test.tsx b/resources/js/admin/mobile/components/MobileInstallBanner.test.tsx new file mode 100644 index 0000000..06f4eb3 --- /dev/null +++ b/resources/js/admin/mobile/components/MobileInstallBanner.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('@tamagui/core', () => ({ + useTheme: () => ({ + color12: { val: '#111827' }, + color: { val: '#111827' }, + gray11: { val: '#6b7280' }, + gray6: { val: '#e5e7eb' }, + gray2: { val: '#f8fafc' }, + blue3: { val: '#dbeafe' }, + primary: { val: '#2563eb' }, + }), +})); + +vi.mock('@tamagui/stacks', () => ({ + YStack: ({ children, ...props }: { children: React.ReactNode }) =>
{children}
, + XStack: ({ children, ...props }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('@tamagui/text', () => ({ + SizableText: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})); + +vi.mock('@tamagui/react-native-web-lite', () => ({ + Pressable: ({ children, ...props }: { children: React.ReactNode }) => , +})); + +import { MobileInstallBanner } from './MobileInstallBanner'; + +describe('MobileInstallBanner', () => { + it('renders install action for prompt variant', () => { + render( + {}} + onDismiss={() => {}} + />, + ); + + expect(screen.getByText('installBanner.title')).toBeInTheDocument(); + expect(screen.getByText('installBanner.action')).toBeInTheDocument(); + expect(screen.getByLabelText('actions.dismiss')).toBeInTheDocument(); + }); + + it('renders iOS hint without install action', () => { + render( + {}} + />, + ); + + expect(screen.getByText('installBanner.iosHint')).toBeInTheDocument(); + expect(screen.queryByText('installBanner.action')).toBeNull(); + }); +}); diff --git a/resources/js/admin/mobile/components/MobileInstallBanner.tsx b/resources/js/admin/mobile/components/MobileInstallBanner.tsx index d351cb4..bf6d21a 100644 --- a/resources/js/admin/mobile/components/MobileInstallBanner.tsx +++ b/resources/js/admin/mobile/components/MobileInstallBanner.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Download, Share2 } from 'lucide-react'; +import { Download, Share2, X } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { useTheme } from '@tamagui/core'; +import { Pressable } from '@tamagui/react-native-web-lite'; import { InstallBannerState } from '../lib/installBanner'; import { CTAButton, MobileCard } from './Primitives'; import { useTranslation } from 'react-i18next'; @@ -10,49 +11,78 @@ import { useTranslation } from 'react-i18next'; type MobileInstallBannerProps = { state: InstallBannerState | null; onInstall?: () => void; + onDismiss?: () => void; + density?: 'default' | 'compact'; }; -export function MobileInstallBanner({ state, onInstall }: MobileInstallBannerProps) { +export function MobileInstallBanner({ + state, + onInstall, + onDismiss, + density = 'default', +}: 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 border = String(theme.gray6?.val ?? theme.borderColor?.val ?? '#e5e7eb'); const accent = String(theme.primary?.val ?? '#2563eb'); + const surface = String(theme.gray2?.val ?? '#f8fafc'); + const accentSoft = String(theme.blue3?.val ?? '#dbeafe'); if (!state) { return null; } const isPrompt = state.variant === 'prompt'; + const isCompact = density === 'compact'; return ( - + - {isPrompt ? : } + {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 && isCompact ? ( + + + {t('installBanner.action', 'Install')} + + + ) : null} + {onDismiss ? ( + + + + ) : null} + - {isPrompt && onInstall ? ( + {isPrompt && onInstall && !isCompact ? ( { it('returns null when already installed', () => { @@ -22,3 +22,21 @@ describe('resolveInstallBannerState', () => { expect(resolveInstallBannerState({ isInstalled: false, isStandalone: false, canInstall: false, isIos: false })).toBeNull(); }); }); + +describe('shouldShowInstallBanner', () => { + it('returns null when dismissed', () => { + const result = shouldShowInstallBanner( + { isInstalled: false, isStandalone: false, canInstall: true, isIos: true }, + true, + ); + expect(result).toBeNull(); + }); + + it('returns state when not dismissed', () => { + const result = shouldShowInstallBanner( + { isInstalled: false, isStandalone: false, canInstall: true, isIos: false }, + false, + ); + expect(result).toEqual({ variant: 'prompt' }); + }); +}); diff --git a/resources/js/admin/mobile/lib/installBanner.ts b/resources/js/admin/mobile/lib/installBanner.ts index b96390d..7c16951 100644 --- a/resources/js/admin/mobile/lib/installBanner.ts +++ b/resources/js/admin/mobile/lib/installBanner.ts @@ -11,6 +11,8 @@ export type InstallBannerInput = { isIos: boolean; }; +export const INSTALL_BANNER_DISMISS_KEY = 'admin-install-banner-dismissed-v1'; + export function resolveInstallBannerState(input: InstallBannerInput): InstallBannerState | null { if (input.isInstalled || input.isStandalone) { return null; @@ -26,3 +28,39 @@ export function resolveInstallBannerState(input: InstallBannerInput): InstallBan return null; } + +export function shouldShowInstallBanner(input: InstallBannerInput, dismissed: boolean): InstallBannerState | null { + if (dismissed) { + return null; + } + + return resolveInstallBannerState(input); +} + +export function getInstallBannerDismissed(): boolean { + if (typeof window === 'undefined') { + return false; + } + + try { + return window.localStorage.getItem(INSTALL_BANNER_DISMISS_KEY) === '1'; + } catch { + return false; + } +} + +export function setInstallBannerDismissed(value: boolean): void { + if (typeof window === 'undefined') { + return; + } + + try { + if (value) { + window.localStorage.setItem(INSTALL_BANNER_DISMISS_KEY, '1'); + } else { + window.localStorage.removeItem(INSTALL_BANNER_DISMISS_KEY); + } + } catch { + // Ignore storage errors. + } +} diff --git a/resources/js/admin/mobile/lib/mobileTour.test.ts b/resources/js/admin/mobile/lib/mobileTour.test.ts index a0e018c..6b9e6b7 100644 --- a/resources/js/admin/mobile/lib/mobileTour.test.ts +++ b/resources/js/admin/mobile/lib/mobileTour.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { resolveTourStepKeys } from './mobileTour'; +import { getTourSeen, resolveTourStepKeys, setTourSeen, TOUR_STORAGE_KEY } from './mobileTour'; describe('resolveTourStepKeys', () => { it('includes the event step when there are no events', () => { @@ -10,3 +10,15 @@ describe('resolveTourStepKeys', () => { expect(resolveTourStepKeys(true)).toEqual(['qr', 'photos', 'push']); }); }); + +describe('tour storage helpers', () => { + it('stores and reads the seen flag', () => { + setTourSeen(false); + expect(getTourSeen()).toBe(false); + + setTourSeen(true); + expect(getTourSeen()).toBe(true); + + window.localStorage.removeItem(TOUR_STORAGE_KEY); + }); +}); diff --git a/resources/js/admin/mobile/lib/mobileTour.ts b/resources/js/admin/mobile/lib/mobileTour.ts index 6893d55..5915c56 100644 --- a/resources/js/admin/mobile/lib/mobileTour.ts +++ b/resources/js/admin/mobile/lib/mobileTour.ts @@ -1,5 +1,7 @@ export type TourStepKey = 'event' | 'qr' | 'photos' | 'push'; +export const TOUR_STORAGE_KEY = 'admin-mobile-tour-v1'; + export function resolveTourStepKeys(hasEvents: boolean): TourStepKey[] { if (hasEvents) { return ['qr', 'photos', 'push']; @@ -7,3 +9,31 @@ export function resolveTourStepKeys(hasEvents: boolean): TourStepKey[] { return ['event', 'qr', 'photos', 'push']; } + +export function getTourSeen(): boolean { + if (typeof window === 'undefined') { + return false; + } + + try { + return window.localStorage.getItem(TOUR_STORAGE_KEY) === 'seen'; + } catch { + return false; + } +} + +export function setTourSeen(seen: boolean): void { + if (typeof window === 'undefined') { + return; + } + + try { + if (seen) { + window.localStorage.setItem(TOUR_STORAGE_KEY, 'seen'); + } else { + window.localStorage.removeItem(TOUR_STORAGE_KEY); + } + } catch { + // Ignore storage errors. + } +}