Cookie changes

This commit is contained in:
Connor Yoh
2025-10-15 15:36:59 +01:00
parent af57ae02dd
commit 32c0fc0f1c
16 changed files with 382 additions and 34 deletions

View File

@@ -9,6 +9,7 @@ import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
import { useScarfTracking } from "./hooks/useScarfTracking";
// Import global styles
import "./styles/tailwind.css";
@@ -38,6 +39,9 @@ const LoadingFallback = () => (
);
export default function App() {
// Initialize scarf tracking (mounts once at app startup)
useScarfTracking();
return (
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>

View File

@@ -6,6 +6,7 @@ import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useViewer } from '../../contexts/ViewerContext';
import { useAppConfig } from '../../hooks/useAppConfig';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@@ -20,6 +21,7 @@ import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
const { config } = useAppConfig();
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
@@ -180,7 +182,7 @@ export default function Workbench() {
{renderMainContent()}
</Box>
<Footer analyticsEnabled />
<Footer analyticsEnabled={config?.enableAnalytics === true} />
</Box>
);
}

View File

@@ -0,0 +1,127 @@
import { Modal, Stack, Button, Text, Title, Paper, List } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '../../styles/zIndex';
import apiClient from '../../services/apiClient';
interface AdminAnalyticsChoiceModalProps {
opened: boolean;
onClose?: () => void;
}
export default function AdminAnalyticsChoiceModal({ opened }: AdminAnalyticsChoiceModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
// Reload the page to apply new settings
window.location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
setLoading(false);
}
};
const handleEnable = () => {
handleChoice(true);
};
const handleDisable = () => {
handleChoice(false);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
size="lg"
centered
zIndex={Z_ANALYTICS_MODAL}
>
<Stack gap="md">
<Title order={2}>{t('analytics.modal.title', 'Configure Analytics')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.modal.description', 'Choose whether to enable analytics for Stirling PDF. If enabled, users can control individual services (PostHog and Scarf) through the cookie preferences.')}
</Text>
<Paper p="md" withBorder>
<Stack gap="xs">
<Text fw={600} size="sm">
{t('analytics.modal.whatWeCollect', 'What we collect:')}
</Text>
<List size="sm" spacing="xs">
<List.Item>{t('analytics.modal.collect.system', 'Operating system and Java version')}</List.Item>
<List.Item>{t('analytics.modal.collect.config', 'CPU/memory configuration and deployment type')}</List.Item>
<List.Item>{t('analytics.modal.collect.features', 'Aggregate feature usage counts')}</List.Item>
<List.Item>{t('analytics.modal.collect.pages', 'Page visits (via tracking pixel)')}</List.Item>
</List>
</Stack>
</Paper>
<Paper p="md" withBorder>
<Stack gap="xs">
<Text fw={600} size="sm">
{t('analytics.modal.whatWeDoNotCollect', 'What we do NOT collect:')}
</Text>
<List size="sm" spacing="xs">
<List.Item>{t('analytics.modal.notCollect.documents', 'Document content or file data')}</List.Item>
<List.Item>{t('analytics.modal.notCollect.pii', 'Personally identifiable information (PII)')}</List.Item>
<List.Item>{t('analytics.modal.notCollect.ip', 'IP addresses')}</List.Item>
</List>
</Stack>
</Paper>
<Text size="sm" fs="italic">
{t('analytics.modal.privacy', 'All analytics data is hosted on EU servers and respects your privacy.')}
</Text>
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Stack gap="sm">
<Button
onClick={handleEnable}
loading={loading}
fullWidth
size="md"
>
{t('analytics.modal.enable', 'Enable Analytics')}
</Button>
<Button
onClick={handleDisable}
loading={loading}
fullWidth
size="md"
variant="subtle"
c="gray"
>
{t('analytics.modal.disable', 'Disable Analytics')}
</Button>
</Stack>
<Text size="xs" c="dimmed" ta="center">
{t('analytics.modal.note', 'This choice can be changed later by editing the settings.yml file.')}
</Text>
</Stack>
</Modal>
);
}

View File

