Files
sharper.ly/apps/web/app/root.tsx
T

310 lines
11 KiB
TypeScript
Raw Normal View History

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";
import { 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";
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",
],
};
/**
* 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-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;
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
async
src="https://www.googletagmanager.com/gtag/js?id=G-TJT6NM2DMT"
/>
<script
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}
/>
<ScrollRestoration />
<Scripts />
</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>
);
}