mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(analytics): build-time bake + telemetry depth (#336)
Bake PostHog + Sentry into the published Docker image (SNAPOTTER_ANALYTICS build arg, codegen script). Delete entire consent system. Move event emission to BullMQ worker. Add cross-tier identity stitching, Sentry performance tracing on both tiers, frontend funnel events. Fix stateful regex bug. 86 files changed, 1593 insertions(+), 3747 deletions(-)
This commit is contained in:
+14
-69
@@ -1,4 +1,4 @@
|
||||
import { APP_VERSION, en, shouldShowConsent } from "@snapotter/shared";
|
||||
import { en } from "@snapotter/shared";
|
||||
import { Component, type ErrorInfo, lazy, type ReactNode, Suspense, useEffect } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { Toaster, toast } from "sonner";
|
||||
@@ -8,7 +8,7 @@ import { RouteAnnouncer } from "./components/common/route-announcer";
|
||||
import { I18nProvider } from "./contexts/i18n-context";
|
||||
import { useAuth } from "./hooks/use-auth";
|
||||
import { useMobile } from "./hooks/use-mobile";
|
||||
import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics";
|
||||
import { 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
|
||||
@@ -25,9 +25,6 @@ const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default
|
||||
const PrivacyPolicyPage = lazy(() =>
|
||||
import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })),
|
||||
);
|
||||
const AnalyticsConsentPage = lazy(() =>
|
||||
import("./pages/analytics-consent-page").then((m) => ({ default: m.AnalyticsConsentPage })),
|
||||
);
|
||||
const EditorPage = lazy(() =>
|
||||
import("./pages/editor-page").then((m) => ({ default: m.EditorPage })),
|
||||
);
|
||||
@@ -51,6 +48,13 @@ class ErrorBoundary extends Component<
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Uncaught render error:", error, info.componentStack);
|
||||
import("@sentry/react")
|
||||
.then((Sentry) => {
|
||||
Sentry.captureException(error, {
|
||||
contexts: { react: { componentStack: info.componentStack ?? undefined } },
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -81,36 +85,9 @@ class ErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const {
|
||||
loading,
|
||||
authEnabled,
|
||||
isAuthenticated,
|
||||
mustChangePassword,
|
||||
analyticsEnabled,
|
||||
analyticsConsentShownAt,
|
||||
analyticsConsentRemindAt,
|
||||
} = useAuth();
|
||||
const storeConsent = useAnalyticsStore((s) => s.consent);
|
||||
const setStoreConsent = useAnalyticsStore((s) => s.setConsent);
|
||||
const analyticsConfig = useAnalyticsStore((s) => s.config);
|
||||
const { loading, authEnabled, isAuthenticated, mustChangePassword } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only hydrate on session load, not on store changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loading &&
|
||||
analyticsEnabled !== undefined &&
|
||||
storeConsent.analyticsConsentShownAt === null &&
|
||||
storeConsent.analyticsEnabled === null
|
||||
) {
|
||||
setStoreConsent({
|
||||
analyticsEnabled: analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt: analyticsConsentShownAt ?? null,
|
||||
analyticsConsentRemindAt: analyticsConsentRemindAt ?? null,
|
||||
});
|
||||
}
|
||||
}, [loading, analyticsEnabled, analyticsConsentShownAt, setStoreConsent]);
|
||||
|
||||
// When auth is disabled, redirect away from login/change-password to prevent escalation
|
||||
if (
|
||||
!loading &&
|
||||
@@ -124,8 +101,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
if (
|
||||
location.pathname === "/login" ||
|
||||
location.pathname === "/change-password" ||
|
||||
location.pathname === "/privacy" ||
|
||||
location.pathname === "/analytics-consent"
|
||||
location.pathname === "/privacy"
|
||||
) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -150,22 +126,6 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
return <Navigate to="/change-password" replace />;
|
||||
}
|
||||
|
||||
const effectiveConsent = {
|
||||
analyticsEnabled: storeConsent.analyticsEnabled ?? analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt:
|
||||
storeConsent.analyticsConsentShownAt ?? analyticsConsentShownAt ?? null,
|
||||
analyticsConsentRemindAt:
|
||||
storeConsent.analyticsConsentRemindAt ?? analyticsConsentRemindAt ?? null,
|
||||
};
|
||||
const serverEnabled = analyticsConfig?.enabled ?? false;
|
||||
if (
|
||||
authEnabled &&
|
||||
analyticsConfig !== null &&
|
||||
shouldShowConsent(effectiveConsent, serverEnabled)
|
||||
) {
|
||||
return <Navigate to="/analytics-consent" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -183,7 +143,6 @@ 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();
|
||||
@@ -199,22 +158,9 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!analyticsConfigLoaded ||
|
||||
!analyticsConfig?.enabled ||
|
||||
analyticsConsent.analyticsEnabled !== true
|
||||
)
|
||||
return;
|
||||
void (async () => {
|
||||
setAnalyticsConsent(true);
|
||||
await initAnalytics(analyticsConfig);
|
||||
identify(
|
||||
analyticsConfig.instanceId,
|
||||
{ version: APP_VERSION },
|
||||
{ instance_id: analyticsConfig.instanceId },
|
||||
);
|
||||
})();
|
||||
}, [analyticsConfigLoaded, analyticsConfig, analyticsConsent.analyticsEnabled]);
|
||||
if (!analyticsConfigLoaded || !analyticsConfig?.enabled) return;
|
||||
void initAnalytics(analyticsConfig);
|
||||
}, [analyticsConfigLoaded, analyticsConfig]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -238,7 +184,6 @@ export function App() {
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
|
||||
<Route path="/editor" element={<EditorPage />} />
|
||||
<Route path="/:section/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
||||
import { AlertCircle, ArrowLeft, CheckCircle2, Download, FileText, FolderPlus } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -71,6 +72,9 @@ export function ReviewPanel({
|
||||
}, [originalSize, fileSize]);
|
||||
|
||||
const handleDownload = () => {
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.RESULT_DOWNLOADED, {});
|
||||
});
|
||||
triggerDownload(downloadUrl, filename);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@
|
||||
import { format, plural } from "@/lib/format";
|
||||
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { OtterLogo } from "../common/otter-logo";
|
||||
@@ -60,7 +59,6 @@ type Section =
|
||||
| "api-keys"
|
||||
| "ai-features"
|
||||
| "tools"
|
||||
| "analytics"
|
||||
| "about";
|
||||
|
||||
interface NavItem {
|
||||
@@ -124,7 +122,6 @@ function useNavItems() {
|
||||
requiredPermission: "settings:write",
|
||||
},
|
||||
{ id: "tools", label: t.settings.nav.tools, icon: Wrench },
|
||||
{ id: "analytics", label: t.settings.nav.productAnalytics, icon: Eye },
|
||||
{ id: "about", label: t.settings.nav.about, icon: Info },
|
||||
],
|
||||
[t],
|
||||
@@ -217,7 +214,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
{section === "api-keys" && <ApiKeysSection />}
|
||||
{section === "ai-features" && <AiFeaturesSection />}
|
||||
{section === "tools" && <ToolsSection />}
|
||||
{section === "analytics" && <AnalyticsSection />}
|
||||
|
||||
{section === "about" && <AboutSection />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,7 +285,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
||||
{section === "api-keys" && <ApiKeysSection />}
|
||||
{section === "ai-features" && <AiFeaturesSection />}
|
||||
{section === "tools" && <ToolsSection />}
|
||||
{section === "analytics" && <AnalyticsSection />}
|
||||
|
||||
{section === "about" && <AboutSection />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3384,71 +3381,6 @@ function ToolsSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────── Analytics ────────────────────── */
|
||||
|
||||
function AnalyticsSection() {
|
||||
const { t } = useTranslation();
|
||||
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">{t.analytics.settingsTitle}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t.analytics.settingsDescription}</p>
|
||||
<p className="text-xs text-muted-foreground">{t.analytics.settingsPrivacy}</p>
|
||||
</div>
|
||||
|
||||
{disabled ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t.analytics.settingsDisabledByAdmin}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-foreground">
|
||||
{enabled ? "Analytics enabled" : "Analytics disabled"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={t.analytics.settingsTitle}
|
||||
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"
|
||||
>
|
||||
{t.analytics.learnMore}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────────────────────── About ────────────────────── */
|
||||
|
||||
function AboutSection() {
|
||||
|
||||
@@ -10,9 +10,6 @@ interface AuthState {
|
||||
mfaRequired: boolean;
|
||||
role: string | null;
|
||||
permissions: string[];
|
||||
analyticsEnabled: boolean | null;
|
||||
analyticsConsentShownAt: number | null;
|
||||
analyticsConsentRemindAt: number | null;
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderName: string | null;
|
||||
samlEnabled: boolean;
|
||||
@@ -48,9 +45,6 @@ export function useAuth() {
|
||||
mfaRequired: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: null,
|
||||
samlEnabled: false,
|
||||
@@ -78,9 +72,6 @@ export function useAuth() {
|
||||
mfaRequired: false,
|
||||
role: "admin",
|
||||
permissions: ANON_ADMIN_PERMISSIONS,
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: null,
|
||||
samlEnabled: false,
|
||||
@@ -110,9 +101,6 @@ export function useAuth() {
|
||||
mfaRequired: session.user?.mfaRequired === true,
|
||||
role: session.user?.role ?? null,
|
||||
permissions: session.user?.permissions ?? [],
|
||||
analyticsEnabled: session.user?.analyticsEnabled ?? null,
|
||||
analyticsConsentShownAt: session.user?.analyticsConsentShownAt ?? null,
|
||||
analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null,
|
||||
oidcEnabled: config.oidcEnabled ?? false,
|
||||
oidcProviderName: config.oidcProviderName ?? null,
|
||||
samlEnabled: config.samlEnabled ?? false,
|
||||
@@ -132,9 +120,6 @@ export function useAuth() {
|
||||
mfaRequired: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
oidcEnabled: config.oidcEnabled ?? false,
|
||||
oidcProviderName: config.oidcProviderName ?? null,
|
||||
samlEnabled: config.samlEnabled ?? false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiToolPath, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, apiToolPath, PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders, parseApiError } from "@/lib/api";
|
||||
@@ -243,6 +243,14 @@ export function useToolProcessor(toolId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.TOOL_STARTED, {
|
||||
tool_id: toolId,
|
||||
is_batch: false,
|
||||
file_count: files.length,
|
||||
});
|
||||
});
|
||||
|
||||
const capturedIndex = useFileStore.getState().selectedIndex;
|
||||
|
||||
setError(null);
|
||||
@@ -521,6 +529,15 @@ export function useToolProcessor(toolId: string) {
|
||||
setError("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.TOOL_STARTED, {
|
||||
tool_id: toolId,
|
||||
is_batch: true,
|
||||
file_count: files.length,
|
||||
});
|
||||
});
|
||||
|
||||
if (files.length === 1) {
|
||||
processFiles(files, settings);
|
||||
return;
|
||||
|
||||
@@ -4,10 +4,11 @@ type PostHogInstance = import("posthog-js").PostHog;
|
||||
|
||||
let posthog: PostHogInstance | null = null;
|
||||
let initialized = false;
|
||||
let consentGranted = false;
|
||||
|
||||
const FILE_EXT_PATTERN =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/gi;
|
||||
const FILE_EXT_TEST =
|
||||
/\.(jpe?g|png|pdf|webp|gif|tiff?|bmp|svg|hei[cf]?|avif|raw|cr2|nef|arw|dng|psd|tga|exr|hdr)\b/i;
|
||||
const FILE_PATH_PATTERN = /\/(tmp\/workspace|data\/files|data\/ai|Users|home)\//g;
|
||||
|
||||
function scrubString(str: string): string {
|
||||
@@ -15,7 +16,7 @@ function scrubString(str: string): string {
|
||||
}
|
||||
|
||||
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
if (initialized || !config.enabled || !consentGranted) return;
|
||||
if (initialized || !config.enabled) return;
|
||||
|
||||
try {
|
||||
const posthogJs = (await import("posthog-js")).default;
|
||||
@@ -25,29 +26,33 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
autocapture: false,
|
||||
capture_pageview: true,
|
||||
disable_session_recording: true,
|
||||
session_recording: {
|
||||
captureCanvas: { recordCanvas: false },
|
||||
maskAllInputs: true,
|
||||
maskTextSelector: ".file-name, .file-path, [data-file-name]",
|
||||
blockSelector: "[data-user-content]",
|
||||
},
|
||||
ip: false,
|
||||
persistence: "localStorage",
|
||||
person_profiles: "always",
|
||||
}) ?? null;
|
||||
initialized = true;
|
||||
} catch (err) {
|
||||
console.warn("[analytics] PostHog init failed:", err);
|
||||
}
|
||||
|
||||
if (posthog) {
|
||||
posthog.register({
|
||||
instance_id: config.instanceId,
|
||||
app_version: (await import("@snapotter/shared")).APP_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (config.sentryDsn) {
|
||||
const Sentry = await import("@sentry/react");
|
||||
Sentry.init({
|
||||
dsn: config.sentryDsn,
|
||||
release: (await import("@snapotter/shared")).APP_VERSION,
|
||||
environment: "production",
|
||||
tracesSampleRate: config.sampleRate,
|
||||
sendDefaultPii: false,
|
||||
integrations: [Sentry.browserTracingIntegration()],
|
||||
beforeSend(event) {
|
||||
if (!consentGranted) return null;
|
||||
startErrorReplay();
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.username;
|
||||
@@ -66,10 +71,9 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
return event;
|
||||
},
|
||||
beforeBreadcrumb(breadcrumb) {
|
||||
if (!consentGranted) return null;
|
||||
if (breadcrumb.category === "ui.click") return null;
|
||||
if (breadcrumb.category === "fetch" && breadcrumb.data?.url) {
|
||||
if (FILE_EXT_PATTERN.test(breadcrumb.data.url as string)) return null;
|
||||
if (FILE_EXT_TEST.test(breadcrumb.data.url as string)) return null;
|
||||
}
|
||||
if (breadcrumb.message) {
|
||||
breadcrumb.message = scrubString(breadcrumb.message);
|
||||
@@ -83,42 +87,8 @@ export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function shutdownAnalytics(): void {
|
||||
if (posthog) {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
posthog.reset();
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
posthog = null;
|
||||
initialized = false;
|
||||
consentGranted = false;
|
||||
}
|
||||
|
||||
export function setAnalyticsConsent(enabled: boolean): void {
|
||||
consentGranted = enabled;
|
||||
if (!enabled) {
|
||||
shutdownAnalytics();
|
||||
}
|
||||
}
|
||||
|
||||
export function identify(
|
||||
instanceId: string,
|
||||
properties: Record<string, unknown>,
|
||||
propertiesSetOnce?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!posthog || !consentGranted) return;
|
||||
try {
|
||||
posthog.identify(instanceId, properties, propertiesSetOnce);
|
||||
} catch {
|
||||
// never throw
|
||||
}
|
||||
}
|
||||
|
||||
export function track(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!posthog || !consentGranted) return;
|
||||
if (!posthog) return;
|
||||
try {
|
||||
posthog.capture(event, properties);
|
||||
} catch {
|
||||
@@ -126,11 +96,11 @@ export function track(event: string, properties?: Record<string, unknown>): void
|
||||
}
|
||||
}
|
||||
|
||||
export function startErrorReplay(): void {
|
||||
if (!posthog || !consentGranted) return;
|
||||
export function getDistinctId(): string | null {
|
||||
if (!posthog) return null;
|
||||
try {
|
||||
posthog.startSessionRecording();
|
||||
return posthog.get_distinct_id();
|
||||
} catch {
|
||||
// never throw
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDistinctId } from "@/lib/analytics";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
|
||||
const API_BASE = "/api";
|
||||
@@ -60,15 +61,9 @@ export function formatHeaders(init?: HeadersInit): Headers {
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const consent = localStorage.getItem("snapotter-analytics-consent");
|
||||
if (consent === "true" || consent === "false") {
|
||||
headers.set("X-Analytics-Consent", consent);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
const distinctId = getDistinctId();
|
||||
if (distinctId) {
|
||||
headers.set("X-PostHog-Distinct-Id", distinctId);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Shield } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
|
||||
export function AnalyticsConsentPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { config, configLoaded, fetchConfig, acceptAnalytics, declineAnalytics, remindLater } =
|
||||
useAnalyticsStore();
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoaded && !config?.enabled) {
|
||||
declineAnalytics().then(() => navigate("/", { replace: true }));
|
||||
}
|
||||
}, [configLoaded, config, navigate, declineAnalytics]);
|
||||
|
||||
if (!configLoaded) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAccept = async () => {
|
||||
await acceptAnalytics();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const handleDecline = async () => {
|
||||
await remindLater();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-[400px] space-y-6 rounded-2xl border border-border bg-card p-9 shadow-lg">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-primary/10">
|
||||
<Shield className="h-[22px] w-[22px] text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-center">
|
||||
<h1 className="text-lg font-semibold text-foreground">{t.analytics.consentTitle}</h1>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{t.analytics.consentDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">{t.analytics.consentChangeable}</p>
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAccept}
|
||||
className="flex-1 rounded-[10px] bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{t.analytics.acceptButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecline}
|
||||
className="flex-1 rounded-[10px] border border-border px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
{t.analytics.declineButton}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Tool } from "@snapotter/shared";
|
||||
import { CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -85,6 +85,16 @@ export function HomePage() {
|
||||
|
||||
const searchResults = useFuseSearch(visibleTools, search);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search || search.length < 2) return;
|
||||
const timer = setTimeout(() => {
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.SEARCH, { query: search, results_count: searchResults.length });
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, searchResults.length]);
|
||||
|
||||
const tabTools = useMemo(() => {
|
||||
if (activeTab === "all") return visibleTools;
|
||||
return visibleTools.filter((tool) => toolSection(tool) === activeTab);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ANALYTICS_EVENTS,
|
||||
getRequiredBundlesForTool,
|
||||
PYTHON_SIDECAR_TOOLS,
|
||||
SECTIONS,
|
||||
@@ -263,6 +264,13 @@ export function ToolPage() {
|
||||
useEffect(() => {
|
||||
if (tool) {
|
||||
recordRecentTool(tool.id);
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.TOOL_OPENED, {
|
||||
tool_id: tool.id,
|
||||
modality: tool.modality,
|
||||
category: tool.category,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [tool]);
|
||||
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import type { AnalyticsConfig, ConsentState } from "@snapotter/shared";
|
||||
import type { AnalyticsConfig } from "@snapotter/shared";
|
||||
import { create } from "zustand";
|
||||
import { setAnalyticsConsent } from "@/lib/analytics";
|
||||
import { apiPut } from "@/lib/api";
|
||||
|
||||
interface AnalyticsState {
|
||||
config: AnalyticsConfig | null;
|
||||
consent: ConsentState;
|
||||
configLoaded: boolean;
|
||||
fetchConfig: () => Promise<void>;
|
||||
setConsent: (consent: ConsentState) => void;
|
||||
acceptAnalytics: () => Promise<void>;
|
||||
declineAnalytics: () => Promise<void>;
|
||||
remindLater: () => Promise<void>;
|
||||
toggleAnalytics: (enabled: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAnalyticsStore = create<AnalyticsState>((set, get) => ({
|
||||
config: null,
|
||||
consent: {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: null,
|
||||
analyticsConsentRemindAt: null,
|
||||
},
|
||||
configLoaded: false,
|
||||
|
||||
fetchConfig: async () => {
|
||||
if (get().configLoaded) return;
|
||||
try {
|
||||
@@ -34,69 +20,4 @@ export const useAnalyticsStore = create<AnalyticsState>((set, get) => ({
|
||||
set({ configLoaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
setConsent: (consent: ConsentState) => {
|
||||
set({ consent });
|
||||
setAnalyticsConsent(consent.analyticsEnabled === true);
|
||||
},
|
||||
|
||||
acceptAnalytics: async () => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { enabled: true });
|
||||
} catch {
|
||||
localStorage.setItem("snapotter-analytics-consent", "true");
|
||||
}
|
||||
const now = Date.now();
|
||||
const consent: ConsentState = {
|
||||
analyticsEnabled: true,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
set({ consent });
|
||||
setAnalyticsConsent(true);
|
||||
},
|
||||
|
||||
declineAnalytics: async () => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { enabled: false });
|
||||
} catch {
|
||||
localStorage.setItem("snapotter-analytics-consent", "false");
|
||||
}
|
||||
const now = Date.now();
|
||||
const consent: ConsentState = {
|
||||
analyticsEnabled: false,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: null,
|
||||
};
|
||||
set({ consent });
|
||||
setAnalyticsConsent(false);
|
||||
},
|
||||
|
||||
remindLater: async () => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { remindLater: true });
|
||||
} catch {
|
||||
localStorage.setItem("snapotter-analytics-consent", "remind");
|
||||
}
|
||||
const now = Date.now();
|
||||
const consent: ConsentState = {
|
||||
analyticsEnabled: null,
|
||||
analyticsConsentShownAt: now,
|
||||
analyticsConsentRemindAt: now + 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
set({ consent });
|
||||
setAnalyticsConsent(false);
|
||||
},
|
||||
|
||||
toggleAnalytics: async (enabled: boolean) => {
|
||||
try {
|
||||
await apiPut("/v1/user/analytics", { enabled });
|
||||
} catch {
|
||||
localStorage.setItem("snapotter-analytics-consent", enabled ? "true" : "false");
|
||||
}
|
||||
set((state) => ({
|
||||
consent: { ...state.consent, analyticsEnabled: enabled },
|
||||
}));
|
||||
setAnalyticsConsent(enabled);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { detectModalityFromMime, type Modality } from "@snapotter/shared";
|
||||
import { ANALYTICS_EVENTS, detectModalityFromMime, type Modality } from "@snapotter/shared";
|
||||
import { create } from "zustand";
|
||||
import { fetchDecodedPreview, needsServerPreview } from "@/lib/image-preview";
|
||||
|
||||
@@ -166,6 +166,9 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
// -- Actions --------------------------------------------------------------
|
||||
|
||||
setFiles: (files) => {
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.FILE_ADDED, { file_count: files.length });
|
||||
});
|
||||
revokeEntries(get().entries);
|
||||
const entries = files.map(createEntry);
|
||||
set({
|
||||
@@ -203,6 +206,9 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
},
|
||||
|
||||
addFiles: (files) => {
|
||||
import("@/lib/analytics").then(({ track }) => {
|
||||
track(ANALYTICS_EVENTS.FILE_ADDED, { file_count: files.length });
|
||||
});
|
||||
const oldLen = get().entries.length;
|
||||
const newEntries = files.map(createEntry);
|
||||
const entries = [...get().entries, ...newEntries];
|
||||
|
||||
Reference in New Issue
Block a user