@@ -10,7 +10,9 @@ export interface AppConfig {
languages?: string[];
enableLogin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
@@ -45,15 +47,16 @@ export function useAppConfig(): UseAppConfigReturn {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/config/app-config');
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
}
const data: AppConfig = await response.json();
setConfig(data);
console.warn('Fetched app config:', data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);

View File

@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '../constants/app';
import { useAppConfig } from './useAppConfig';
declare global {
interface Window {
CookieConsent: {
CookieConsent?: {
run: (config: any) => void;
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
};
}
}
@@ -15,8 +18,11 @@ interface CookieConsentConfig {
analyticsEnabled?: boolean;
}
export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConfig = {}) => {
export const useCookieConsent = ({
analyticsEnabled = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
@@ -30,7 +36,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 100);
return;
}
@@ -130,7 +136,24 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
necessary: {
readOnly: true
},
analytics: {}
analytics: {
services: {
...(config?.enablePosthog !== false && {
posthog: {
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
onAccept: () => console.log('PostHog service accepted'),
onReject: () => console.log('PostHog service rejected')
}
}),
...(config?.enableScarf !== false && {
scarf: {
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
onAccept: () => console.log('Scarf service accepted'),
onReject: () => console.log('Scarf service rejected')
}
})
}
}
},
language: {
default: "en",
@@ -184,7 +207,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
// Force show after initialization
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 200);
} catch (error) {
@@ -212,15 +235,23 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
document.head.removeChild(customCSS);
}
};
}, [analyticsEnabled, t]);
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent.show(true);
window.CookieConsent?.show(true);
}
};
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
return false;
}
return window.CookieConsent.acceptedService(service, category);
}, []);
return {
showCookiePreferences
showCookiePreferences,
isServiceAccepted
};
};

View File

@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { useAppConfig } from './useAppConfig';
import { useCookieConsent } from './useCookieConsent';
import { setScarfConfig, firePixel } from '../utils/scarfTracking';
/**
* Hook for initializing Scarf tracking
*
* This hook should be mounted once during app initialization (e.g., in index.tsx).
* It configures the scarf tracking utility with current config and consent state,
* and sets up event listeners to auto-fire pixels when consent is granted.
*
* After initialization, firePixel() can be called from anywhere in the app,
* including non-React utility functions like urlRouting.ts.
*/
export function useScarfTracking() {
const { config } = useAppConfig();
const { isServiceAccepted } = useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Update scarf config whenever config or consent changes
useEffect(() => {
console.warn('[useScarfTracking] Updating scarf config:', { enableScarf: config?.enableScarf, isServiceAccepted });
if (config && config.enableScarf !== undefined) {
setScarfConfig(config.enableScarf, isServiceAccepted);
}
}, [config?.enableScarf, isServiceAccepted]);
// Listen to cookie consent changes and auto-fire pixel when consent is granted
useEffect(() => {
const handleConsentChange = () => {
console.warn('[useScarfTracking] Consent changed, checking scarf service acceptance');
if (isServiceAccepted('scarf', 'analytics')) {
firePixel(window.location.pathname);
}
};
window.addEventListener('cc:onConsent', handleConsentChange);
window.addEventListener('cc:onChange', handleConsentChange);
return () => {
window.removeEventListener('cc:onConsent', handleConsentChange);
window.removeEventListener('cc:onChange', handleConsentChange);
};
}, [isServiceAccepted]);
}

View File

@@ -29,22 +29,21 @@ posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
defaults: '2025-05-24',
capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
debug: false,
opt_out_capturing_by_default: false, // We handle opt-out via cookie consent
opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent
});
function updatePosthogConsent(){
if(typeof(posthog) == "undefined") {
return;
}
const optIn = (window.CookieConsent as any).acceptedCategory('analytics');
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated analytics consent: ", optIn? "opted in" : "opted out");
if(typeof(posthog) == "undefined" || !posthog.__loaded) {
return;
}
const optIn = (window.CookieConsent as any)?.acceptedService?.('posthog', 'analytics') || false;
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out");
}
window.addEventListener("cc:onConsent", updatePosthogConsent);
window.addEventListener("cc:onChange", updatePosthogConsent);

View File

