Skip to content

Commit 78565ac

Browse files
committed
feat: Analytics tracker hook
1 parent f5482cf commit 78565ac

File tree

6 files changed

+216
-17
lines changed

6 files changed

+216
-17
lines changed

index.html

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,18 @@
1616
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@600&display=swap" rel="stylesheet">
1717
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@700&display=swap" rel="stylesheet">
1818

19-
<!-- Prevent print shortcut -->
2019
<script>
2120
window.onkeydown = function (e) {
2221
if (e.code === "KeyP" && e.ctrlKey) {
2322
return false;
24-
}
25-
// on cmd + R, reload the page
26-
if (e.code === "KeyR" && e.metaKey) {
23+
} else if (e.code === "KeyR" && e.metaKey) {
2724
window.location.reload();
2825
return false;
29-
}
30-
if (e.code === "F3") return false;
26+
} else if (e.code === "F3") return false;
3127
};
28+
document.addEventListener("contextmenu", (event) =>
29+
event.preventDefault()
30+
);
3231
</script>
3332
<script>
3433
const global = globalThis;
@@ -44,12 +43,6 @@
4443
</head>
4544
<body>
4645
<div id="root"></div>
47-
<script>
48-
// Disable right-click context menu
49-
document.addEventListener("contextmenu", (event) =>
50-
event.preventDefault()
51-
);
52-
</script>
5346
<script type="module" src="/src/main.tsx"></script>
5447
</body>
5548
</html>

src-tauri/tauri.conf.json

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@
5656
"dangerousDisableAssetCspModification": ["style-src"],
5757
"csp": {
5858
"connect-src": [
59+
"https://www.google-analytics.com",
60+
"https://region1.google-analytics.com",
61+
"https://www.googletagmanager.com",
5962
"ipc:",
6063
"http://ipc.localhost",
6164
"http://asset.localhost",
@@ -79,8 +82,34 @@
7982
"asset:"
8083
],
8184
"worker-src": ["'self'", "blob:", "https://unpkg.com"],
82-
"script-src": ["'self'", "'unsafe-inline'"],
83-
"style-src": ["'self'", "'unsafe-inline'"]
85+
"script-src": [
86+
"'self'",
87+
"'unsafe-inline'",
88+
"'unsafe-eval'",
89+
"https://www.googletagmanager.com",
90+
"https://www.google-analytics.com"
91+
],
92+
"style-src": [
93+
"'self'",
94+
"'unsafe-inline'",
95+
"https://fonts.googleapis.com"
96+
],
97+
"font-src": [
98+
"'self'",
99+
"data:",
100+
"https://fonts.gstatic.com"
101+
],
102+
"img-src": [
103+
"'self'",
104+
"data:",
105+
"blob:",
106+
"https://www.google-analytics.com",
107+
"https://www.googletagmanager.com",
108+
"https://i0.wp.com",
109+
"https://github.blog",
110+
"https://avatars.githubusercontent.com",
111+
"https:"
112+
]
84113
}
85114
},
86115
"windows": []

src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Sidebar } from "./components/Sidebar";
2222
import { APP_CONFIG } from "./constants/app";
2323
import { sidebarTools } from "./constants/sidebar";
2424
import { tools } from "./constants/tools";
25+
import { useAnalytics } from "./hooks/useAnalytics";
2526
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
2627
import { useRouteTransition } from "./hooks/useRouteAnim";
2728
import { insertTauriDragRegion } from "./utils/dragRegion";
@@ -33,6 +34,9 @@ function App() {
3334
const location = useLocation();
3435
const navigate = useNavigate();
3536

37+
// Analytics Tracker
38+
useAnalytics();
39+
3640
// Route transition animation state
3741
const { animation, routeLocation, setRouteAnimation } = useRouteTransition();
3842

@@ -98,7 +102,6 @@ function App() {
98102

99103
init();
100104
}, []);
101-
102105
useEffect(() => {
103106
// This is necessary for Tauri to allow dragging the window
104107
insertTauriDragRegion();

src/constants/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,9 @@ export const APP_CONFIG = {
2727
},
2828
},
2929
},
30+
GA: {
31+
MEASUREMENT_ID: import.meta.env.VITE_GA_MEASUREMENT_ID,
32+
FORCE_BEACON: import.meta.env.VITE_GA_FORCE_BEACON,
33+
DEBUG: import.meta.env.VITE_GA_DEBUG,
34+
},
3035
} as const;

