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:
SnapOtter
2026-06-24 11:05:39 +08:00
committed by GitHub
parent a53038ed96
commit 5d36ac06d8
86 changed files with 1594 additions and 3748 deletions
+14 -69
View File
@@ -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() {
-15
View File
@@ -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,
+18 -1
View File
@@ -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;
+21 -51
View File
@@ -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;
}
}
+4 -9
View File
@@ -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>
);
}
+11 -1
View File
@@ -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);
+8
View File
@@ -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 -80
View File
@@ -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);
},
}));
+7 -1
View File
@@ -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];