@@ -6,6 +6,7 @@ import { useSidebarContext } from "../contexts/SidebarContext";
import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { BASE_PATH, getBaseUrl } from "../constants/app";
import { useMediaQuery } from "@mantine/hooks";
import { useAppConfig } from "../hooks/useAppConfig";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "../components/tools/ToolPanel";
@@ -17,6 +18,7 @@ import LocalIcon from "../components/shared/LocalIcon";
import { useFilesModalContext } from "../contexts/FilesModalContext";
import AppConfigModal from "../components/shared/AppConfigModal";
import ToolPanelModePrompt from "../components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "../components/shared/AdminAnalyticsChoiceModal";
import "./HomePage.css";
@@ -42,11 +44,20 @@ export default function HomePage() {
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
const isMobile = useMediaQuery("(max-width: 1024px)");
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
if (config && config.enableAnalytics === null) {
setShowAnalyticsModal(true);
}
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
@@ -151,6 +162,7 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<AdminAnalyticsChoiceModal opened={showAnalyticsModal} />
<ToolPanelModePrompt />
{isMobile ? (
<div className="mobile-layout">

View File

@@ -3,7 +3,8 @@
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
export const Z_INDEX_AUTOMATE_MODAL = 1100;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
export const Z_ANALYTICS_MODAL = 1301;

View File

@@ -1,14 +1,74 @@
/**
* Scarf analytics pixel tracking utility
*
* This module provides a firePixel function that can be called from anywhere,
* including non-React utility functions. Configuration and consent state are
* injected via setScarfConfig() which should be called from a React hook
* during app initialization.
*
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
* The initialization hook (useScarfTracking) is mounted in App.tsx.
*
* For testing: Use resetScarfConfig() to clear module state between tests.
*/
// Module-level state
let configured: boolean = false;
let enableScarf: boolean | null = null;
let isServiceAccepted: ((service: string, category: string) => boolean) | null = null;
let lastFiredPathname: string | null = null;
let lastFiredTime = 0;
/**
* Configure scarf tracking with app config and consent checker
* Should be called from a React hook during app initialization (see useScarfTracking)
*
* @param scarfEnabled - Whether scarf tracking is enabled globally
* @param consentChecker - Function to check if user has accepted scarf service
*/
export function setScarfConfig(
scarfEnabled: boolean | null,
consentChecker: (service: string, category: string) => boolean
): void {
configured = true;
enableScarf = scarfEnabled;
isServiceAccepted = consentChecker;
}
/**
* Fire scarf pixel for analytics tracking
* Only fires if pathname is different from last call or enough time has passed
* Only fires if:
* - Scarf tracking has been initialized via setScarfConfig()
* - Scarf is globally enabled in config
* - User has accepted scarf service via cookie consent
* - Pathname has changed or enough time has passed since last fire
*
* @param pathname - The pathname to track (usually window.location.pathname)
*/
export function firePixel(pathname: string): void {
console.warn('[scarfTracking]Firing with Current config:', { enableScarf, isServiceAccepted });
// Dev-mode warning if called before initialization
if (!configured) {
console.warn(
'[scarfTracking] firePixel() called before setScarfConfig(). ' +
'Ensure useScarfTracking() hook is mounted in App.tsx.'
);
return;
}
// Check if Scarf is globally disabled
if (enableScarf === false) {
return;
}
// Check if consent checker is available and scarf service is accepted
if (!isServiceAccepted || !isServiceAccepted('scarf', 'analytics')) {
return;
}
const now = Date.now();
// Only fire if pathname changed or it's been at least 1 second since last fire
// Only fire if pathname changed or it's been at least 250ms since last fire
if (pathname === lastFiredPathname && now - lastFiredTime < 250) {
return;
}
@@ -22,5 +82,16 @@ export function firePixel(pathname: string): void {
const img = new Image();
img.referrerPolicy = "no-referrer-when-downgrade";
img.src = url;
console.warn('[scarfTracking] Firing pixel for', pathname);
}
/**
* Reset scarf tracking configuration and state
* Useful for testing to ensure clean state between test runs
*/
export function resetScarfConfig(): void {
enableScarf = null;
isServiceAccepted = null;
lastFiredPathname = null;
lastFiredTime = 0;
}