diff --git a/src/routes/onboarding/index.tsx b/src/routes/onboarding/index.tsx index 8d35309f..3930a14a 100644 --- a/src/routes/onboarding/index.tsx +++ b/src/routes/onboarding/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useWizard, Wizard } from "react-use-wizard"; import clsx from "clsx"; @@ -14,6 +14,7 @@ import SelectLanguage from "../../components/Language"; import { useGetAutoTunnelInfoQuery, useGetServerVersionQuery } from "@/app/services/server"; import { useAppSelector } from "@/app/store"; import { compareVersion } from "@/utils"; +import { trackUmamiEvent } from "@/utils/umami"; import { shallowEqual } from "react-redux"; const TUNNEL_MIN_VERSION = "0.5.19"; @@ -58,6 +59,12 @@ const Navigator = ({ showTunnelStep }: { showTunnelStep: boolean }) => { export default function OnboardingPage() { const { t } = useTranslation("welcome"); const [serverName, setServerName] = useState(""); + + // Load Umami and fire "installed" once when the onboarding page mounts. + // forceLoad=true so the script is fetched here and only here. + useEffect(() => { + trackUmamiEvent("installed"); + }, []); const { isLoading: versionLoading } = useGetServerVersionQuery(); const currentVersion = useAppSelector((store) => store.server.version, shallowEqual); const versionOk = !!currentVersion && compareVersion(currentVersion, TUNNEL_MIN_VERSION) >= 0; diff --git a/src/routes/onboarding/steps/get-public-domain.tsx b/src/routes/onboarding/steps/get-public-domain.tsx index e06a626d..9e7a9011 100644 --- a/src/routes/onboarding/steps/get-public-domain.tsx +++ b/src/routes/onboarding/steps/get-public-domain.tsx @@ -8,6 +8,7 @@ import { BASE_ORIGIN, tokenHeader } from "@/app/config"; import { getLocalAuthData, compareVersion } from "@/utils"; import { useAppSelector } from "@/app/store"; import { useGetAutoTunnelInfoQuery } from "@/app/services/server"; +import { trackUmamiEvent } from "@/utils/umami"; const REQUIRED_VERSION = "0.5.19"; @@ -66,6 +67,7 @@ export default function GetPublicDomain() { } const handleYes = async () => { + trackUmamiEvent("create_tunnel"); setPhase("starting"); setLogs([]); setErrorMsg(null); diff --git a/src/routes/setting/Cloudflared/index.tsx b/src/routes/setting/Cloudflared/index.tsx index d45832e9..6e96fa41 100644 --- a/src/routes/setting/Cloudflared/index.tsx +++ b/src/routes/setting/Cloudflared/index.tsx @@ -9,6 +9,7 @@ import ServerVersionChecker from "@/components/ServerVersionChecker"; import Button from "@/components/styled/Button"; import { getLocalAuthData, compareVersion } from "@/utils"; import { useAppSelector } from "@/app/store"; +import { trackUmamiEvent } from "@/utils/umami"; const TUNNEL_MIN_VERSION = "0.5.19"; @@ -80,6 +81,7 @@ export default function Cloudflared() { const startTunnel = async () => { if (streaming) return; + trackUmamiEvent("create_tunnel"); setStreaming(true); setStatus("starting"); setLogs([]); diff --git a/src/utils/umami.ts b/src/utils/umami.ts new file mode 100644 index 00000000..08122cc1 --- /dev/null +++ b/src/utils/umami.ts @@ -0,0 +1,68 @@ +/** + * Umami analytics — lazy loader. + * + * The script is injected into at most once per page lifetime, + * loaded on demand the first time trackUmamiEvent() is called. + * Automatic pageview tracking is disabled; all events are explicit. + */ + +const UMAMI_SCRIPT_URL = "https://install.voce.chat/script.js"; +const UMAMI_WEBSITE_ID = "121f1469-bdce-4326-8fa3-7b820837c916"; + +type UmamiWindow = Window & { umami?: { track: (event: string, data?: Record) => void } }; + +let loaded = false; +let loading = false; +const queue: Array<() => void> = []; + +function loadScript(): Promise { + return new Promise((resolve) => { + if (loaded) { + resolve(); + return; + } + + queue.push(resolve); + + if (loading) return; + loading = true; + + const script = document.createElement("script"); + script.async = true; + script.defer = true; + script.src = UMAMI_SCRIPT_URL; + script.setAttribute("data-website-id", UMAMI_WEBSITE_ID); + script.setAttribute("data-auto-track", "false"); // disable automatic pageview tracking + script.onload = () => { + loaded = true; + loading = false; + queue.splice(0).forEach((cb) => cb()); + }; + script.onerror = () => { + // Don't block callers on network failure; just drain the queue + loading = false; + queue.splice(0).forEach((cb) => cb()); + }; + document.head.appendChild(script); + }); +} + +/** + * Load the Umami script (if not already loaded) then fire a named event. + * The script is fetched on first call and reused for all subsequent calls. + * Safe to call from anywhere — silently no-ops if the script fails to load. + * + * @param eventName The event name shown in the Umami dashboard. + * @param data Optional key/value payload. + */ +export async function trackUmamiEvent( + eventName: string, + data?: Record +): Promise { + await loadScript(); + + const umami = (window as UmamiWindow).umami; + if (umami?.track) { + umami.track(eventName, data); + } +}