237 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|