Files
fotospiel-app/resources/js/guest/components/GuestAnalyticsNudge.tsx
Codex Agent bdb1789a10
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Add guest analytics consent nudge
2026-01-23 16:20:14 +01:00

237 lines
6.2 KiB
TypeScript

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