187 lines
8.1 KiB
TypeScript
187 lines
8.1 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { Links, Outlet, Scripts, ScrollRestoration, useActionData, useLocation, useMatches, useSearchParams } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useChangeLanguage } from "remix-i18next/react";
|
|
import clsx from "clsx";
|
|
|
|
import { effectiveLng } from "@hivegrid/i18n";
|
|
|
|
import { CookieCategory } from "./application/cookies/CookieCategory";
|
|
import { DARK_MODE_IN_APP } from "./application/Constants";
|
|
import { defaultBrandPublicIcons, defaultFaviconLinks } from "./assets/brand/default/public-icons";
|
|
import ImpersonationBanner from "./components/impersonation-banner";
|
|
import CookieConsentBanner from "./components/cookies/CookieConsentBanner";
|
|
import PageMaintenanceMode from "./components/pages/PageMaintenanceMode";
|
|
import ServerError from "./components/ui/errors/ServerError";
|
|
import FloatingLoader from "./components/ui/loaders/FloatingLoader";
|
|
import { Toaster as SonnerToaster } from "./components/ui/sonner";
|
|
import { RouteTracingProvider } from "./lib/sentry/routeTracing";
|
|
import { registerServiceWorker } from "./lib/pwa/registerServiceWorker";
|
|
import { SERVICE_WORKER_RECOVERY_BOOTSTRAP } from "./lib/pwa/serviceWorkerRecoveryBootstrap";
|
|
import FeatureFlagTracker from "./modules/featureFlags/components/FeatureFlagTracker";
|
|
import ScriptAnalytics from "./modules/shared/scripts/ScriptAnalytics";
|
|
import ScriptCrisp from "./modules/shared/scripts/ScriptCrisp";
|
|
import ScriptInjector from "./modules/shared/scripts/ScriptInjector";
|
|
import ScriptSentryReplay from "./modules/shared/scripts/ScriptSentryReplay";
|
|
import { isPortalRootData, type PortalRootData } from "./services/customer-portal/portal-root";
|
|
import { isAdminSurface, isAppOrAdminSurface } from "./utils/app/HostAwareSurface";
|
|
import { type AppRootData, useRootData } from "./utils/data/useRootData";
|
|
import AnalyticsHelper from "./utils/helpers/AnalyticsHelper";
|
|
import CookieHelper from "./utils/helpers/CookieHelper";
|
|
import { getCurrentDocumentCspNonce } from "./utils/security/documentCspNonce";
|
|
import { HiveGridMeta } from "./utils/seo/HiveGridMeta";
|
|
|
|
export const rootI18nNamespaces = ["translations", "settings", "billing", "crm", "auth", "emailMarketing"] as const;
|
|
|
|
export function RootSurfaceApp({ data }: { data: AppRootData | PortalRootData }) {
|
|
if (isPortalRootData(data)) throw new Error("Core build received Portal root data");
|
|
return <CoreApp data={data} />;
|
|
}
|
|
|
|
export function RootSurfaceErrorBoundary(_props: { rootData?: AppRootData | PortalRootData }) {
|
|
return (
|
|
<Document>
|
|
<div className="mx-auto p-12 text-center">
|
|
<ServerError />
|
|
</div>
|
|
</Document>
|
|
);
|
|
}
|
|
|
|
function Document({
|
|
children,
|
|
lang = "en",
|
|
dir = "ltr",
|
|
cspNonce: cspNonceProp,
|
|
}: {
|
|
children: React.ReactNode;
|
|
lang?: string;
|
|
dir?: string;
|
|
cspNonce?: string;
|
|
}) {
|
|
const location = useLocation();
|
|
const rootData = useRootData();
|
|
const cspNonce = useStableDocumentCspNonce(cspNonceProp ?? rootData.cspNonce);
|
|
const lastLocation = useRef("");
|
|
const matches = useMatches();
|
|
const actionData = useActionData();
|
|
const [searchParams] = useSearchParams();
|
|
const isAdminArea = isAdminSurface(rootData, location.pathname);
|
|
const isAppOrAdminArea = isAppOrAdminSurface(rootData, location.pathname);
|
|
const trustpilotEnabled =
|
|
!isAppOrAdminArea &&
|
|
Boolean(rootData.appConfiguration?.reviews?.trustpilot) &&
|
|
Boolean(CookieHelper.hasConsent(rootData.userSession ?? null, CookieCategory.FUNCTIONAL));
|
|
|
|
useEffect(() => {
|
|
if (lastLocation.current === location.pathname) {
|
|
return;
|
|
}
|
|
const routeMatch = matches.find((match) => match.pathname === location.pathname);
|
|
lastLocation.current = location.pathname;
|
|
|
|
const params = searchParams ? `?${searchParams.toString()}` : "";
|
|
AnalyticsHelper.addPageView({
|
|
url: location.pathname + params,
|
|
route: routeMatch?.id,
|
|
rootData,
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [location, rootData, actionData]);
|
|
|
|
useEffect(() => {
|
|
registerServiceWorker({ isDev: import.meta.env?.DEV });
|
|
}, []);
|
|
|
|
return (
|
|
<html
|
|
lang={lang}
|
|
dir={dir}
|
|
key={rootData.userSession?.lng}
|
|
className={clsx(
|
|
DARK_MODE_IN_APP && rootData.userSession?.lightOrDarkMode === "dark" && "dark",
|
|
!DARK_MODE_IN_APP && !isAppOrAdminArea && rootData.userSession?.lightOrDarkMode === "dark" && "dark"
|
|
)}
|
|
>
|
|
<head>
|
|
<HiveGridMeta />
|
|
<link rel="preload" href="/fonts/instrument-sans-latin.woff2" as="font" type="font/woff2" crossOrigin="anonymous" />
|
|
<meta name="theme-color" content="#111827" />
|
|
{rootData.csrf && <meta name="csrf-token" content={rootData.csrf} />}
|
|
<meta name="msapplication-TileColor" content="#111827" />
|
|
<meta name="msapplication-config" content={defaultBrandPublicIcons.browserConfig} />
|
|
<link rel="apple-touch-icon" href={defaultBrandPublicIcons.appleTouchIcon} />
|
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
{trustpilotEnabled && <link rel="dns-prefetch" href="https://widget.trustpilot.com" />}
|
|
{rootData.appConfiguration?.branding?.favicon ? (
|
|
<link rel="icon" href={rootData.appConfiguration.branding.favicon} />
|
|
) : (
|
|
defaultFaviconLinks.map((link) => <link key={`${link.rel}:${link.sizes}`} rel={link.rel} type={link.type} sizes={link.sizes} href={link.href} />)
|
|
)}
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta httpEquiv="Content-Type" content="text/html; charset=utf-8" />
|
|
{trustpilotEnabled && (
|
|
<script nonce={cspNonce} type="text/javascript" src="//widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js" async></script>
|
|
)}
|
|
<script nonce={cspNonce} dangerouslySetInnerHTML={{ __html: SERVICE_WORKER_RECOVERY_BOOTSTRAP }} />
|
|
<Links />
|
|
</head>
|
|
|
|
<body className={clsx(`theme-${rootData.theme}`, rootData.theme?.endsWith("-scaled") ? "theme-scaled" : "", "bg-background font-product")}>
|
|
{rootData.impersonatingSession && (
|
|
<ImpersonationBanner
|
|
fromEmail={rootData.impersonatingSession.fromUser.email}
|
|
toEmail={rootData.impersonatingSession.toUser.email}
|
|
csrf={rootData.csrf}
|
|
/>
|
|
)}
|
|
{rootData.featureFlags?.includes("maintenance") && !isAdminArea && !location.pathname.startsWith("/login") ? (
|
|
<FeatureFlagTracker flag="maintenance">
|
|
<PageMaintenanceMode />
|
|
</FeatureFlagTracker>
|
|
) : (
|
|
children
|
|
)}
|
|
<ScriptCrisp nonce={cspNonce} />
|
|
<ScriptAnalytics nonce={cspNonce} />
|
|
{!isAppOrAdminArea && <CookieConsentBanner />}
|
|
<FloatingLoader />
|
|
<ScrollRestoration nonce={cspNonce} />
|
|
<Scripts nonce={cspNonce} />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
function CoreApp({ data }: { data: AppRootData }) {
|
|
const { locale, anrede } = data;
|
|
const { i18n } = useTranslation();
|
|
const rootData = useRootData();
|
|
const location = useLocation();
|
|
const cspNonce = useStableDocumentCspNonce(rootData.cspNonce);
|
|
const customScriptsEnabled =
|
|
!isAppOrAdminSurface(rootData, location.pathname) && Boolean(CookieHelper.hasConsent(rootData.userSession ?? null, CookieCategory.ADVERTISEMENT));
|
|
useChangeLanguage(effectiveLng(locale, anrede));
|
|
return (
|
|
<Document lang={locale} dir={i18n.dir()} cspNonce={cspNonce}>
|
|
<RouteTracingProvider>
|
|
<Outlet />
|
|
</RouteTracingProvider>
|
|
{/* Save confirmations drop in centred at the top, where the eye already
|
|
is after pressing a button in the page header, rather than in a
|
|
corner of the screen. */}
|
|
<SonnerToaster />
|
|
<ScriptSentryReplay />
|
|
<ScriptInjector scripts={customScriptsEnabled ? rootData.appConfiguration?.scripts : undefined} nonce={cspNonce} />
|
|
</Document>
|
|
);
|
|
}
|
|
|
|
function useStableDocumentCspNonce(loaderNonce?: string): string | undefined {
|
|
const nonceRef = useRef<string | undefined>(undefined);
|
|
if (!nonceRef.current) {
|
|
nonceRef.current = typeof document === "undefined" ? loaderNonce : (getCurrentDocumentCspNonce(document) ?? loaderNonce);
|
|
}
|
|
return nonceRef.current;
|
|
}
|