onboarding tracking is now wired, the tour can be replayed from Settings, install‑banner reset is included, and empty states in Tasks/Members/Guest Messages now have guided CTAs.
What changed:
- Onboarding tracking: admin_app_opened on first authenticated dashboard load; event_created, branding_configured,
and invite_created on their respective actions.
- Tour replay: Settings now has an “Experience” section to replay the tour (clears tour seen flag and opens via ?tour=1).
- Empty states: Tasks, Members, and Guest Messages now include richer copy + quick actions.
- New helpers + copy: Tour storage helpers, new translations, and related UI wiring.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<typeof useInstallPrompt>;
|
||||
@@ -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<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 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(() => {
|
||||
|
||||
@@ -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<typeof createEvent>[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);
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
|
||||
const formRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [form, setForm] = React.useState<FormState>({
|
||||
title: '',
|
||||
@@ -200,7 +201,8 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<div ref={formRef}>
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
||||
{t('guestMessages.composeTitle', 'Send a message')}
|
||||
</Text>
|
||||
@@ -294,7 +296,8 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</MobileCard>
|
||||
</div>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
@@ -313,10 +316,18 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
))}
|
||||
</YStack>
|
||||
) : history.length === 0 ? (
|
||||
<YStack space="$1.5">
|
||||
<Text fontSize="$sm" color={mutedText}>
|
||||
{t('guestMessages.empty', 'No guest messages yet.')}
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={String(theme.color?.val ?? '#111827')}>
|
||||
{t('guestMessages.emptyTitle', 'Send your first guest message')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
{t('guestMessages.emptyBody', 'Share a quick reminder or highlight to keep guests engaged.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('guestMessages.emptyAction', 'Compose message')}
|
||||
fullWidth={false}
|
||||
onPress={() => formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const emailInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(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}
|
||||
/>
|
||||
</MobileField>
|
||||
<MobileField label={t('events.members.role', 'Role')}>
|
||||
@@ -188,9 +191,19 @@ export default function MobileEventMembersPage() {
|
||||
))}
|
||||
</YStack>
|
||||
) : members.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
{t('events.members.empty', 'Noch keine Einladungen.')}
|
||||
</Text>
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.members.emptyTitle', 'Invite your team')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
{t('events.members.emptyBody', 'Send the first invite so helpers can access the event.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('events.members.emptyAction', 'Send first invite')}
|
||||
onPress={() => emailInputRef.current?.focus()}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{members
|
||||
|
||||
@@ -382,11 +382,24 @@ export default function MobileEventTasksPage() {
|
||||
<YStack space="$2">
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize={13} fontWeight="700" color={text}>
|
||||
{t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}
|
||||
{t('events.tasks.emptyTitle', 'No tasks yet')}
|
||||
</Text>
|
||||
<Text fontSize={12} color={muted}>
|
||||
{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.')}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('events.tasks.emptyActionTask', 'Add task')}
|
||||
onPress={() => setShowTaskSheet(true)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('events.tasks.emptyActionPack', 'Import pack')}
|
||||
tone="ghost"
|
||||
onPress={() => setShowCollectionSheet(true)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
|
||||
<Pressable onPress={() => setShowTaskSheet(true)}>
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
</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}
|
||||
<MobileInstallBanner
|
||||
state={installBanner}
|
||||
density="compact"
|
||||
onInstall={installPrompt.canInstall ? () => void installPrompt.promptInstall() : undefined}
|
||||
onDismiss={() => {
|
||||
setInstallBannerDismissed(true);
|
||||
setInstallBannerDismissedState(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<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.')}
|
||||
|
||||
@@ -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() {
|
||||
<MobileInstallBanner
|
||||
state={installBanner}
|
||||
onInstall={installPrompt.canInstall ? () => void installPrompt.promptInstall() : undefined}
|
||||
onDismiss={() => {
|
||||
setInstallBannerDismissed(true);
|
||||
setInstallBannerDismissedState(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MobileCard space="$3">
|
||||
@@ -370,6 +389,32 @@ export default function MobileSettingsPage() {
|
||||
) : null}
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Sparkles size={18} color={text} />
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
{t('mobileSettings.experienceTitle', 'Experience')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('mobileSettings.experienceBody', 'Replay the quick tour or re-enable the install banner.')}
|
||||
</Text>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('mobileSettings.experienceReplay', 'Replay quick tour')}
|
||||
onPress={handleReplayTour}
|
||||
fullWidth={false}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('mobileSettings.experienceResetInstall', 'Show install banner')}
|
||||
tone="ghost"
|
||||
onPress={handleResetInstallBanner}
|
||||
fullWidth={false}
|
||||
disabled={!installBannerDismissed}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<User size={18} color={text} />
|
||||
|
||||
@@ -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 }) => <div {...props}>{children}</div>,
|
||||
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/text', () => ({
|
||||
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||
Pressable: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
import { MobileInstallBanner } from './MobileInstallBanner';
|
||||
|
||||
describe('MobileInstallBanner', () => {
|
||||
it('renders install action for prompt variant', () => {
|
||||
render(
|
||||
<MobileInstallBanner
|
||||
state={{ variant: 'prompt' }}
|
||||
density="compact"
|
||||
onInstall={() => {}}
|
||||
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(
|
||||
<MobileInstallBanner
|
||||
state={{ variant: 'ios' }}
|
||||
density="default"
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('installBanner.iosHint')).toBeInTheDocument();
|
||||
expect(screen.queryByText('installBanner.action')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<MobileCard space="$2" borderColor={border} backgroundColor={String(theme.blue2?.val ?? '#eff6ff')}>
|
||||
<MobileCard
|
||||
space={isCompact ? '$1.5' : '$2'}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
padding={isCompact ? '$2' : '$3'}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||
<XStack alignItems="center" space="$2" flex={1}>
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
width={isCompact ? 32 : 36}
|
||||
height={isCompact ? 32 : 36}
|
||||
borderRadius={12}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={String(theme.blue3?.val ?? '#dbeafe')}
|
||||
backgroundColor={accentSoft}
|
||||
>
|
||||
{isPrompt ? <Download size={18} color={accent} /> : <Share2 size={18} color={accent} />}
|
||||
{isPrompt ? <Download size={16} color={accent} /> : <Share2 size={16} color={accent} />}
|
||||
</XStack>
|
||||
<YStack flex={1} space="$0.5">
|
||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={text}>
|
||||
{t('installBanner.title', 'Install Fotospiel Admin')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
<Text fontSize={isCompact ? 10 : '$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 alignItems="center" space="$2">
|
||||
{isPrompt && onInstall && isCompact ? (
|
||||
<Pressable onPress={onInstall}>
|
||||
<Text fontSize={10} fontWeight="700" color={accent}>
|
||||
{t('installBanner.action', 'Install')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{onDismiss ? (
|
||||
<Pressable onPress={onDismiss} aria-label={t('actions.dismiss', 'Dismiss')}>
|
||||
<X size={14} color={muted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
{isPrompt && onInstall ? (
|
||||
{isPrompt && onInstall && !isCompact ? (
|
||||
<CTAButton
|
||||
label={t('installBanner.action', 'Install')}
|
||||
onPress={onInstall}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveInstallBannerState } from './installBanner';
|
||||
import { resolveInstallBannerState, shouldShowInstallBanner } from './installBanner';
|
||||
|
||||
describe('resolveInstallBannerState', () => {
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user