459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
/* ==========================================================================
|
|||
|
|
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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|