267 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|