Files
fotospiel-app/resources/js/guest-v2/components/GuestAnalyticsNudge.tsx
Codex Agent 298a8375b6
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Update guest v2 branding and theming
2026-02-03 15:18:44 +01:00

267 lines
7.3 KiB
TypeScript

import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { useConsent } from '@/contexts/consent';
import { useTranslation } from '@/guest/i18n/useTranslation';
import { isUploadPath, shouldShowAnalyticsNudge } from '@/guest/lib/analyticsConsent';
import { useGuestThemeVariant } from '../lib/guestTheme';
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 { isDark } = useGuestThemeVariant();
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 (
<YStack
position="fixed"
left={0}
right={0}
zIndex={1400}
pointerEvents="none"
paddingHorizontal="$4"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 96px)' }}
>
<YStack
pointerEvents="auto"
marginHorizontal="auto"
maxWidth={560}
borderRadius="$6"
padding="$4"
borderWidth={1}
borderColor={isDark ? 'rgba(148, 163, 184, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.96)' : 'rgba(255, 255, 255, 0.96)'}
style={{ backdropFilter: 'blur(16px)' }}
>
<XStack flexWrap="wrap" gap="$3" alignItems="center" justifyContent="space-between">
<YStack gap="$1" flexShrink={1} minWidth={220}>
<Text fontSize="$4" fontWeight="$7" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.title')}
</Text>
<Text fontSize="$2" color={isDark ? 'rgba(226, 232, 240, 0.7)' : 'rgba(15, 23, 42, 0.6)'}>
{t('consent.analytics.body')}
</Text>
</YStack>
<XStack gap="$2" flexWrap="wrap">
<Button
size="$2"
borderRadius="$pill"
backgroundColor={isDark ? 'rgba(248, 250, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)'}
borderColor={isDark ? 'rgba(248, 250, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)'}
borderWidth={1}
onPress={handleSnooze}
>
<Text fontSize="$2" fontWeight="$6" color={isDark ? '#F8FAFF' : '#0F172A'}>
{t('consent.analytics.later')}
</Text>
</Button>
<Button size="$2" borderRadius="$pill" backgroundColor="$primary" onPress={handleAllow}>
<Text fontSize="$2" fontWeight="$6" color="#FFFFFF">
{t('consent.analytics.allow')}
</Text>
</Button>
</XStack>
</XStack>
</YStack>
</YStack>
);
}