import { ANALYTICS_EVENTS, 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"; import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { MigrationBanner } from "./components/common/migration-banner"; import { RouteAnnouncer } from "./components/common/route-announcer"; import { UsageSurveyOverlay } from "./components/onboarding/usage-survey-overlay"; import { I18nProvider } from "./contexts/i18n-context"; import { useAuth } from "./hooks/use-auth"; import { useMobile } from "./hooks/use-mobile"; import { initAnalytics, isAnalyticsActive, optOut, track } from "./lib/analytics"; import { AUTH_GUARD_UNGATED_PATHS } from "./lib/auth-routes"; 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. const AutomatePage = lazy(() => import("./pages/automate-page").then((m) => ({ default: m.AutomatePage })), ); const ChangePasswordPage = lazy(() => import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })), ); const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage }))); const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage }))); const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage }))); const PrivacyPolicyPage = lazy(() => import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })), ); const EditorPage = lazy(() => import("./pages/editor-page").then((m) => ({ default: m.EditorPage })), ); const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage }))); const NotFoundPage = lazy(() => import("./pages/not-found-page").then((m) => ({ default: m.NotFoundPage })), ); class ErrorBoundary extends Component< { children: ReactNode }, { hasError: boolean; error: Error | null } > { constructor(props: { children: ReactNode }) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error("Uncaught render error:", error, info.componentStack); if (!isAnalyticsActive()) return; // respect the runtime opt-out // Mirror the crash class only (no PII). track() and Sentry are best-effort. track(ANALYTICS_EVENTS.TOOL_CLIENT_ERROR, { error_name: error.name }); import("@sentry/react") .then((Sentry) => { Sentry.captureReactException(error, info); }) .catch(() => {}); } render() { if (this.state.hasError) { return (

{en.common.somethingWentWrong}

{this.state.error?.message || en.common.unexpectedError}

); } return this.props.children; } } function AuthGuard({ children }: { children: React.ReactNode }) { const { loading, authEnabled, isAuthenticated, mustChangePassword } = useAuth(); const location = useLocation(); // When auth is disabled, redirect away from login/change-password to prevent escalation if ( !loading && !authEnabled && (location.pathname === "/login" || location.pathname === "/change-password") ) { return ; } // Don't guard the login or change-password pages if (AUTH_GUARD_UNGATED_PATHS.has(location.pathname)) { return <>{children}; } if (loading) { return (

{en.common.loading}

); } if (authEnabled && !isAuthenticated) { return ; } // Force password change before allowing access to the app if (authEnabled && mustChangePassword) { return ; } return <>{children}; } // Single page-level loading fallback — shown while JS for a route downloads. function PageLoader() { return (
); } export function App() { const isMobile = useMobile(); const analyticsConfig = useAnalyticsStore((s) => s.config); const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded); const fetchAnalyticsConfig = useAnalyticsStore((s) => s.fetchConfig); useEffect(() => { fetchAnalyticsConfig(); }, [fetchAnalyticsConfig]); useEffect(() => { if (localStorage.getItem("snapotter-welcome") === "1") { localStorage.removeItem("snapotter-welcome"); toast("Hello from the otter side! 🦦", { duration: 5000, }); } }, []); useEffect(() => { if (!analyticsConfigLoaded) return; if (analyticsConfig?.enabled) { void initAnalytics(analyticsConfig); } else if (isAnalyticsActive()) { // Instance-wide opt-out observed after this tab already initialized. optOut(); } }, [analyticsConfigLoaded, analyticsConfig]); useEffect(() => { const refetch = () => { if (document.visibilityState === "visible") void fetchAnalyticsConfig(); }; document.addEventListener("visibilitychange", refetch); window.addEventListener("focus", refetch); return () => { document.removeEventListener("visibilitychange", refetch); window.removeEventListener("focus", refetch); }; }, [fetchAnalyticsConfig]); return ( {en.a11y.skipToContent} }> } /> } /> } /> } /> } /> } /> {/* Legacy 1.x color tools were consolidated into adjust-colors; redirect old bookmarks to the section route. */} } /> } /> } /> } /> } /> } /> } /> ); }