added seeder for legacy apge and added the legacy page
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
/* ==========================================================================
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user