2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
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
|
2026-09-23 21:57:24 +02:00
|
|
|
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.
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
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";
|
2026-10-24 12:05:51 +02:00
|
|
|
import { getServerEnv, getSiteUrl } from "~/lib/env.server";
|
2026-09-11 02:46:56 +02:00
|
|
|
import {
|
|
|
|
|
readCanonicalPath,
|
|
|
|
|
readFooterSurface,
|
|
|
|
|
readNavSurface,
|
|
|
|
|
readSelfAlternates,
|
|
|
|
|
} from "~/lib/route-handle";
|
|
|
|
|
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
|
|
|
|
|
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
|
2026-09-23 21:57:24 +02:00
|
|
|
import { DEFAULT_THEME, readTheme, themeCookie, type AppTheme } from "~/lib/theme";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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",
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-15 23:44:56 +02:00
|
|
|
function createCspNonce(): string {
|
|
|
|
|
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
|
|
|
return btoa(String.fromCharCode(...bytes));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
/**
|
|
|
|
|
* 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),
|
2026-09-23 21:57:24 +02:00
|
|
|
/* 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")),
|
2026-10-15 23:44:56 +02:00
|
|
|
nonce: createCspNonce(),
|
2026-10-24 12:05:51 +02:00
|
|
|
/* 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,
|
2026-09-11 02:46:56 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 21:57:24 +02:00
|
|
|
/**
|
|
|
|
|
* 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) },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
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]);
|
|
|
|
|
|
2026-09-23 21:57:24 +02:00
|
|
|
/* 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;
|
2026-10-15 23:44:56 +02:00
|
|
|
const nonce = rootData?.nonce;
|
2026-09-23 21:57:24 +02:00
|
|
|
|
|
|
|
|
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
|
|
|
|
|
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
/* 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 (
|
2026-09-23 21:57:24 +02:00
|
|
|
<html lang={locale} dir={dir} data-surface={theme}>
|
2026-09-11 02:46:56 +02:00
|
|
|
<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 />
|
2026-09-27 12:18:31 +02:00
|
|
|
<script
|
2026-10-16 00:27:16 +02:00
|
|
|
nonce={nonce}
|
2026-09-27 12:18:31 +02:00
|
|
|
async
|
|
|
|
|
src="https://www.googletagmanager.com/gtag/js?id=G-TJT6NM2DMT"
|
|
|
|
|
/>
|
|
|
|
|
<script
|
2026-10-15 23:44:56 +02:00
|
|
|
nonce={nonce}
|
2026-09-27 12:18:31 +02:00
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
|
__html: `window.dataLayer = window.dataLayer || [];
|
|
|
|
|
function gtag(){dataLayer.push(arguments);}
|
|
|
|
|
gtag('js', new Date());
|
|
|
|
|
gtag('config', 'G-TJT6NM2DMT');`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-09-11 02:46:56 +02:00
|
|
|
</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}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-10-15 23:44:56 +02:00
|
|
|
<ScrollRestoration nonce={nonce} />
|
|
|
|
|
<Scripts nonce={nonce} />
|
2026-09-11 02:46:56 +02:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|