import { ActionFunction, LoaderFunctionArgs, redirect, useRouteError, useSearchParams } from "react-router"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, useActionData, useLoaderData, useLocation, useMatches } from "react-router"; import "./globals.css"; import "./themes.css"; import "./styles/sentry-masking.css"; import { createUserSession, getUserInfo, validateCSRFToken } from "./utils/session.server"; import { AppRootData, useRootData } from "./utils/data/useRootData"; import { loadRootData } from "./utils/data/.server/rootData"; import FloatingLoader from "./components/ui/loaders/FloatingLoader"; import CookieConsentBanner from "./components/cookies/CookieConsentBanner"; import { allCookieCategories } from "./application/cookies/ApplicationCookies"; import { CookieCategory } from "./application/cookies/CookieCategory"; import { useEffect, useState } from "react"; import AnalyticsHelper from "./utils/helpers/AnalyticsHelper"; import clsx from "clsx"; import { getUser, updateUserProfile } from "./utils/db/users.db.server"; import ServerError from "./components/ui/errors/ServerError"; import PageMaintenanceMode from "./components/pages/PageMaintenanceMode"; import FeatureFlagTracker from "./modules/featureFlags/components/FeatureFlagTracker"; import { serverTimingHeaders } from "./modules/metrics/utils/defaultHeaders.server"; import ImpersonationBanner from "./components/impersonation-banner"; import { useTranslation } from "react-i18next"; import { Toaster as ReactHostToaster } from "react-hot-toast"; import { Toaster as SonnerToaster } from "./components/ui/sonner"; import { useChangeLanguage } from "remix-i18next/react"; import EventsService from "./modules/events/services/.server/EventsService"; import { UserPreferencesUpdatedDto } from "./modules/events/dtos/UserPreferencesUpdatedDto"; import AuthService from "./modules/users/services/AuthService"; import ScriptInjector from "./modules/shared/scripts/ScriptInjector"; import ScriptCrisp from "./modules/shared/scripts/ScriptCrisp"; import ScriptAnalytics from "./modules/shared/scripts/ScriptAnalytics"; import { v2MetaFunction } from "./utils/compat/v2MetaFunction"; import { DARK_MODE_IN_APP } from "./application/Constants"; import { nextLightOrDarkMode, normalizeLightOrDarkMode, persistUserLightOrDarkMode } from "./utils/theme/userThemePreference.server"; import { isAdminSurface, isAppOrAdminSurface } from "./utils/app/HostAwareSurface"; import { getCanonicalHostRedirectUrl } from "./utils/app/HostRouting.server"; import { safeRedirectPathFromRequest } from "./utils/app/RedirectSafety.server"; import { defaultBrandPublicIcons, defaultFaviconLinks } from "./assets/brand/default/public-icons"; import { RouteTracingProvider } from "./lib/sentry/routeTracing"; import { registerServiceWorker } from "./lib/pwa/registerServiceWorker"; import { getTranslations, i18nCookie } from "./locale/i18next.server"; export { serverTimingHeaders as headers }; export const handle = { i18n: ["translations", "settings", "billing", "crm", "auth", "emailMarketing"] }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const canonicalRedirectUrl = getCanonicalHostRedirectUrl(request); if (canonicalRedirectUrl) { throw redirect(canonicalRedirectUrl, { status: 308 }); } return loadRootData({ request, params }); }; export const meta: v2MetaFunction = ({ data }) => data?.metatags ?? []; function Document({ children, lang = "en", dir = "ltr" }: { children: React.ReactNode; lang?: string; dir?: string }) { const location = useLocation(); const rootData = useRootData(); const [lastLocation, setLastLocation] = useState(""); const matches = useMatches(); const actionData = useActionData(); const [searchParams] = useSearchParams(); const isAdminArea = isAdminSurface(rootData, location.pathname); const isAppOrAdminArea = isAppOrAdminSurface(rootData, location.pathname); useEffect(() => { if (lastLocation == location.pathname) { return; } const routeMatch = matches.find((m) => m.pathname == location.pathname); setLastLocation(location.pathname); async function addView() { let params = searchParams ? "?" + searchParams.toString() : ""; AnalyticsHelper.addPageView({ url: location.pathname + params, route: routeMatch?.id, rootData, }); } addView(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastLocation, location, rootData, actionData]); useEffect(() => { // PWA service-worker registration (#70). Module is a no-op in dev/SSR // and gracefully degrades when the browser lacks SW support. registerServiceWorker({ isDev: import.meta.env?.DEV }); }, []); return ( {/* Self-hosted Subframe display font (Instrument Sans); preload the latin subset so first paint already has the design-system typeface. */} {rootData.csrf && } {/* PWA manifest (#70) — installable web-app metadata; Lighthouse PWA audit. */} {/* Resource hints (#70) — speed up first connection to third-party origins that are conditionally requested later. dns-prefetch is cheap and idempotent; preconnect opens a TCP+TLS handshake early so the actual script tag can reuse the warm connection. */} {rootData.appConfiguration?.reviews?.trustpilot && ( )} {rootData.appConfiguration?.branding?.favicon ? ( ) : ( <> {defaultFaviconLinks.map((link) => ( ))} )} {rootData.appConfiguration?.reviews?.trustpilot && ( )} location.pathname.startsWith(`/${p}`)) ? "bg-muted" : "" )} > {rootData.impersonatingSession && ( )} {rootData.featureFlags?.includes("maintenance") && !isAdminArea && !location.pathname.startsWith("/login") ? ( ) : ( children )} {!isAppOrAdminArea && } ); } export const action: ActionFunction = async ({ request }) => { const canonicalRedirectUrl = getCanonicalHostRedirectUrl(request); if (canonicalRedirectUrl) { throw redirect(canonicalRedirectUrl, { status: 308 }); } const userInfo = await getUserInfo(request); const form = await request.clone().formData(); const action = form.get("action"); if (userInfo.userId && (action === "toggleLightOrDarkMode" || action === "setLocale" || action === "setTheme")) { // Reuse the already-parsed form data — avoids a second clone-and-parse // round-trip in the CSRF helper and protects against the // body-already-consumed crash that surfaced in B-009. await validateCSRFToken(request, { formData: form }); } const redirectTo = safeRedirectPathFromRequest(request, form.get("redirect")?.toString()); if (action === "toggleLightOrDarkMode") { const lightOrDarkMode = nextLightOrDarkMode(userInfo.lightOrDarkMode); if (userInfo.userId) { const user = await getUser(userInfo.userId); if (user) { await persistUserLightOrDarkMode(user.id, lightOrDarkMode); await EventsService.create({ request, event: "user.preferences.updated", tenantId: null, userId: user.id, data: { old: { lightOrDarkMode: normalizeLightOrDarkMode(user.lightOrDarkMode) }, new: { lightOrDarkMode }, user: { id: user.id, email: user.email }, } satisfies UserPreferencesUpdatedDto, }); } } return createUserSession( { ...userInfo, lightOrDarkMode, }, redirectTo ); } if (action === "setLocale") { const lng = form.get("lng")?.toString() ?? ""; if (userInfo.userId) { const user = await getUser(userInfo.userId); if (user) { await updateUserProfile({ locale: lng }, user.id); await EventsService.create({ request, event: "user.preferences.updated", tenantId: null, userId: user.id, data: { old: { locale: user.locale ?? "" }, new: { locale: lng }, user: { id: user.id, email: user.email }, } satisfies UserPreferencesUpdatedDto, }); } } const localeSession = await createUserSession( { ...userInfo, lng, }, redirectTo ); // remixI18Next.getLocale (which drives the SSR and translations) // reads the `_i18n` cookie, not the session, so the choice must be persisted // there too or switching language has no visible effect. localeSession.headers.append("Set-Cookie", await i18nCookie.serialize(lng)); return localeSession; } if (action === "setCookieConsent") { const preserveParams = ["ref", "source", "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"]; const allowed = form.getAll("cookies[]").map((f) => f.toString()); const cookies: { category: string; allowed: boolean }[] = []; allCookieCategories.forEach((item) => { const categoryName = CookieCategory[item]; const isAllowed = allowed.filter((f) => f.toString() === categoryName.toString()).length > 0; cookies.push({ category: CookieCategory[item], allowed: isAllowed ?? item === CookieCategory.REQUIRED }); }); const searchParams = new URLSearchParams(); preserveParams.forEach((param) => { const value = form.get(param)?.toString(); if (value) { searchParams.set(param, value); } }); return createUserSession( { ...userInfo, cookies, }, redirectTo ); } if (action === "login") { return await AuthService.loginFromRequest(request, form); } if (action === "setTheme") { const { t } = await getTranslations(request); const theme = form.get("theme")?.toString(); if (!theme) { return Response.json({ error: t("settings.theme.errors.invalidTheme") }); } return createUserSession({ ...userInfo, theme }, redirectTo); } }; export default function App() { const { locale } = useLoaderData(); const { i18n } = useTranslation(); const rootData = useRootData(); useChangeLanguage(locale); return ( ); } export function ErrorBoundary() { const error = useRouteError(); // Report the error to Sentry as a belt-and-braces backup. On the // server the SSR integration already captures unhandled exceptions; // this client-side hook covers route-level boundaries that React // swallowed during rendering. useEffect ensures we fire once per // error per hydration. useEffect(() => { if (error) { void import("@sentry/react").then((Sentry) => { Sentry.captureException(error); }); } }, [error]); return (
); }