Files
sharper.ly/apps/web/app/root.tsx
T
IllusionandClaude Opus 5.5 709a978da5 feat(web): add Cloudflare Turnstile to the public forms
Render the widget on the contact, event registration and both application
forms, and verify the token with Siteverify in each action before anything
is written to the CMS. Verification fails closed. The check is off unless
both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set.

Allow challenges.cloudflare.com in script-src and frame-src.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 12:05:51 +02:00

322 lines
12 KiB
TypeScript

/* ============================================================================
ROOT ROUTE - the document and the application shell
----------------------------------------------------------------------------
`Layout` renders the whole document, which is why <html lang> and <html dir>
are correct in the FIRST SSR byte: the locale is read from the URL, not from
a cookie, a context provider or an effect, so the server and the first
client render cannot disagree. The theme surface is the one deliberate
exception to "no cookies" - it is a cookie read in the loader, but because
the loader is what the server renders, the first byte still matches.
The header and the footer live here rather than in the ":lang" layout so the
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
navigation and the mandatory EU visibility still present.
========================================================================= */
import { useMemo, type ReactNode } from "react";
import {
isRouteErrorResponse,
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLocation,
useRouteLoaderData,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Footer } from "~/components/ui/Footer";
import { Navigation } from "~/components/ui/Navigation";
import { useLocale, useRouteHandleValue } from "~/i18n";
import { DEFAULT_LOCALE, dirFor, type Locale } from "~/i18n/config";
import { localizedPath } from "~/i18n/paths";
import { createTranslator, getMessages } from "~/i18n/t";
import { getServerEnv, getSiteUrl } from "~/lib/env.server";
import {
readCanonicalPath,
readFooterSurface,
readNavSurface,
readSelfAlternates,
} from "~/lib/route-handle";
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
import { DEFAULT_THEME, readTheme, themeCookie, type AppTheme } from "~/lib/theme";
/**
* Last-resort origin for the canonical and hreflang links. The root loader
* normally supplies the real one (`getSiteUrl`), but when IT is what threw,
* the ErrorBoundary still renders through `Layout` with no loader data - and
* a page with no canonical at all is worse than one pointing at the public
* production origin. Mirrors DEFAULTS.SITE_URL in ~/lib/env.server, which
* cannot be imported here because `Layout` also runs in the browser.
*/
const FALLBACK_SITE_URL = "https://sharper.ly";
/**
* The faces each language actually renders in - every file listed here exists
* in public/fonts/. Only the two weights above the fold are preloaded per
* locale; Montserrat Arabic Medium is left to load normally.
*/
const FONT_PRELOADS: Readonly<Record<Locale, readonly string[]>> = {
en: ["/fonts/Panton-Regular.woff2", "/fonts/Panton-Bold.woff2"],
ar: [
"/fonts/Montserrat-Arabic-Regular.woff2",
"/fonts/Montserrat-Arabic-Bold.woff2",
],
};
function createCspNonce(): string {
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
return btoa(String.fromCharCode(...bytes));
}
/**
* Resolved once per request. Every route can read the origin and the locale
* off this loader via `matches[0].data` in its own `meta`.
*/
export function loader({ request }: Route.LoaderArgs) {
const { pathname } = new URL(request.url);
const locale = pathnameLocale(pathname);
return {
locale,
dir: dirFor(locale),
pathname,
siteUrl: getSiteUrl(request),
/* Which surface the document stands on. Read from the theme cookie so the
server paints the same surface the reader asked for - the browser never
guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
`:root` is dark, so the absence of a cookie is a dark first visit. */
theme: readTheme(request.headers.get("Cookie")),
nonce: createCspNonce(),
/* Public by design - the widget needs it in the browser. Null when
Turnstile is not configured, and then no form renders the widget. */
turnstileSiteKey: getServerEnv().TURNSTILE_SITE_KEY ?? null,
};
}
/**
* The root action is the theme toggle's destination (the nav posts here at
* `/`). It validates the requested theme and persists it with Set-Cookie; the
* loader re-runs and the shell repaints the `data-surface` on <html>.
*/
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const requested = formData.get("theme");
const theme: AppTheme =
requested === "light" ? "light" : requested === "dark" ? "dark" : DEFAULT_THEME;
return new Response(null, {
status: 204,
headers: { "Set-Cookie": themeCookie(theme) },
});
}
function pathnameLocale(pathname: string): Locale {
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
}
/**
* Site-level defaults. A page's own `meta` replaces this array wholesale, so
* this is what renders for a route that declares none - and, because `error`
* is passed here when a boundary catches, it is also the error page's meta.
*/
export function meta({ loaderData, error }: Route.MetaArgs) {
const locale = loaderData?.locale ?? DEFAULT_LOCALE;
const messages = getMessages(locale);
const t = createTranslator(locale);
const isError = error !== undefined;
const isNotFound = isRouteErrorResponse(error) && error.status === 404;
return buildMeta({
locale,
pathname: loaderData?.pathname ?? localizedPath("/", locale),
siteUrl: loaderData?.siteUrl ?? FALLBACK_SITE_URL,
title: isNotFound
? t("errors.notFoundTitle")
: isError
? t("errors.genericTitle")
: messages.site.metaTitle,
description: isError ? undefined : messages.site.description,
appendSiteName: isError,
// An error page must never enter the index, whatever its status code.
noIndex: isError,
});
}
export function Layout({ children }: { children: ReactNode }) {
const locale = useLocale();
const dir = dirFor(locale);
const { pathname, search, hash } = useLocation();
const t = useMemo(() => createTranslator(locale), [locale]);
const navItems = useMemo(() => buildNavItems(locale), [locale]);
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
/* The theme cookie decides which surface the DOCUMENT stands on. The header
and footer follow it, unless the route's handle forces one (`navSurface`
/ `footerSurface`) - so a page can still draw a light nav on a dark
surface in either theme. */
const rootData = useRouteLoaderData<typeof loader>("root");
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
const nonce = rootData?.nonce;
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
`meta` replaces its parents' wholesale, so a page that writes its own
would silently drop them. `handle.canonicalPath` is the escape hatch. */
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
/* A detail route emits its own pair from the counterpart's real Strapi slug
(see routes/_shared/alternates.ts). Emitting the shell's prefix-swapped
set as well would ship TWO <link rel="canonical"> tags, and the second
would point at an Arabic URL that does not exist. */
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
return (
<html lang={locale} dir={dir} data-surface={theme}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="any" />
{!ownsAlternates && (
<>
<link rel="canonical" href={canonicalUrl(siteUrl, canonicalPath)} />
{alternateUrls(siteUrl, canonicalPath).map((alternate) => (
<link
key={alternate.hrefLang}
rel="alternate"
hrefLang={alternate.hrefLang}
href={alternate.href}
/>
))}
</>
)}
{FONT_PRELOADS[locale].map((href) => (
<link
key={href}
rel="preload"
as="font"
type="font/woff2"
href={href}
crossOrigin="anonymous"
/>
))}
<Meta />
<Links />
<script
nonce={nonce}
async
src="https://www.googletagmanager.com/gtag/js?id=G-TJT6NM2DMT"
/>
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-TJT6NM2DMT');`,
}}
/>
</head>
<body className="flex min-h-dvh flex-col">
<a
href="#main-content"
className="sr-only body-sm-bold focus:not-sr-only focus:absolute focus:top-md focus:start-md focus:z-[var(--layer-header)] focus:rounded-[var(--radius-control)] focus:bg-[var(--surface-accent)] focus:px-md focus:py-sm focus:text-[var(--text-on-accent)]"
>
{t("nav.skipToContent")}
</a>
<Navigation
locale={locale}
pathname={pathname}
search={search}
hash={hash}
items={navItems}
surface={navSurface}
/>
<main id="main-content" className="flex-1">
{children}
</main>
<Footer
locale={locale}
surface={footerSurface}
linkGroups={footerLinkGroups}
/>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
/**
* The site's 404 and 500 page. Localised like any other page, and rendered
* inside `Layout`, so <html lang>/<html dir>, the navigation and the footer
* are all still correct.
*/
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const locale = useLocale();
const t = useMemo(() => createTranslator(locale), [locale]);
const routeError = isRouteErrorResponse(error) ? error : null;
const status = routeError?.status ?? 500;
const isNotFound = status === 404;
const title = isNotFound
? t("errors.notFoundTitle")
: t("errors.genericTitle");
const body = isNotFound ? t("errors.notFoundBody") : t("errors.genericBody");
// Stack traces are development-only: a production reader gets the message,
// never the server's internals.
const stack =
import.meta.env.DEV && error instanceof Error ? error.stack : undefined;
return (
<div className="mx-auto flex w-full max-w-[var(--layout-prose-max)] flex-col items-start gap-lg px-[var(--layout-gutter)] py-4xl">
<p className="label-md text-[var(--text-tertiary)]">
{t("errors.statusLabel", { status })}
</p>
<h1 className="display-sm text-[var(--text-primary)]">{title}</h1>
<p className="body-lg text-[var(--text-secondary)]">{body}</p>
<Button
as={Link}
to={localizedPath("/", locale)}
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.backToHome")}
</Button>
{stack ? (
<details className="w-full">
<summary className="body-sm-bold cursor-pointer text-[var(--text-tertiary)]">
{t("errors.detailsLabel")}
</summary>
<pre className="mt-md w-full overflow-x-auto rounded-[var(--radius-card)] bg-[var(--surface-raised)] p-md text-[var(--text-secondary)]">
<code>{stack}</code>
</pre>
</details>
) : null}
</div>
);
}