diff --git a/resources/js/guest/components/GuestAnalyticsNudge.tsx b/resources/js/guest/components/GuestAnalyticsNudge.tsx new file mode 100644 index 0000000..9be39f8 --- /dev/null +++ b/resources/js/guest/components/GuestAnalyticsNudge.tsx @@ -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(() => 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 = [ + '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 ( +
+
+
+
+

+ {t('consent.analytics.title')} +

+

+ {t('consent.analytics.body')} +

+
+
+ + +
+
+
+
+ ); +} diff --git a/resources/js/guest/components/settings-sheet.tsx b/resources/js/guest/components/settings-sheet.tsx index 131f0a1..933e9c7 100644 --- a/resources/js/guest/components/settings-sheet.tsx +++ b/resources/js/guest/components/settings-sheet.tsx @@ -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({ + {matomoEnabled ? ( + + + {t('settings.analytics.title')} + {t('settings.analytics.description')} + + +
+ {t('settings.analytics.label')} + savePreferences({ analytics: checked })} + aria-label={t('settings.analytics.label')} + /> +
+
{t('settings.analytics.note')}
+
+
+ ) : null} + diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts index f3a938b..e37e71f 100644 --- a/resources/js/guest/i18n/messages.ts +++ b/resources/js/guest/i18n/messages.ts @@ -28,6 +28,14 @@ export const messages: Record = { 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 = { 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 = { 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 = { 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.', diff --git a/resources/js/guest/lib/__tests__/analyticsConsent.test.ts b/resources/js/guest/lib/__tests__/analyticsConsent.test.ts new file mode 100644 index 0000000..63cfe30 --- /dev/null +++ b/resources/js/guest/lib/__tests__/analyticsConsent.test.ts @@ -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); + }); +}); diff --git a/resources/js/guest/lib/analyticsConsent.ts b/resources/js/guest/lib/analyticsConsent.ts new file mode 100644 index 0000000..d9d35d2 --- /dev/null +++ b/resources/js/guest/lib/analyticsConsent.ts @@ -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 + ); +} diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx index 6c445b3..542e95f 100644 --- a/resources/js/guest/main.tsx +++ b/resources/js/guest/main.tsx @@ -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 }) => (
@@ -50,15 +51,17 @@ const appRoot = async () => { }> - - - - - }> - - - - + + + + + + }> + + + + + diff --git a/resources/js/guest/router.tsx b/resources/js/guest/router.tsx index c6269c4..7c3eaf1 100644 --- a/resources/js/guest/router.tsx +++ b/resources/js/guest/router.tsx @@ -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() {
+ ); } @@ -56,6 +60,7 @@ function HomeLayout() { return ( + ); } @@ -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() { + ); } @@ -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 (
@@ -335,6 +345,7 @@ function SimpleLayout({ title, children }: { title: string; children: React.Reac {children}
+
);