/* ============================================================================ NEWS AND UPDATES - listing ---------------------------------------------------------------------------- /{locale}/news-and-updates §5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx, laid out to the 2026 redesign draft ("News-and-Updates"): an ambient hero, the two-stream tab strip, one wide lead card over a three-up grid, and the partner callout. Tab 1 SHARP-ER News ?tab=sharper-news official project updates Tab 2 From the Network ?tab=from-the-network updates submitted by CSOs granted as part of the consortium, each card crediting the organisation The tab values are the `news-article.category` enumeration in the CMS, verbatim, so the query string and the Strapi filter cannot drift apart. The tabs are LINKS, not client-side state: each stream is its own URL, so it can be shared, bookmarked and crawled, and the server renders exactly what the address asks for. `aria-current="page"` marks the active one. NOTHING FROM THE DRAFT'S SAMPLE FEED IS HERE. Every headline, date, tag, excerpt and byline it shows - the launch event, the Misrata sub-project, the fact sheet, "30 JUN 2026" - was sample content written for the mockup. Each card is built from a Strapi entry or the localised empty state renders instead (CLAUDE.md rule 5). ========================================================================= */ import { Link, useLocation } from "react-router"; import { Button } from "~/components/ui/Button"; import { CardActionHint, CardLink, CardMedia, CardShell, } from "~/components/ui/Card"; import { formatDate, sectionPath, toIsoDate, useTranslation, type Locale, } from "~/i18n"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import type { Route } from "./+types/news-and-updates"; import { CMS, NEWS_CATEGORIES, fetchCollection, taxonomyNames, toImage, type CmsImage, type NewsCategory, type NewsEntry, } from "./_shared/cms"; import { PAGE_SIZE, buildPagination, compactFilters, parseEnum, parsePage, readRootSeo, requireLocale, withParam, } from "./_shared/listing"; import { CalloutBand, EmptyState, ListingHero, PageShell, Pagination, ResultSummary, TabLinks, type TabLink, } from "./_shared/listing-ui"; import { NEWS_COPY, getListingChrome } from "./_shared/page-copy"; import "./_page-kit/motion.css"; /** Tab 1 is the default stream, matching the order of the draft. */ const DEFAULT_TAB: NewsCategory = "sharper-news"; /** `news-article.publishDate` - required in the CMS, so it always sorts. */ const DATE_FIELD = "publishDate"; interface NewsListItem { readonly id: string; readonly slug: string; readonly title: string; readonly excerpt?: string; readonly organisation?: string; readonly date?: string; readonly dateLabel?: string; /** First topic of the entry - the draft's coloured chip. From the CMS only. */ readonly tag?: string; readonly image?: CmsImage; } /* --------------------------------------------------------------------------- LOADER ------------------------------------------------------------------------ */ export async function loader({ params, request }: Route.LoaderArgs) { const locale = requireLocale(params.lang); const search = new URL(request.url).searchParams; const page = parsePage(search); const tab = parseEnum(search, "tab", NEWS_CATEGORIES, DEFAULT_TAB); const listing = await fetchCollection(CMS.newsArticles, { locale, filters: compactFilters({ category: { $eq: tab } }), sort: [`${DATE_FIELD}:desc`, "title:asc"], populate: { coverImage: { fields: ["url", "alternativeText", "width", "height", "formats"], }, // A relation, not a string: without this the byline is simply absent. authorOrganisation: { fields: ["name", "slug"] }, // Drives the draft's chip above the headline. Nothing is invented: a // card with no topic simply has no chip. topics: { fields: ["name", "slug"] }, }, pagination: { page, pageSize: PAGE_SIZE, withCount: true }, }); const entries: NewsListItem[] = listing.entries.map((entry) => toListItem(entry, locale), ); return { entries, tab, pagination: buildPagination(page, PAGE_SIZE, listing.pagination.total), available: listing.available, }; } function toListItem(entry: NewsEntry, locale: Locale): NewsListItem { const image = toImage(entry.coverImage, "medium"); const date = entry.publishDate ?? entry.publishedAt ?? undefined; const organisation = entry.authorOrganisation?.name; const tag = taxonomyNames(entry.topics)[0]; return { id: entry.documentId, slug: entry.slug, title: entry.title, ...(entry.excerpt ? { excerpt: entry.excerpt } : {}), ...(organisation ? { organisation } : {}), ...(date ? { date, dateLabel: formatDate(date, locale) } : {}), ...(tag ? { tag } : {}), ...(image ? { image } : {}), }; } /* --------------------------------------------------------------------------- META The canonical URL and the en/ar/x-default hreflang set are emitted for every route by the shell (root.tsx `Layout`), so `alternates` stays off here - passing it from a leaf would emit each of those links twice. ------------------------------------------------------------------------ */ export function meta({ matches, loaderData }: Route.MetaArgs) { const root = readRootSeo(matches); const copy = NEWS_COPY[root.locale]; return buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: copy.heading, description: copy.metaDescription, // If the CMS could not be reached the page is an empty shell, not an empty // section - keep it out of the index rather than teaching a crawler that // SHARP-ER has published nothing. noIndex: loaderData?.available === false, jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [ { name: "SHARP-ER", pathname: sectionPath("home", root.locale) }, { name: copy.heading, pathname: sectionPath("news", root.locale) }, ]), }); } /* --------------------------------------------------------------------------- PAGE ------------------------------------------------------------------------ */ export default function NewsAndUpdates({ loaderData }: Route.ComponentProps) { const { entries, pagination, tab } = loaderData; const { t, locale } = useTranslation(); const { pathname, search } = useLocation(); const params = new URLSearchParams(search); const copy = NEWS_COPY[locale]; const chrome = getListingChrome(locale).news; const empty = new URLSearchParams(); const tabs: TabLink[] = [ { id: "sharper-news", label: t("news.tabProject"), href: `${pathname}${withParam(empty, "tab", "sharper-news")}`, isCurrent: tab === "sharper-news", }, { id: "from-the-network", label: t("news.tabNetwork"), href: `${pathname}${withParam(empty, "tab", "from-the-network")}`, isCurrent: tab === "from-the-network", }, ]; /* The draft leads the page with one wide card. It is the newest entry of the stream, and only on page 1 - page 4 of an archive has no "lead story". */ const isFirstPage = pagination.page === 1; const [lead, ...rest] = entries; const featured = isFirstPage ? lead : undefined; const grid = featured ? rest : entries; const hrefOf = (entry: NewsListItem) => `${sectionPath("news", locale)}/${entry.slug}`; return ( <>

{tab === "from-the-network" ? copy.networkBlurb : copy.projectBlurb}

{entries.length === 0 ? ( {tab === "from-the-network" ? ( ) : null} ) : ( <> {featured ? ( <>

{chrome.featuredLabel}

) : null} {grid.length > 0 ? ( <>

{chrome.moreLabel}

    {grid.map((entry) => ( ))}
) : null} )}
{/* "Are you a partner organisation? Submit your update ->" - §5 of the draft. It points at Contact, which is where a submission is handled until the CMS grows a submission form. */}
); } /* --------------------------------------------------------------------------- THE TWO CARDS Composed from the exported card primitives rather than Card.Content: the draft's newsroom cards carry a chip-and-date row above the headline and a credit line pinned to the foot, neither of which that card has a slot for. ------------------------------------------------------------------------ */ /** The chip-and-date row. Both halves are optional and both come from Strapi. */ function NewsMetaRow({ entry }: { entry: NewsListItem }) { if (!entry.tag && !entry.dateLabel) return null; return (
{entry.tag ? ( {entry.tag} ) : null} {entry.dateLabel ? ( ) : null}
); } /** The wide lead card: picture beside the story, stacked on a phone. */ function FeaturedNewsCard({ entry, href, actionLabel, }: { entry: NewsListItem; href: string; actionLabel: string; }) { return ( {entry.image ? (
{/* The highlight travelling across the picture - the draft's sheen. */}
) : null}

{entry.title}

{entry.excerpt ? (

{entry.excerpt}

) : null}
); } /** One card of the three-up grid. */ function NewsCard({ entry, href, creditLabel, }: { entry: NewsListItem; href: string; creditLabel?: string; }) { return ( {entry.image ? ( ) : null}

{entry.title}

{entry.excerpt ? (

{entry.excerpt}

) : null}
{/* The draft closes the small card with the crediting byline and nothing else - the read-more affordance belongs to the lead card only, and the headline is already the link. */} {creditLabel ? (

{creditLabel}

) : null}
); }