Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/root.tsx
T

325 lines
14 KiB
TypeScript

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<AppRootData> = ({ 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 (
<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>
<Meta />
{/* Self-hosted Subframe display font (Instrument Sans); preload the latin
subset so first paint already has the design-system typeface. */}
<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={defaultBrandPublicIcons.webManifest} />
{/* PWA manifest (#70) — installable web-app metadata; Lighthouse PWA audit. */}
<link rel="manifest" href="/manifest.webmanifest" />
{/* 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. */}
<link rel="dns-prefetch" href="https://o.ingest.sentry.io" />
<link rel="preconnect" href="https://o.ingest.sentry.io" crossOrigin="anonymous" />
{rootData.appConfiguration?.reviews?.trustpilot && (
<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" />
{rootData.appConfiguration?.reviews?.trustpilot && (
<script type="text/javascript" src="//widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js" async></script>
)}
<Links />
</head>
<body
className={clsx(
`theme-${rootData.theme}`,
rootData.theme?.endsWith("-scaled") ? "theme-scaled" : "",
// D-D0a — kein `antialiased`: Tailwinds -webkit-font-smoothing:antialiased liess Text auf macOS/WebKit
// duenner wirken als Davids Subframe-Designs (Default-Subpixel). Subpixel = dickerer Text, deckt sich
// mit den Screens (Single Source of Truth).
"bg-background font-sans"
// ["app/", "admin/"].some((p) => location.pathname.startsWith(`/${p}`)) ? "bg-muted" : ""
)}
>
{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 />
<ScriptAnalytics />
{!isAppOrAdminArea && <CookieConsentBanner />}
<FloatingLoader />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
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 <html lang> 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<AppRootData>();
const { i18n } = useTranslation();
const rootData = useRootData();
useChangeLanguage(locale);
return (
<Document lang={locale} dir={i18n.dir()}>
<RouteTracingProvider>
<Outlet />
</RouteTracingProvider>
<ReactHostToaster />
<SonnerToaster />
<ScriptInjector scripts={rootData.appConfiguration?.scripts} />
</Document>
);
}
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 (
<Document>
<div className="mx-auto p-12 text-center">
<ServerError />
</div>
</Document>
);
}