mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
Cookie changes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
127
frontend/src/components/shared/AdminAnalyticsChoiceModal.tsx
Normal file
127
frontend/src/components/shared/AdminAnalyticsChoiceModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
45
frontend/src/hooks/useScarfTracking.ts
Normal file
45
frontend/src/hooks/useScarfTracking.ts
Normal 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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user