mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Customised Analytics for admins and users (#4687)
Adds granular privacy controls for analytics - splits single
enableAnalytics toggle into separate PostHog and Scarf controls with
improved admin
UX.
Backend Changes
Configuration (ApplicationProperties.java)
- Added enablePosthog and enableScarf boolean fields
- New methods: isPosthogEnabled(), isScarfEnabled() (null = enabled when
analytics is on)
Services
- PostHogService: Now checks isPosthogEnabled() instead of
isAnalyticsEnabled()
- ConfigController: Exposes new flags via API
- SettingsController: Changed endpoint from @RequestBody to
@RequestParam
Frontend Changes
Architecture
- Converted useAppConfig hook → AppConfigContext provider for global
access
- Added refetch() method for config updates without reload
New Features
1. AdminAnalyticsChoiceModal: First-launch modal when enableAnalytics
=== null
- Enable/disable without editing YAML
- Includes documentation link
2. Scarf Tracking System: Modular utility with React hook wrapper
- Respects config + per-service cookie consent
- Works from any code location (React or vanilla JS)
3. Enhanced Cookie Consent: Per-service toggles (PostHog and Scarf
separate)
Integration
- App.tsx: Added AppConfigProvider + scarf initializer
- HomePage.tsx: Shows admin modal when needed
- index.tsx: PostHog opt-out by default, service-level consent
Key Benefits
✅ Backward compatible (null defaults to enabled)
✅ Granular control per analytics service
✅ First-launch admin modal (no YAML editing)
✅ Privacy-focused with opt-out defaults
✅ API-based config updates
---------
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
@@ -9,10 +9,12 @@ import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||
import { AppConfigProvider } from "./contexts/AppConfigContext";
|
||||
import { OnboardingProvider } from "./contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import OnboardingTour from "./components/onboarding/OnboardingTour";
|
||||
import { useScarfTracking } from "./hooks/useScarfTracking";
|
||||
|
||||
// Import auth components
|
||||
import { AuthProvider } from "./auth/UseSession";
|
||||
@@ -48,6 +50,12 @@ const LoadingFallback = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
useScarfTracking();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
@@ -66,30 +74,33 @@ export default function App() {
|
||||
path="/*"
|
||||
element={
|
||||
<OnboardingProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<Landing />
|
||||
<OnboardingTour />
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
<AppConfigProvider>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<Landing />
|
||||
<OnboardingTour />
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useFileState } from '../../contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '../../types/workbench';
|
||||
import { useViewer } from '../../contexts/ViewerContext';
|
||||
import { useAppConfig } from '../../contexts/AppConfigContext';
|
||||
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();
|
||||
@@ -188,7 +190,7 @@ export default function Workbench() {
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
<Footer analyticsEnabled />
|
||||
<Footer analyticsEnabled={config?.enableAnalytics === true} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
112
frontend/src/components/shared/AdminAnalyticsChoiceModal.tsx
Normal file
112
frontend/src/components/shared/AdminAnalyticsChoiceModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { Z_ANALYTICS_MODAL } from '../../styles/zIndex';
|
||||
import { useAppConfig } from '../../contexts/AppConfigContext';
|
||||
import apiClient from '../../services/apiClient';
|
||||
|
||||
interface AdminAnalyticsChoiceModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { refetch } = useAppConfig();
|
||||
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);
|
||||
|
||||
// Refetch config to apply new settings without page reload
|
||||
await refetch();
|
||||
|
||||
// Close the modal after successful save
|
||||
onClose();
|
||||
} 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.title', 'Do you want make Stirling PDF better?')}</Title>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/analytics-telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{t('analytics.learnMore', 'Learn more')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
>
|
||||
{t('analytics.enable', 'Enable analytics')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleDisable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
>
|
||||
{t('analytics.disable', 'Disable analytics')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import { useAppConfig } from '../../contexts/AppConfigContext';
|
||||
import { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core';
|
||||
import { useAppConfig } from '../../../../hooks/useAppConfig';
|
||||
import { useAppConfig } from '../../../../contexts/AppConfigContext';
|
||||
import { useAuth } from '../../../../auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -125,4 +125,4 @@ const Overview: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
export default Overview;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Stack, Button } from "@mantine/core";
|
||||
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
|
||||
import { useAppConfig } from "../../../hooks/useAppConfig";
|
||||
import { useAppConfig } from "../../../contexts/AppConfigContext";
|
||||
|
||||
interface CertificateTypeSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
// Helper to get JWT from localStorage for Authorization header
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
@@ -16,7 +16,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;
|
||||
@@ -32,17 +34,21 @@ export interface AppConfig {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UseAppConfigReturn {
|
||||
interface AppConfigContextValue {
|
||||
config: AppConfig | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Create context
|
||||
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Custom hook to fetch and manage application configuration
|
||||
* Provider component that fetches and provides app configuration
|
||||
* Should be placed at the top level of the app, before any components that need config
|
||||
*/
|
||||
export function useAppConfig(): UseAppConfigReturn {
|
||||
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -59,13 +65,13 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const data: AppConfig = await response.json();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
setError(errorMessage);
|
||||
console.error('Failed to fetch app config:', err);
|
||||
console.error('[AppConfig] Failed to fetch app config:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -75,11 +81,31 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
const value: AppConfigContextValue = {
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
<AppConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</AppConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access application configuration
|
||||
* Must be used within AppConfigProvider
|
||||
*/
|
||||
export function useAppConfig(): AppConfigContextValue {
|
||||
const context = useContext(AppConfigContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAppConfig must be used within AppConfigProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAppConfig } from './useAppConfig';
|
||||
import { useAppConfig } from '../contexts/AppConfigContext'
|
||||
|
||||
export const useBaseUrl = (): string => {
|
||||
const { config } = useAppConfig();
|
||||
|
||||
@@ -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 '../contexts/AppConfigContext';
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
44
frontend/src/hooks/useScarfTracking.ts
Normal file
44
frontend/src/hooks/useScarfTracking.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppConfig } from '../contexts/AppConfigContext';
|
||||
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(() => {
|
||||
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]);
|
||||
}
|
||||
@@ -17,22 +17,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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDocumentMeta } from "../hooks/useDocumentMeta";
|
||||
import { BASE_PATH } from "../constants/app";
|
||||
import { useBaseUrl } from "../hooks/useBaseUrl";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useAppConfig } from "../contexts/AppConfigContext";
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
|
||||
import ToolPanel from "../components/tools/ToolPanel";
|
||||
@@ -18,6 +19,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";
|
||||
|
||||
@@ -43,11 +45,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${
|
||||
@@ -152,6 +163,10 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<AdminAnalyticsChoiceModal
|
||||
opened={showAnalyticsModal}
|
||||
onClose={() => setShowAnalyticsModal(false)}
|
||||
/>
|
||||
<ToolPanelModePrompt />
|
||||
{isMobile ? (
|
||||
<div className="mobile-layout">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/UseSession';
|
||||
import { useAppConfig } from '../hooks/useAppConfig';
|
||||
import HomePage from '../pages/HomePage';
|
||||
import Login from './Login';
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/UseSession'
|
||||
import { useAppConfig } from '../contexts/AppConfigContext'
|
||||
import HomePage from '../pages/HomePage'
|
||||
import Login from './Login'
|
||||
|
||||
/**
|
||||
* Landing component - Smart router based on authentication status
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
|
||||
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
|
||||
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
|
||||
export const Z_ANALYTICS_MODAL = 1301;
|
||||
|
||||
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
|
||||
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
|
||||
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
|
||||
|
||||
export const Z_INDEX_AUTOMATE_MODAL = 1100;
|
||||
|
||||
@@ -1,14 +1,73 @@
|
||||
/**
|
||||
* 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 {
|
||||
// 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;
|
||||
}
|
||||
@@ -24,3 +83,13 @@ export function firePixel(pathname: string): void {
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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