Add guest analytics consent nudge
This commit is contained in:
236
resources/js/guest/components/GuestAnalyticsNudge.tsx
Normal file
236
resources/js/guest/components/GuestAnalyticsNudge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
44
resources/js/guest/lib/__tests__/analyticsConsent.test.ts
Normal file
44
resources/js/guest/lib/__tests__/analyticsConsent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
34
resources/js/guest/lib/analyticsConsent.ts
Normal file
34
resources/js/guest/lib/analyticsConsent.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user