mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add analytics init, identify, search tracking, consent header, settings toggle
This commit is contained in:
+32
-1
@@ -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 { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
import { ConnectionMonitor } from "./components/common/connection-monitor";
|
||||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||||
import { useAuth } from "./hooks/use-auth";
|
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
|
// 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.
|
// downloaded when the user navigates there, shrinking the main bundle.
|
||||||
@@ -129,6 +132,34 @@ function PageLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
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 (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConnectionMonitor />
|
<ConnectionMonitor />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
|
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
|
||||||
import { cn, copyToClipboard } from "@/lib/utils";
|
import { cn, copyToClipboard } from "@/lib/utils";
|
||||||
|
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { GemLogo } from "../common/gem-logo";
|
import { GemLogo } from "../common/gem-logo";
|
||||||
import { AiFeaturesSection } from "./ai-features-section";
|
import { AiFeaturesSection } from "./ai-features-section";
|
||||||
@@ -50,6 +51,7 @@ type Section =
|
|||||||
| "api-keys"
|
| "api-keys"
|
||||||
| "ai-features"
|
| "ai-features"
|
||||||
| "tools"
|
| "tools"
|
||||||
|
| "analytics"
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -70,6 +72,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||||
{ id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" },
|
{ id: "ai-features", label: "AI Features", icon: Sparkles, requiredPermission: "settings:write" },
|
||||||
{ id: "tools", label: "Tools", icon: Wrench },
|
{ id: "tools", label: "Tools", icon: Wrench },
|
||||||
|
{ id: "analytics", label: "Product Analytics", icon: Eye },
|
||||||
{ id: "about", label: "About", icon: Info },
|
{ id: "about", label: "About", icon: Info },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -147,6 +150,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|||||||
{section === "api-keys" && <ApiKeysSection />}
|
{section === "api-keys" && <ApiKeysSection />}
|
||||||
{section === "ai-features" && <AiFeaturesSection />}
|
{section === "ai-features" && <AiFeaturesSection />}
|
||||||
{section === "tools" && <ToolsSection />}
|
{section === "tools" && <ToolsSection />}
|
||||||
|
{section === "analytics" && <AnalyticsSection />}
|
||||||
{section === "about" && <AboutSection />}
|
{section === "about" && <AboutSection />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Product Analytics</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Share anonymous usage data to help improve ashim.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Your images never leave your machine.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{disabled ? (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
Product analytics has been disabled by the server administrator.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{enabled ? "Analytics enabled" : "Analytics disabled"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAnalytics(!enabled)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||||
|
enabled ? "bg-primary" : "bg-muted-foreground/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
|
||||||
|
enabled ? "translate-x-6" : "translate-x-1",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ────────────────────── About ────────────────────── */
|
/* ────────────────────── About ────────────────────── */
|
||||||
|
|
||||||
function AboutSection() {
|
function AboutSection() {
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ export function formatHeaders(init?: HeadersInit): Headers {
|
|||||||
if (token) {
|
if (token) {
|
||||||
headers.set("Authorization", `Bearer ${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;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { CategoryInfo, Tool } from "@ashim/shared";
|
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 { Eye, EyeOff, FileImage, LayoutGrid, List, Search } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { GemLogo } from "@/components/common/gem-logo";
|
import { GemLogo } from "@/components/common/gem-logo";
|
||||||
|
import { track } from "@/lib/analytics";
|
||||||
import { apiGet } from "@/lib/api";
|
import { apiGet } from "@/lib/api";
|
||||||
import { ICON_MAP } from "@/lib/icon-map";
|
import { ICON_MAP } from "@/lib/icon-map";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -51,6 +52,17 @@ export function FullscreenGridPage() {
|
|||||||
);
|
);
|
||||||
}, [search, visibleTools]);
|
}, [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 groupedTools = useMemo(() => {
|
||||||
const groups = new Map<string, Tool[]>();
|
const groups = new Map<string, Tool[]>();
|
||||||
for (const tool of filteredTools) {
|
for (const tool of filteredTools) {
|
||||||
|
|||||||
Reference in New Issue
Block a user