Add guest analytics consent nudge
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-23 16:20:14 +01:00
parent 4bf0d5052c
commit bdb1789a10
7 changed files with 388 additions and 9 deletions

View File

@@ -0,0 +1,236 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { useConsent } from '@/contexts/consent';
import { useTranslation } from '../i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '../lib/analyticsConsent';
const PROMPT_STORAGE_KEY = 'fotospiel.guest.analyticsPrompt';
const SNOOZE_MS = 60 * 60 * 1000;
const ACTIVE_IDLE_LIMIT_MS = 20_000;
type PromptStorage = {
snoozedUntil?: number | null;
};
function readSnoozedUntil(): number | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(PROMPT_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as PromptStorage;
return typeof parsed.snoozedUntil === 'number' ? parsed.snoozedUntil : null;
} catch {
return null;
}
}
function writeSnoozedUntil(value: number | null) {
if (typeof window === 'undefined') {
return;
}
try {
const payload: PromptStorage = { snoozedUntil: value };
window.localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(payload));
} catch {
// ignore storage failures
}
}
function randomInt(min: number, max: number): number {
const low = Math.ceil(min);
const high = Math.floor(max);
return Math.floor(Math.random() * (high - low + 1)) + low;
}
export default function GuestAnalyticsNudge({
enabled,
pathname,
}: {
enabled: boolean;
pathname: string;
}) {
const { t } = useTranslation();
const { decisionMade, preferences, savePreferences } = useConsent();
const analyticsConsent = Boolean(preferences?.analytics);
const [thresholdSeconds] = React.useState(() => randomInt(60, 120));
const [thresholdRoutes] = React.useState(() => randomInt(2, 3));
const [activeSeconds, setActiveSeconds] = React.useState(0);
const [routeCount, setRouteCount] = React.useState(0);
const [isOpen, setIsOpen] = React.useState(false);
const [snoozedUntil, setSnoozedUntil] = React.useState<number | null>(() => readSnoozedUntil());
const lastPathRef = React.useRef(pathname);
const lastActivityAtRef = React.useRef(Date.now());
const visibleRef = React.useRef(typeof document === 'undefined' ? true : document.visibilityState === 'visible');
const isUpload = isUploadPath(pathname);
React.useEffect(() => {
const previousPath = lastPathRef.current;
const currentPath = pathname;
lastPathRef.current = currentPath;
if (previousPath === currentPath) {
return;
}
if (isUploadPath(previousPath) || isUploadPath(currentPath)) {
return;
}
setRouteCount((count) => count + 1);
}, [pathname]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleActivity = () => {
lastActivityAtRef.current = Date.now();
};
const events: Array<keyof WindowEventMap> = [
'pointerdown',
'pointermove',
'keydown',
'scroll',
'touchstart',
];
events.forEach((event) => window.addEventListener(event, handleActivity, { passive: true }));
return () => {
events.forEach((event) => window.removeEventListener(event, handleActivity));
};
}, []);
React.useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const handleVisibility = () => {
visibleRef.current = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const interval = window.setInterval(() => {
const now = Date.now();
if (!visibleRef.current) {
return;
}
if (isUploadPath(lastPathRef.current)) {
return;
}
if (now - lastActivityAtRef.current > ACTIVE_IDLE_LIMIT_MS) {
return;
}
setActiveSeconds((seconds) => seconds + 1);
}, 1000);
return () => window.clearInterval(interval);
}, []);
React.useEffect(() => {
if (!enabled || analyticsConsent || decisionMade) {
setIsOpen(false);
return;
}
const shouldOpen = shouldShowAnalyticsNudge({
decisionMade,
analyticsConsent,
snoozedUntil,
now: Date.now(),
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
});
if (shouldOpen) {
setIsOpen(true);
}
}, [
enabled,
analyticsConsent,
decisionMade,
snoozedUntil,
activeSeconds,
routeCount,
thresholdSeconds,
thresholdRoutes,
isUpload,
]);
React.useEffect(() => {
if (isUpload) {
setIsOpen(false);
}
}, [isUpload]);
if (!enabled || decisionMade || analyticsConsent || !isOpen || isUpload) {
return null;
}
const handleSnooze = () => {
const until = Date.now() + SNOOZE_MS;
setSnoozedUntil(until);
writeSnoozedUntil(until);
setIsOpen(false);
};
const handleAllow = () => {
savePreferences({ analytics: true });
writeSnoozedUntil(null);
setIsOpen(false);
};
return (
<div
className="pointer-events-none fixed inset-x-0 z-40 px-4"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
<div className="pointer-events-auto mx-auto max-w-lg rounded-2xl border border-slate-200/80 bg-white/95 p-4 shadow-xl backdrop-blur dark:border-slate-700/60 dark:bg-slate-900/95">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">
{t('consent.analytics.title')}
</p>
<p className="text-xs text-muted-foreground">
{t('consent.analytics.body')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="ghost" onClick={handleSnooze}>
{t('consent.analytics.later')}
</Button>
<Button type="button" size="sm" onClick={handleAllow}>
{t('consent.analytics.allow')}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import type { LocaleCode } from '../i18n/messages';
import { useHapticsPreference } from '../hooks/useHapticsPreference';
import { triggerHaptic } from '../lib/haptics';
import { getHelpSlugForPathname } from '../lib/helpRouting';
import { useConsent } from '@/contexts/consent';
const legalPages = [
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
@@ -270,6 +271,8 @@ function HomeView({
}: HomeViewProps) {
const { t } = useTranslation();
const { enabled: hapticsEnabled, setEnabled: setHapticsEnabled, supported: hapticsSupported } = useHapticsPreference();
const { preferences, savePreferences } = useConsent();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const legalLinks = React.useMemo(
() =>
legalPages.map((page) => ({
@@ -389,6 +392,26 @@ function HomeView({
</CardContent>
</Card>
{matomoEnabled ? (
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('settings.analytics.title')}</CardTitle>
<CardDescription>{t('settings.analytics.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">{t('settings.analytics.label')}</span>
<Switch
checked={Boolean(preferences?.analytics)}
onCheckedChange={(checked) => savePreferences({ analytics: checked })}
aria-label={t('settings.analytics.label')}
/>
</div>
<div className="text-xs text-muted-foreground">{t('settings.analytics.note')}</div>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-3">
<CardTitle>

View File

@@ -28,6 +28,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Lädt...',
},
},
consent: {
analytics: {
title: 'Hilf uns, die App zu verbessern',
body: 'Erlaube anonyme Statistik (Matomo). Du kannst das jederzeit in den Einstellungen ändern.',
allow: 'Erlauben',
later: 'Nicht jetzt',
},
},
navigation: {
home: 'Start',
tasks: 'Aufgaben',
@@ -700,6 +708,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
label: 'Vibrationen aktivieren',
unsupported: 'Auf diesem Gerät nicht verfügbar.',
},
analytics: {
title: 'Anonyme Statistik',
description: 'Hilft uns, die Guest-App zu verbessern (Matomo, ohne Tracking-Cookies).',
label: 'Statistik zulassen',
note: 'Du kannst deine Entscheidung jederzeit hier ändern.',
},
legal: {
title: 'Rechtliches',
description: 'Die rechtlich verbindlichen Texte sind jederzeit hier abrufbar.',
@@ -772,6 +786,14 @@ export const messages: Record<LocaleCode, NestedMessages> = {
loading: 'Loading...',
},
},
consent: {
analytics: {
title: 'Help us improve the app',
body: 'Allow anonymous analytics (Matomo). You can change this anytime in settings.',
allow: 'Allow',
later: 'Not now',
},
},
navigation: {
home: 'Home',
tasks: 'Tasks',
@@ -1441,6 +1463,12 @@ export const messages: Record<LocaleCode, NestedMessages> = {
label: 'Enable vibrations',
unsupported: 'Not available on this device.',
},
analytics: {
title: 'Anonymous analytics',
description: 'Helps us improve the Guest app (Matomo, no tracking cookies).',
label: 'Allow analytics',
note: 'You can change this anytime here.',
},
legal: {
title: 'Legal',
description: 'The legally binding documents are always available here.',

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { isUploadPath, shouldShowAnalyticsNudge } from '../analyticsConsent';
describe('isUploadPath', () => {
it('detects upload routes', () => {
expect(isUploadPath('/e/abc/upload')).toBe(true);
expect(isUploadPath('/e/abc/upload/queue')).toBe(true);
});
it('ignores non-upload routes', () => {
expect(isUploadPath('/e/abc/gallery')).toBe(false);
expect(isUploadPath('/settings')).toBe(false);
});
});
describe('shouldShowAnalyticsNudge', () => {
const baseState = {
decisionMade: false,
analyticsConsent: false,
snoozedUntil: null,
now: 1000,
activeSeconds: 60,
routeCount: 2,
thresholdSeconds: 60,
thresholdRoutes: 2,
isUpload: false,
};
it('returns true when thresholds are met', () => {
expect(shouldShowAnalyticsNudge(baseState)).toBe(true);
});
it('returns false when consent decision is made', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, decisionMade: true })).toBe(false);
});
it('returns false when snoozed', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, snoozedUntil: 2000 })).toBe(false);
});
it('returns false on upload routes', () => {
expect(shouldShowAnalyticsNudge({ ...baseState, isUpload: true })).toBe(false);
});
});

View File

@@ -0,0 +1,34 @@
export type AnalyticsNudgeState = {
decisionMade: boolean;
analyticsConsent: boolean;
snoozedUntil: number | null;
now: number;
activeSeconds: number;
routeCount: number;
thresholdSeconds: number;
thresholdRoutes: number;
isUpload: boolean;
};
export function isUploadPath(pathname: string): boolean {
return /\/upload(?:\/|$)/.test(pathname);
}
export function shouldShowAnalyticsNudge(state: AnalyticsNudgeState): boolean {
if (state.decisionMade || state.analyticsConsent) {
return false;
}
if (state.isUpload) {
return false;
}
if (state.snoozedUntil && state.snoozedUntil > state.now) {
return false;
}
return (
state.activeSeconds >= state.thresholdSeconds &&
state.routeCount >= state.thresholdRoutes
);
}

View File

@@ -4,6 +4,7 @@ import '../../css/app.css';
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
import { Sentry, initSentry } from '@/lib/sentry';
import { AppearanceProvider, initializeTheme } from '@/hooks/use-appearance';
import { ConsentProvider } from '@/contexts/consent';
const GuestFallback: React.FC<{ message: string }> = ({ message }) => (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
@@ -50,15 +51,17 @@ const appRoot = async () => {
<Sentry.ErrorBoundary fallback={<GuestFallback message="Erlebnisse können nicht geladen werden." />}>
<React.StrictMode>
<AppearanceProvider>
<LocaleProvider>
<ToastProvider>
<MatomoTracker config={matomoConfig} />
<PwaManager />
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
<ConsentProvider>
<LocaleProvider>
<ToastProvider>
<MatomoTracker config={matomoConfig} />
<PwaManager />
<Suspense fallback={<GuestFallback message="Erlebnisse werden geladen …" />}>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</ConsentProvider>
</AppearanceProvider>
</React.StrictMode>
</Sentry.ErrorBoundary>

View File

@@ -17,6 +17,7 @@ import type { EventBrandingPayload, FetchEventErrorCode } from './services/event
import { NotificationCenterProvider } from './context/NotificationCenterContext';
import RouteErrorElement from '@/components/RouteErrorElement';
import { isTaskModeEnabled } from './lib/engagement';
import GuestAnalyticsNudge from './components/GuestAnalyticsNudge';
const LandingPage = React.lazy(() => import('./pages/LandingPage'));
const ProfileSetupPage = React.lazy(() => import('./pages/ProfileSetupPage'));
@@ -40,6 +41,8 @@ const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
function HomeLayout() {
const { token } = useParams();
const location = useLocation();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
if (!token) {
return (
@@ -49,6 +52,7 @@ function HomeLayout() {
<RouteTransition />
</div>
<BottomNav />
<GuestAnalyticsNudge enabled={matomoEnabled} pathname={location.pathname} />
</div>
);
}
@@ -56,6 +60,7 @@ function HomeLayout() {
return (
<GuestIdentityProvider eventKey={token}>
<EventBoundary token={token} />
<GuestAnalyticsNudge enabled={matomoEnabled} pathname={location.pathname} />
</GuestIdentityProvider>
);
}
@@ -158,6 +163,8 @@ function TaskGuard({ children }: { children: React.ReactNode }) {
function SetupLayout() {
const { token } = useParams<{ token: string }>();
const location = useLocation();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
const { event } = useEventData();
if (!token) return null;
const eventLocale = event && isLocaleCode(event.default_locale) ? event.default_locale : DEFAULT_LOCALE;
@@ -177,6 +184,7 @@ function SetupLayout() {
</EventStatsProvider>
</EventBrandingProvider>
</LocaleProvider>
<GuestAnalyticsNudge enabled={matomoEnabled} pathname={location.pathname} />
</GuestIdentityProvider>
);
}
@@ -327,6 +335,8 @@ function getErrorContent(
}
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
const location = useLocation();
const matomoEnabled = typeof window !== 'undefined' && Boolean((window as any).__MATOMO_GUEST__?.enabled);
return (
<EventBrandingProvider>
<div className="pb-16">
@@ -335,6 +345,7 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac
<RouteTransition>{children}</RouteTransition>
</div>
<BottomNav />
<GuestAnalyticsNudge enabled={matomoEnabled} pathname={location.pathname} />
</div>
</EventBrandingProvider>
);