diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index af6e007f..9754396c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,9 +1,12 @@ -import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react"; +import { APP_VERSION } from "@ashim/shared"; +import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Toaster } from "sonner"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { useAuth } from "./hooks/use-auth"; +import { identify, initAnalytics } from "./lib/analytics"; +import { useAnalyticsStore } from "./stores/analytics-store"; // Lazy-load all pages so each page's JS (and its icons/deps) is only // downloaded when the user navigates there, shrinking the main bundle. @@ -129,6 +132,34 @@ function PageLoader() { } export function App() { + const analyticsConfig = useAnalyticsStore((s) => s.config); + const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded); + const fetchAnalyticsConfig = useAnalyticsStore((s) => s.fetchConfig); + const analyticsConsent = useAnalyticsStore((s) => s.consent); + + useEffect(() => { + fetchAnalyticsConfig(); + }, [fetchAnalyticsConfig]); + + useEffect(() => { + if (analyticsConfigLoaded && analyticsConfig?.enabled) { + initAnalytics(analyticsConfig); + } + }, [analyticsConfigLoaded, analyticsConfig]); + + useEffect(() => { + if ( + !analyticsConfigLoaded || + !analyticsConfig?.enabled || + analyticsConsent.analyticsEnabled !== true + ) + return; + identify(analyticsConfig.instanceId, { + $set: { version: APP_VERSION }, + $set_once: { instance_id: analyticsConfig.instanceId }, + }); + }, [analyticsConfigLoaded, analyticsConfig, analyticsConsent.analyticsEnabled]); + return ( diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 87da6236..4eb17fde 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -30,6 +30,7 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { cn, copyToClipboard } from "@/lib/utils"; +import { useAnalyticsStore } from "@/stores/analytics-store"; import { useSettingsStore } from "@/stores/settings-store"; import { GemLogo } from "../common/gem-logo"; import { AiFeaturesSection } from "./ai-features-section"; @@ -50,6 +51,7 @@ type Section = | "api-keys" | "ai-features" | "tools" + | "analytics" | "about"; interface NavItem { @@ -70,6 +72,7 @@ const NAV_ITEMS: NavItem[] = [ { id: "api-keys", label: "API Keys", icon: Key }, { id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" }, { id: "tools", label: "Tools", icon: Wrench }, + { id: "analytics", label: "Product Analytics", icon: Eye }, { id: "about", label: "About", icon: Info }, ]; @@ -147,6 +150,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { {section === "api-keys" && } {section === "ai-features" && } {section === "tools" && } + {section === "analytics" && } {section === "about" && } @@ -2428,6 +2432,69 @@ function ToolsSection() { ); } +/* ────────────────────── Analytics ────────────────────── */ + +function AnalyticsSection() { + const { consent, config, configLoaded, fetchConfig, toggleAnalytics } = useAnalyticsStore(); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + if (!configLoaded) return null; + + const disabled = !config?.enabled; + const enabled = consent.analyticsEnabled === true; + + return ( +
+
+

Product Analytics

+

+ Share anonymous usage data to help improve ashim. +

+

Your images never leave your machine.

+
+ + {disabled ? ( +

+ Product analytics has been disabled by the server administrator. +

+ ) : ( +
+ + {enabled ? "Analytics enabled" : "Analytics disabled"} + + +
+ )} + + + Learn more + +
+ ); +} + /* ────────────────────── About ────────────────────── */ function AboutSection() { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6d9dd242..1b5d0789 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -60,6 +60,16 @@ export function formatHeaders(init?: HeadersInit): Headers { if (token) { headers.set("Authorization", `Bearer ${token}`); } + if (!token) { + try { + const consent = localStorage.getItem("ashim-analytics-consent"); + if (consent === "true" || consent === "false") { + headers.set("X-Analytics-Consent", consent); + } + } catch { + // localStorage unavailable + } + } return headers; } diff --git a/apps/web/src/pages/fullscreen-grid-page.tsx b/apps/web/src/pages/fullscreen-grid-page.tsx index 3eacafa8..11090a76 100644 --- a/apps/web/src/pages/fullscreen-grid-page.tsx +++ b/apps/web/src/pages/fullscreen-grid-page.tsx @@ -1,9 +1,10 @@ import type { CategoryInfo, Tool } from "@ashim/shared"; -import { CATEGORIES, TOOLS } from "@ashim/shared"; +import { ANALYTICS_EVENTS, CATEGORIES, TOOLS } from "@ashim/shared"; import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { GemLogo } from "@/components/common/gem-logo"; +import { track } from "@/lib/analytics"; import { apiGet } from "@/lib/api"; import { ICON_MAP } from "@/lib/icon-map"; import { cn } from "@/lib/utils"; @@ -51,6 +52,17 @@ export function FullscreenGridPage() { ); }, [search, visibleTools]); + useEffect(() => { + if (!search) return; + const timer = setTimeout(() => { + track(ANALYTICS_EVENTS.SEARCH, { + query: search, + results_count: filteredTools.length, + }); + }, 1000); + return () => clearTimeout(timer); + }, [search, filteredTools.length]); + const groupedTools = useMemo(() => { const groups = new Map(); for (const tool of filteredTools) {