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:
Codex Agent
2025-12-28 18:59:12 +01:00
parent d5f038d098
commit 718c129a8d
16 changed files with 454 additions and 91 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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)) {

View File

@@ -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(() => {

View File

@@ -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);

View File

@@ -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">

View File

@@ -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

View File

@@ -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)}>

View File

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

View File

@@ -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} />

View File

@@ -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();
});
});

View File

@@ -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}

View File

@@ -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' });
});
});

View File

@@ -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.
}
}

View File

@@ -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);
});
});

View File

@@ -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.
}
}