mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +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:
parent
2e932f30bf
commit
960d48f80c
@ -355,6 +355,8 @@ public class ApplicationProperties {
|
||||
private String tessdataDir;
|
||||
private Boolean enableAlphaFunctionality;
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Datasource datasource;
|
||||
private Boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
@ -368,6 +370,18 @@ public class ApplicationProperties {
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
}
|
||||
|
||||
public boolean isPosthogEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
|
||||
}
|
||||
|
||||
public boolean isScarfEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnableScarf() == null || this.getEnableScarf());
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@ -56,7 +56,7 @@ public class PostHogService {
|
||||
}
|
||||
|
||||
private void captureSystemInfo() {
|
||||
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
|
||||
if (!applicationProperties.getSystem().isPosthogEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -67,7 +67,7 @@ public class PostHogService {
|
||||
}
|
||||
|
||||
public void captureEvent(String eventName, Map<String, Object> properties) {
|
||||
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
|
||||
if (!applicationProperties.getSystem().isPosthogEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -325,6 +325,14 @@ public class PostHogService {
|
||||
properties,
|
||||
"system_enableAnalytics",
|
||||
applicationProperties.getSystem().isAnalyticsEnabled());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enablePosthog",
|
||||
applicationProperties.getSystem().isPosthogEnabled());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableScarf",
|
||||
applicationProperties.getSystem().isScarfEnabled());
|
||||
|
||||
// Capture UI properties
|
||||
addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName());
|
||||
|
||||
@ -6,7 +6,7 @@ import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
@ -29,7 +29,7 @@ public class SettingsController {
|
||||
|
||||
@AutoJobPostMapping("/update-enable-analytics")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
|
||||
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
|
||||
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
|
||||
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
|
||||
.body(
|
||||
|
||||
@ -65,6 +65,8 @@ public class ConfigController {
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
configData.put(
|
||||
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
|
||||
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
|
||||
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
@ -120,7 +120,9 @@ system:
|
||||
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
|
||||
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
|
||||
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
|
||||
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
|
||||
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
|
||||
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
|
||||
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
|
||||
42
frontend/package-lock.json
generated
42
frontend/package-lock.json
generated
@ -436,7 +436,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@ -483,7 +482,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@ -507,7 +505,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz",
|
||||
"integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.3.14",
|
||||
"@embedpdf/models": "1.3.14"
|
||||
@ -588,7 +585,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz",
|
||||
"integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@ -605,7 +601,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz",
|
||||
"integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@ -622,7 +617,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz",
|
||||
"integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@ -657,7 +651,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz",
|
||||
"integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@ -690,7 +683,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz",
|
||||
"integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@ -725,7 +717,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz",
|
||||
"integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@ -797,7 +788,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz",
|
||||
"integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
},
|
||||
@ -951,7 +941,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@ -995,7 +984,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@ -2029,7 +2017,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.1.tgz",
|
||||
"integrity": "sha512-OYfxn9cTv+K6RZ8+Ozn/HDQXkB8Fmn+KJJt5lxyFDP9F09EHnC59Ldadv1LyUZVBGtNqz4sn6b3vBShbxwAmYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"clsx": "^2.1.1",
|
||||
@ -2080,7 +2067,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.1.tgz",
|
||||
"integrity": "sha512-lQutBS+Q0iz/cNFvdrsYassPWo3RtWcmDGJeOtKfHigLzFOhxUuLOkQgepDbMf3WcVMB/tist6Px1PQOv57JTw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
@ -2148,7 +2134,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.2.tgz",
|
||||
"integrity": "sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.3",
|
||||
"@mui/core-downloads-tracker": "^7.3.2",
|
||||
@ -3591,7 +3576,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@ -3915,7 +3899,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@ -3926,7 +3909,6 @@
|
||||
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
@ -3987,7 +3969,6 @@
|
||||
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.44.1",
|
||||
"@typescript-eslint/types": "8.44.1",
|
||||
@ -4701,6 +4682,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz",
|
||||
"integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.21"
|
||||
}
|
||||
@ -4710,6 +4692,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz",
|
||||
"integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.21",
|
||||
"@vue/shared": "3.5.21"
|
||||
@ -4720,6 +4703,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz",
|
||||
"integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.21",
|
||||
"@vue/runtime-core": "3.5.21",
|
||||
@ -4732,6 +4716,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz",
|
||||
"integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.21",
|
||||
"@vue/shared": "3.5.21"
|
||||
@ -4759,7 +4744,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -5434,7 +5418,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@ -6438,8 +6421,7 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
|
||||
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@ -6834,7 +6816,6 @@
|
||||
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -7006,7 +6987,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@ -8280,7 +8260,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@ -9077,7 +9056,6 @@
|
||||
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@asamuzakjp/dom-selector": "^6.5.4",
|
||||
"cssstyle": "^5.3.0",
|
||||
@ -10866,7 +10844,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -11158,7 +11135,6 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@ -11531,7 +11507,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -11541,7 +11516,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@ -13179,7 +13153,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -13462,7 +13435,6 @@
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -13545,7 +13517,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"napi-postinstall": "^0.3.0"
|
||||
},
|
||||
@ -13765,7 +13736,6 @@
|
||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@ -13897,7 +13867,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -13911,7 +13880,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
|
||||
@ -250,6 +250,7 @@
|
||||
"title": "Do you want make Stirling PDF better?",
|
||||
"paragraph1": "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.",
|
||||
"paragraph2": "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.",
|
||||
"learnMore": "Learn more",
|
||||
"enable": "Enable analytics",
|
||||
"disable": "Disable analytics",
|
||||
"settings": "You can change the settings for analytics in the config/settings.yml file"
|
||||
@ -3371,6 +3372,10 @@
|
||||
"title": "Analytics",
|
||||
"description": "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"posthog": "PostHog Analytics",
|
||||
"scarf": "Scarf Pixel"
|
||||
}
|
||||
},
|
||||
"removeMetadata": {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user