Files
sharper.ly/apps/web/app/routes/legacy.tsx
T

459 lines
16 KiB
TypeScript
Raw Normal View History

/* ==========================================================================
SHARP LEGACY - /en/legacy and /ar/legacy
--------------------------------------------------------------------------
The full programme record stays available from the CMS. The page introduces
each phase with a short story, its own gallery, and three highlights.
========================================================================== */
import { useRef, useState } from "react";
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { createTranslator, sectionPath, type Translator } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/legacy";
import type { CmsLegacyPage } from "./_page-kit/cms-types";
import { loadSingle, toImage } from "./_page-kit/cms.server";
import type { LegacyGalleryImage, LegacyStageCopy } from "./_page-kit/copy";
import { pageCopy } from "./_page-kit/copy";
import { resolveLegacyStage } from "./_page-kit/legacy-cms.server";
import { routeLocale } from "./_page-kit/locale";
import { cx, PageHero, PageSection, SectionHeader } from "./_page-kit/sections";
import { RichText } from "./_page-kit/rich-text";
import "./_page-kit/motion.css";
const STAGES = [
{
id: "sharp-i",
band: true,
},
{
id: "sharp-ii",
band: false,
},
] as const;
/** Keep the short page narrative in step with the editor's CMS description. */
function storyPreview(markdown: string) {
const [introduction = "", ...rawChapters] = markdown
.trim()
.replace(/\r\n?/g, "\n")
.split(/\n(?=###\s)/);
const chapters = rawChapters.flatMap((raw) => {
const match = /^###\s+([^\n]+)\n([\s\S]*)$/.exec(raw.trim());
return match
? [{ heading: match[1]!, body: match[2]!.trim() }]
: [];
});
const featuredIndexes = new Set(
chapters.length > 3 ? [1, 2, chapters.length - 1] : chapters.map((_, index) => index),
);
const firstSentences = (value: string, count: number) =>
value.trim().split(/(?<=[.!?؟])\s+/u).slice(0, count).join(" ");
return {
introduction: firstSentences(introduction, 2),
highlights: chapters
.filter((_, index) => featuredIndexes.has(index))
.map(({ heading, body }) => ({
heading,
preview: firstSentences(body.split(/\n\s*\n/)[0] ?? body, 2),
})),
};
}
export function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
return loadSingle<CmsLegacyPage>("legacy-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
stageOne: { populate: { metrics: true, focusAreas: true, images: true } },
stageTwo: { populate: { metrics: true, focusAreas: true, images: true } },
seo: { populate: { ogImage: true } },
},
}).then((page) => {
const fallback = pageCopy(locale).legacy;
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
title: page?.hero?.headline ?? null,
lead: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
stageOne: resolveLegacyStage(page?.stageOne, fallback.stageOne),
stageTwo: resolveLegacyStage(page?.stageTwo, fallback.stageTwo),
closingHeading: page?.closingHeading ?? null,
closingBody: page?.closingBody ?? null,
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
});
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).legacy;
const seo = loaderData?.seo;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: seo?.title ?? t("nav.sections.legacy"),
description: seo?.description ?? loaderData?.hero.lead ?? copy.heroLead,
...(seo?.image
? { image: { url: seo.image.src, alt: seo.image.alt } }
: {}),
noIndex: seo?.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{ name: t("nav.sections.about"), pathname: sectionPath("about", locale) },
{ name: t("nav.sections.legacy"), pathname: sectionPath("legacy", locale) },
]),
});
}
function LegacyImageGallery({
images,
stageName,
heading,
empty,
t,
}: {
images: readonly LegacyGalleryImage[];
stageName: string;
heading: string;
empty: string;
t: Translator;
}) {
const trackRef = useRef<HTMLUListElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const scrollToImage = (index: number) => {
trackRef.current?.children.item(index)?.scrollIntoView({
behavior: "auto",
block: "nearest",
inline: "start",
});
setActiveIndex(index);
};
const updateActiveImage = () => {
const track = trackRef.current;
if (!track) return;
const bounds = track.getBoundingClientRect();
const center = (bounds.left + bounds.right) / 2;
let closestIndex = 0;
let closestDistance = Number.POSITIVE_INFINITY;
Array.from(track.children).forEach((slide, index) => {
const rect = slide.getBoundingClientRect();
const distance = Math.abs((rect.left + rect.right) / 2 - center);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
});
setActiveIndex((current) => current === closestIndex ? current : closestIndex);
};
if (images.length === 0) {
return (
<div className="relative flex aspect-[4/3] flex-col justify-end overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl">
<span aria-hidden="true" className="absolute -end-md -top-xl display-sm text-[var(--text-accent)] opacity-10">
{stageName}
</span>
<p className="relative max-w-[20rem] body-md text-[var(--text-secondary)]">
{empty}
</p>
</div>
);
}
if (images.length === 1) {
const image = images[0]!;
return (
<figure className="flex flex-col gap-sm">
<img
src={image.src}
alt={image.alt}
loading="lazy"
decoding="async"
className="aspect-[4/3] w-full rounded-card border border-[var(--border-subtle)] object-cover shadow-[var(--elevation-card)]"
/>
{image.caption ? (
<figcaption className="body-sm text-[var(--text-secondary)]">
{image.caption}
</figcaption>
) : null}
</figure>
);
}
return (
<div role="region" aria-label={heading} className="flex min-w-0 flex-col gap-sm">
<ul
ref={trackRef}
tabIndex={0}
onScroll={updateActiveImage}
className="flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain rounded-card motion-safe:scroll-smooth focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
{images.map((image, index) => (
<li key={`${image.src}-${index}`} className="w-full shrink-0 snap-start">
<figure className="flex flex-col gap-sm">
<img
src={image.src}
alt={image.alt}
loading="lazy"
decoding="async"
className="aspect-[4/3] w-full rounded-card border border-[var(--border-subtle)] object-cover shadow-[var(--elevation-card)]"
/>
{image.caption ? (
<figcaption className="body-sm text-[var(--text-secondary)]">
{image.caption}
</figcaption>
) : null}
</figure>
</li>
))}
</ul>
<div className="flex items-center justify-between gap-sm">
<p aria-live="polite" className="label-md text-[var(--text-secondary)]">
{t("legacy.imageCount", { current: activeIndex + 1, total: images.length })}
</p>
<div className="flex items-center gap-xs">
<button
type="button"
aria-label={t("legacy.previousImage", { programme: stageName })}
disabled={activeIndex === 0}
onClick={() => scrollToImage(activeIndex - 1)}
className="flex size-11 items-center justify-center rounded-control border border-[var(--border-default)] text-[var(--text-primary)] transition-colors hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
<ArrowIcon direction="backward" />
</button>
<button
type="button"
aria-label={t("legacy.nextImage", { programme: stageName })}
disabled={activeIndex === images.length - 1}
onClick={() => scrollToImage(activeIndex + 1)}
className="flex size-11 items-center justify-center rounded-control border border-[var(--border-default)] text-[var(--text-primary)] transition-colors hover:border-[var(--border-accent)] hover:text-[var(--text-accent)] disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)]"
>
<ArrowIcon direction="forward" />
</button>
</div>
</div>
<div aria-hidden="true" className="flex gap-xs">
{images.map((image, index) => (
<span
key={`${image.src}-${index}`}
className={cx(
"h-1 flex-1 rounded-chip transition-colors",
index === activeIndex ? "bg-[var(--border-accent)]" : "bg-[var(--border-subtle)]",
)}
/>
))}
</div>
</div>
);
}
function StageSection({
stage,
id,
band,
t,
}: {
stage: LegacyStageCopy;
id: string;
band: boolean;
t: Translator;
}) {
const story = storyPreview(stage.description);
return (
<PageSection
id={id}
aria-labelledby={`${id}-heading`}
{...(band ? { band: true } : {})}
>
<div className="flex flex-col gap-2xl">
<div className="grid items-center gap-xl lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] lg:gap-2xl">
<div className="flex min-w-0 flex-col gap-lg">
<SectionHeader
id={`${id}-heading`}
eyebrow={stage.years}
title={stage.name}
lead={stage.summary}
size="display"
/>
{story.introduction ? (
<p className="body-lg text-[var(--text-secondary)]">
{story.introduction}
</p>
) : null}
<dl className="grid grid-cols-3 gap-sm border-t border-[var(--border-default)] pt-lg">
{stage.metrics.slice(0, 3).map((metric) => (
<div key={metric.label} className="flex min-w-0 flex-col gap-xs">
<dt className="order-2 label-md text-[var(--text-secondary)]">
{metric.label}
</dt>
<dd className="order-1 heading-h2 text-[var(--text-accent)]">
{metric.value}
</dd>
</div>
))}
</dl>
</div>
<div className={cx("flex min-w-0 flex-col gap-sm", id === "sharp-ii" && "lg:order-first")}>
<h3 className="label-md uppercase text-[var(--text-accent)]">
{stage.imageHeading}
</h3>
<LegacyImageGallery
images={stage.images}
stageName={stage.name}
heading={stage.imageHeading}
empty={stage.imageEmpty}
t={t}
/>
</div>
</div>
{story.highlights.length > 0 ? (
<div className="grid gap-md md:grid-cols-3">
{story.highlights.map((highlight) => (
<article
key={highlight.heading}
className="flex flex-col gap-sm border-s-2 border-[var(--border-accent)] bg-[var(--surface-card)] p-lg"
>
<h3 className="heading-h3 text-[var(--text-primary)]">
{highlight.heading}
</h3>
<p className="body-md text-[var(--text-secondary)]">
{highlight.preview}
</p>
</article>
))}
</div>
) : null}
<details className="group rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<summary className="flex cursor-pointer list-none items-center justify-between gap-md p-lg body-md-bold text-[var(--text-primary)] focus-visible:outline-[color:var(--focus-ring-color)] focus-visible:outline-[length:var(--focus-ring-width)] focus-visible:outline-offset-[var(--focus-ring-offset)] [&::-webkit-details-marker]:hidden">
{t("legacy.fullRecord", { programme: stage.name })}
<span aria-hidden="true" className="heading-h3 text-[var(--text-accent)] group-open:rotate-45">
+
</span>
</summary>
<div className="flex flex-col gap-xl border-t border-[var(--border-subtle)] px-lg py-xl">
<RichText markdown={stage.description} className="w-full" />
{stage.focusAreas.length > 0 ? (
<div className="flex flex-col gap-sm">
<h3 className="heading-h3 text-[var(--text-primary)]">
{stage.focusHeading}
</h3>
<ul className="flex flex-wrap gap-sm">
{stage.focusAreas.map((area) => (
<li
key={area}
className="rounded-chip border border-[var(--border-subtle)] px-md py-xs body-sm text-[var(--text-secondary)]"
>
{area}
</li>
))}
</ul>
</div>
) : null}
</div>
</details>
</div>
</PageSection>
);
}
export default function Legacy({ loaderData }: Route.ComponentProps) {
const locale = loaderData.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).legacy;
const stageEntries = [
{ ...STAGES[0], stage: loaderData.stageOne },
{ ...STAGES[1], stage: loaderData.stageTwo },
] as const;
return (
<>
<PageHero
eyebrow={loaderData.hero.eyebrow ?? copy.heroEyebrow}
title={loaderData.hero.title ?? copy.heroTitle}
lead={loaderData.hero.lead ?? copy.heroLead}
{...(loaderData.hero.media ? { media: loaderData.hero.media } : {})}
ambient={[
{
top: -150,
from: "start",
offset: "5%",
width: 560,
height: 470,
},
{
top: 100,
from: "end",
offset: "-80px",
width: 460,
height: 420,
tone: "danger",
reverse: true,
},
]}
/>
{stageEntries.map(({ stage, ...config }) => (
<StageSection
key={`${config.id}-${locale}`}
stage={stage}
t={t}
{...config}
/>
))}
<PageSection
aria-labelledby="legacy-closing-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="mx-auto flex max-w-[var(--layout-prose-max)] flex-col items-start gap-lg">
<SectionHeader
id="legacy-closing-heading"
title={loaderData.closingHeading ?? copy.closingHeading}
/>
<p className="body-lg text-[var(--text-secondary)]">
{(loaderData.closingBody ?? copy.closingParagraphs.join(" "))
.trim()
.split(/(?<=[.!?؟])\s+/u)
.slice(0, 2)
.join(" ")}
</p>
<Button
as={Link}
to={sectionPath("about", locale)}
prefetch="intent"
variant="primary"
iconEnd={<ArrowIcon />}
>
{t("actions.discoverSharper")}
</Button>
</div>
</PageSection>
</>
);
}