325 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|