src/hooks/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
export { useAnalytics } from "./useAnalytics";
12
export { useFile } from "./useFile";
2-
export { useRouteTransition } from "./useRouteAnim";
33
export { useKeyboardShortcuts } from "./useKeyboardShortcuts";
4-
export { useSidebarState } from "./useSidebarState";
4+
export { useRouteTransition } from "./useRouteAnim";
55
export { useSidebarShortcuts } from "./useSidebarShortcuts";
6+
export { useSidebarState } from "./useSidebarState";

src/hooks/useAnalytics.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { APP_CONFIG } from "@/constants/app";
2+
import { tools } from "@/constants/tools";
3+
import { isTauri } from "@/utils/isTauri";
4+
import { useEffect, useRef } from "react";
5+
import { useLocation } from "react-router-dom";
6+
7+
function uuidv4() {
8+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
9+
const r = (Math.random() * 16) | 0;
10+
const v = c === "x" ? r : (r & 0x3) | 0x8;
11+
return v.toString(16);
12+
});
13+
}
14+
15+
function getClientId(): string {
16+
try {
17+
const key = "devbox:ga_cid";
18+
const existing = localStorage.getItem(key);
19+
if (existing) return existing;
20+
const cid = uuidv4();
21+
localStorage.setItem(key, cid);
22+
return cid;
23+
} catch {
24+
return uuidv4();
25+
}
26+
}
27+
28+
declare global {
29+
interface Window {
30+
dataLayer?: any[];
31+
gtag?: (...args: any[]) => void;
32+
}
33+
}
34+
35+
export function useAnalytics() {
36+
const location = useLocation();
37+
const readyRef = useRef(false);
38+
const lastPathRef = useRef<string>("");
39+
const measurementId = APP_CONFIG.GA.MEASUREMENT_ID;
40+
const FORCE_BEACON = APP_CONFIG.GA.FORCE_BEACON === "1";
41+
const DEBUG = APP_CONFIG.GA.DEBUG === "1";
42+
const sessionIdRef = useRef<number>(Math.floor(Date.now() / 1000));
43+
const sessionCountRef = useRef<number>(1);
44+
45+
useEffect(() => {
46+
if (!measurementId || readyRef.current) return;
47+
if (!window.dataLayer) window.dataLayer = [];
48+
window.gtag = function gtag() {
49+
window.dataLayer!.push(arguments as any);
50+
};
51+
52+
const script = document.createElement("script");
53+
script.async = true;
54+
script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
55+
script.onload = () => {
56+
const platform = isTauri() ? "devbox_app" : "webapp";
57+
window.gtag!("js", new Date());
58+
window.gtag!("config", measurementId, {
59+
send_page_view: false,
60+
app_name: "Devbox",
61+
platform,
62+
});
63+
readyRef.current = true;
64+
console.log("GA manual initialized", { measurementId, platform, FORCE_BEACON, DEBUG });
65+
sendPageView(location.pathname, true);
66+
};
67+
script.onerror = () => console.warn("GA script failed to load");
68+
document.head.appendChild(script);
69+
}, [measurementId]);
70+
71+
useEffect(() => {
72+
if (!readyRef.current) return;
73+
sendPageView(location.pathname);
74+
}, [location.pathname]);
75+
76+
function sendPageView(path: string, initial = false) {
77+
if (!measurementId) return;
78+
if (lastPathRef.current === path && !initial) return;
79+
lastPathRef.current = path;
80+
const cid = getClientId();
81+
let appName = tools.find(tool => tool.path === path)?.text;
82+
if (path === "/dashboard") {
83+
appName = "Dashboard";
84+
} else if (path === "/settings") {
85+
appName = "Settings";
86+
}
87+
88+
const pageLocation = isTauri() ? `tauri:/${path}` : window.location.href;
89+
const params: Record<string, any> = {
90+
page_location: pageLocation,
91+
page_path: path,
92+
page_title: appName || path,
93+
sid: sessionIdRef.current,
94+
sct: sessionCountRef.current,
95+
debug_mode: DEBUG ? 1 : undefined,
96+
};
97+
try {
98+
window.gtag && window.gtag("event", "page_view", params);
99+
console.debug("GA page_view", params);
100+
if (FORCE_BEACON) {
101+
beaconFallback(measurementId, params, cid, true);
102+
}
103+
} catch (e) {
104+
console.warn("GA gtag send failed, fallback", e);
105+
beaconFallback(measurementId, params, cid, true);
106+
}
107+
108+
// If running under a non-http(s) custom protocol (tauri:// / asset://), CORS blocks fetch/Beacon.
109+
// Always send an image pixel which bypasses CORS preflight.
110+
if (!/^https?:/.test(window.location.protocol)) {
111+
imageFallback(measurementId, params, cid, DEBUG);
112+
}
113+
}
114+
}
115+
116+
function beaconFallback(id: string, params: Record<string, any>, cid: string, verbose = false) {
117+
try {
118+
const endpoint = "https://www.google-analytics.com/g/collect";
119+
const raw: Record<string, string | undefined> = {
120+
v: "2",
121+
tid: id,
122+
cid,
123+
en: "page_view",
124+
dl: String(params.page_location || ""),
125+
dt: String(params.page_title || ""),
126+
sid: params.sid?.toString() || Math.floor(Date.now() / 1000).toString(),
127+
sct: params.sct?.toString() || "1",
128+
_dbg: params.debug_mode ? "1" : undefined,
129+
};
130+
const filtered = Object.fromEntries(
131+
Object.entries(raw).filter(([, v]) => typeof v === "string" && v.length > 0)
132+
) as Record<string, string>;
133+
const search = new URLSearchParams(filtered);
134+
const url = `${endpoint}?${search.toString()}`;
135+
if (navigator.sendBeacon) navigator.sendBeacon(url);
136+
else fetch(url, { method: "GET", mode: "no-cors" }).catch(() => {});
137+
if (verbose) console.debug("GA beacon sent", url);
138+
} catch (e) {
139+
console.warn("GA beacon fallback failed", e);
140+
}
141+
}
142+
143+
function imageFallback(id: string, params: Record<string, any>, cid: string, debug = false) {
144+
try {
145+
const endpoint = "https://www.google-analytics.com/g/collect";
146+
const data: Record<string, string> = {
147+
v: "2",
148+
tid: id,
149+
cid,
150+
en: "page_view",
151+
dl: String(params.page_location || ""),
152+
dt: String(params.page_title || ""),
153+
sid: (params.sid || Math.floor(Date.now() / 1000)).toString(),
154+
sct: (params.sct || 1).toString(),
155+
_r: "1",
156+
_s: "1",
157+
_et: "0",
158+
};
159+
if (debug) data._dbg = "1";
160+
const qs = new URLSearchParams(data).toString();
161+
const img = new Image();
162+
img.referrerPolicy = "no-referrer";
163+
img.src = `${endpoint}?${qs}&z=${Math.random().toString(36).slice(2)}`;
164+
if (debug) console.debug("GA image fallback sent", img.src);
165+
} catch (e) {
166+
console.warn("GA image fallback failed", e);
167+
}
168+
}

0 commit comments

Comments
 (0)