import React, { Suspense, createContext, lazy, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { Box, Button, IconButton, Typography } from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import { isCancel } from "axios";
import DashboardIcon from "@mui/icons-material/Dashboard";
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
import BusinessIcon from "@mui/icons-material/Business";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import SettingsIcon from "@mui/icons-material/Settings";
import AlarmIcon from "@mui/icons-material/Alarm";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import ShieldIcon from "@mui/icons-material/Shield";
import SearchIcon from "@mui/icons-material/Search";
import MemoryIcon from "@mui/icons-material/Memory";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
import InsightsOutlinedIcon from "@mui/icons-material/InsightsOutlined";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams, createBrowserRouter, RouterProvider } from "react-router-dom";
import { ToastProvider } from "./toast";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import JobTable from "./components/JobTable";
import NotificationsPopover from "./components/NotificationsPopover";
import type { JobTableColumns } from "./components/JobTable";
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import LoginPage from "./views/LoginPage";
import LandingPage from "./views/LandingPage";
import ForgotPasswordPage from "./views/ForgotPasswordPage";
import ResetPasswordPage from "./views/ResetPasswordPage";
import VerifyEmailPage from "./views/VerifyEmailPage";
import MicrosoftLegacyRelinkPage from "./views/MicrosoftLegacyRelinkPage";
import RouteErrorPage from "./views/RouteErrorPage";
import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
import { AccountPlanProvider } from "./accountPlan";
import AppShell, { NavItem } from "./layout/AppShell";
import { getThemeModePref, setThemeModePref, subscribeToThemePreferenceChanges, ThemeModePref } from "./themePrefs";
import JobTrackerThemeProvider from "./ThemeProvider";
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from "./sessionBoundary";
const AddJobModal = lazy(() => import("./components/AddJobModal"));
const DashboardView = lazy(() => import("./components/DashboardView"));
const AnalyticsPage = lazy(() => import("./views/AnalyticsPage"));
const JobDiscoveryPage = lazy(() => import("./views/JobDiscoveryPage"));
const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
const SettingsView = lazy(() => import("./components/SettingsView"));
const RemindersView = lazy(() => import("./components/RemindersView"));
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage"));
const CvBuilderPage = lazy(() => import("./views/CvBuilderPage"));
const ApplicationWorkspacePage = lazy(() => import("./views/ApplicationWorkspacePage"));
const CvBuilderEditor = lazy(() => import("./views/CvBuilderEditor"));
const PublicCvPage = lazy(() => import("./views/PublicCvPage"));
const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage"));
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const OperationsPage = lazy(() => import("./views/OperationsPage"));
const OfferComparisonPage = lazy(() => import("./views/OfferComparisonPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
function LegacyBoardRedirect() {
const location = useLocation();
const query = new URLSearchParams(location.search);
query.set("view", "board");
return ;
}
function LegacyCorrespondenceReviewRedirect() {
const location = useLocation();
const query = new URLSearchParams(location.search);
query.set("view", "review");
return ;
}
type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
const ThemeControlsContext = createContext(null);
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
email?: string;
userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
avatarImageDataUrl?: string;
roles?: string[];
plan?: "free" | "pro";
entitlements?: { ai?: boolean; proThemes?: boolean };
appVersion?: string;
appCommitSha?: string;
uiLanguage?: "en" | "nb-NO";
};
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/dashboard")) return [t("home"), t("dashboard")];
if (path.startsWith("/analytics")) return [t("home"), t("insights"), t("analytics")];
if (path.startsWith("/discover")) return [t("home"), t("discoverJobs")];
if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), t("jobDetails")];
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
if (path.startsWith("/operations")) return [t("home"), t("operations")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")];
if (path.startsWith("/offers")) return [t("home"), t("offerComparisonTitle")];
if (path.startsWith("/correspondence/review")) return [t("home"), t("gmailReviewQueue")];
if (path.startsWith("/correspondence")) return [t("home"), t("correspondenceInboxTitle")];
if (path.startsWith("/trash")) return [t("home"), t("trash")];
if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), t("connectedAccounts")];
if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
if (path.startsWith("/career/builder")) return [t("home"), t("careerWorkspace"), t("cvBuilder")];
if (path.startsWith("/career")) return [t("home"), t("careerWorkspace")];
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
return [t("home")];
}
function titleFor(path: string, t: (k: any) => string): string {
if (path === "/dashboard") return t("dashboard");
if (path.startsWith("/analytics")) return t("analytics");
if (path.startsWith("/reminders")) return t("reminders");
if (path.startsWith("/operations")) return t("operations");
if (path.startsWith("/discover")) return t("discoverJobs");
if (/^\/jobs\/\d+/.test(path)) return t("jobDetails");
if (path.startsWith("/jobs")) return t("jobApplications");
if (path.startsWith("/kanban")) return t("kanbanBoard");
if (path.startsWith("/companies")) return t("companies");
if (path.startsWith("/offers")) return t("offerComparisonTitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewQueue");
if (path.startsWith("/correspondence")) return t("correspondenceInboxTitle");
if (path.startsWith("/trash")) return t("trash");
if (path.startsWith("/settings/connected-accounts")) return t("connectedAccounts");
if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return t("profile");
if (path.startsWith("/career/builder")) return t("cvBuilder");
if (path.startsWith("/career")) return t("careerWorkspace");
if (path.startsWith("/admin/audit")) return t("auditLog");
if (path.startsWith("/admin/users")) return t("users");
if (path.startsWith("/admin/system")) return t("systemStatus");
return t("appTitle");
}
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
if (/^\/settings\/?$/.test(path)) return t("settingsPageDescription");
if (path === "/dashboard") return t("dashboardPageSubtitle");
if (path.startsWith("/analytics")) return t("analyticsPageSubtitle");
if (path.startsWith("/discover")) return t("discoverJobsSubtitle");
if (/^\/jobs\/\d+/.test(path)) return t("jobDetailsWorkspaceSubtitle");
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
if (path.startsWith("/offers")) return t("offerComparisonPageSubtitle");
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
return undefined;
}
function PageLoader() {
const { t } = useI18n();
return {t("loading")};
}
function LegacyApplicationRedirect() {
const { id } = useParams();
const location = useLocation();
return ;
}
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
const location = useLocation();
const navigate = useNavigate();
const { t, hydrateLanguage } = useI18n();
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
const [addOpen, setAddOpen] = useState(false);
const [captureUrl, setCaptureUrl] = useState(undefined);
const [quickOpen, setQuickOpen] = useState(false);
const [refreshToken, setRefreshToken] = useState(0);
const [requireAuth, setRequireAuth] = useState(null);
const [authResolved, setAuthResolved] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [me, setMe] = useState(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [reminderCount, setReminderCount] = useState(0);
const [notificationCount, setNotificationCount] = useState(0);
const [notificationAnchor, setNotificationAnchor] = useState(null);
const path = location.pathname;
const isJobs = path.startsWith("/jobs");
const shortcutHint = useMemo(() => (
typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K"
), []);
useEffect(() => {
api.get("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
}, []);
// Quick-capture target: bookmarklet (/?add=) or PWA share (url in `add`, or a link
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
useEffect(() => {
const url = resolveCaptureUrl(location.search);
if (!url) return;
setCaptureUrl(url);
setAddOpen(true);
const params = new URLSearchParams(location.search);
params.delete("add");
params.delete("addtext");
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
}, [location.search, location.pathname, navigate]);
useEffect(() => {
let active = true;
api.get("/auth/me")
.then((r) => {
if (!active) return;
setMe(r.data);
hydrateLanguage(r.data?.uiLanguage);
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
})
.catch((error) => {
if (!active || isCancel(error)) return;
setMe(null);
setIsAdmin(false);
clearAuthClientState(false);
})
.finally(() => {
if (active) setAuthResolved(true);
});
return () => {
active = false;
};
}, [hydrateLanguage]);
useEffect(() => {
const load = () => {
api.get("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setReminderCount(0));
};
load();
const id = window.setInterval(load, 60000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
const load = () => {
api.get<{ count: number }>("/notifications/unread-count").then((r) => setNotificationCount(Math.max(0, Number(r.data?.count) || 0))).catch(() => setNotificationCount(0));
};
load();
const id = window.setInterval(load, 60000);
window.addEventListener("notifications-changed", load);
return () => {
window.clearInterval(id);
window.removeEventListener("notifications-changed", load);
};
}, []);
useEffect(() => {
const onAuthChanged = () => {
setAuthResolved(false);
api.get("/auth/me")
.then((r) => {
setMe(r.data);
hydrateLanguage(r.data?.uiLanguage);
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
})
.catch((error) => {
if (isCancel(error)) return;
setMe(null);
setIsAdmin(false);
clearAuthClientState(false);
})
.finally(() => setAuthResolved(true));
};
window.addEventListener("auth-changed", onAuthChanged);
return () => window.removeEventListener("auth-changed", onAuthChanged);
}, [hydrateLanguage]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setQuickOpen(true);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
if (requireAuth === null || !authResolved) return {t("loading")};
if (requireAuth && !me) return ;
const pageTitle = titleFor(path, t);
const pageSubtitle = subtitleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
const nav: NavItem[] = [
{ to: "/dashboard", label: t("dashboard"), icon: },
{ to: "/jobs", activeFor: ["/applications"], label: t("jobApplications"), icon: , section: t("jobSearch"), children: [
{ to: "/discover", label: t("discoverJobs"), icon: },
{ to: "/reminders", label: t("reminders"), icon: , badgeCount: reminderCount },
{ to: "/companies", label: t("companies"), icon: },
{ to: "/offers", label: t("offerComparisonNav"), icon: },
{ to: "/trash", label: t("trash"), icon: },
] },
{ to: "/correspondence", label: t("correspondenceInbox"), icon: , section: t("jobSearch") },
{ to: "/career", label: t("careerWorkspaceProfileTab"), icon: , section: t("career") },
{ to: "/career/builder", label: t("cvBuilder"), icon: , section: t("career") },
{ to: "/analytics", label: t("analytics"), icon: , section: t("insights") },
];
const navBottom: NavItem[] = [
{ to: "/admin/audit", label: t("auditLog"), icon: , hidden: !isAdmin, section: t("admin") },
{ to: "/admin/users", label: t("users"), icon: , hidden: !isAdmin, section: t("admin") },
{ to: "/admin/system", label: t("system"), icon: , hidden: !isAdmin, section: t("admin") },
{ to: "/settings", label: t("settings"), icon: , section: t("account"), children: [
{ to: "/profile", label: t("profile"), icon: },
{ to: "/settings/connected-accounts", label: t("connectedAccounts"), icon: },
] },
];
const rightActions = (
{compactHeaderActions ? (
setQuickOpen(true)}
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
>
) : (
} onClick={() => setQuickOpen(true)} sx={{ gap: 0.5 }}>
{t("quickSearch")}
{shortcutHint}
)}
{isJobs ? (
) : null}
);
return (
{ setMobileDrawerOpen(false); navigate(to); }}
user={{ email: me?.email, userName: me?.userName, displayName: me?.displayName || fullName || undefined, avatarImageDataUrl: me?.avatarImageDataUrl, roleLabel: isAdmin ? t("superAdmin") : t("user") }}
buildMetadata={isAdmin && me?.appVersion ? { version: me.appVersion, commitSha: me.appCommitSha } : undefined}
notificationsCount={notificationCount}
onOpenNotifications={(anchor) => setNotificationAnchor(anchor)}
onOpenSettings={() => navigate("/settings")}
onOpenProfile={() => navigate("/profile")}
onSignOut={() => { void api.post("/auth/logout").catch(() => undefined).finally(() => { clearAuthClientState(); navigate("/login"); }); }}
rightActions={rightActions}
>
}>
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
} />
setNotificationAnchor(null)}
onNavigate={(to) => navigate(to)}
onChanged={() => window.dispatchEvent(new Event("notifications-changed"))}
/>
{ setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
);
}
function ThemeAwareShell(props: Omit, "themeMode" | "onThemeModeChange">) {
const controls = useContext(ThemeControlsContext);
if (!controls) throw new Error("Theme controls are unavailable.");
return ;
}
function subscribeSessionBoundary(onChange: () => void) {
window.addEventListener(SESSION_INVALIDATED_EVENT, onChange);
return () => window.removeEventListener(SESSION_INVALIDATED_EVENT, onChange);
}
export default function App() {
const invalidated = useSyncExternalStore(subscribeSessionBoundary, isSessionInvalidated, () => false);
useEffect(() => {
if (invalidated) window.location.replace("/login?sessionChanged=1");
}, [invalidated]);
// Unmount providers as well as routes: confirmations/toasts can retain private text and
// callbacks. Passive effect navigation runs after the old forms' unload guards are removed.
return invalidated ? Session changed. Clearing this page… : ;
}
function Application() {
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
const [themeMode, setThemeMode] = useState(() => getThemeModePref());
useEffect(() => {
const sync = () => { setThemeMode(getThemeModePref()); };
return subscribeToThemePreferenceChanges(sync);
}, []);
const onThemeModeChange = useCallback((v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); }, []);
const themeControls = useMemo(() => ({ themeMode, onThemeModeChange }), [onThemeModeChange, themeMode]);
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
const raw = window.localStorage.getItem("jobPageSize");
const n = raw ? Number(raw) : 15;
return (n === 20 || n === 25 ? n : 15) as 15 | 20 | 25;
});
const [jobColumns, setJobColumns] = useState(() => {
const raw = window.localStorage.getItem("jobColumns");
if (!raw) return { status: true, dateApplied: true, daysSince: true, jobUrl: false };
try {
const p = JSON.parse(raw) as Partial;
return { status: p.status ?? true, dateApplied: p.dateApplied ?? true, daysSince: p.daysSince ?? true, jobUrl: p.jobUrl ?? false };
} catch {
return { status: true, dateApplied: true, daysSince: true, jobUrl: false };
}
});
const router = useMemo(() => createBrowserRouter([
{ path: "/", element: , errorElement: },
{ path: "/login", element: , errorElement: },
{ path: "/register", element: , errorElement: },
{ path: "/forgot-password", element: , errorElement: },
{ path: "/reset-password", element: , errorElement: },
{ path: "/verify-email", element: , errorElement: },
{ path: "/confirm-email-change", element: , errorElement: },
{ path: "/microsoft-legacy-relink", element: , errorElement: },
{ path: "/cv/:slug", element: , errorElement: },
{ path: "/*", element: , errorElement: },
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize]);
return (
);
}