/* ============================================================================ NEWS AND UPDATES - listing ---------------------------------------------------------------------------- /{locale}/news-and-updates §5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx. Two streams, exactly as the draft describes them: 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. ========================================================================= */ import { Link, useLocation } from "react-router"; import { Button } from "~/components/ui/Button"; import { Card } from "~/components/ui/Card"; import { formatDate, sectionPath, useTranslation, type Locale } from "~/i18n"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import type { Route } from "./+types/news-and-updates"; import { CMS, NEWS_CATEGORIES, fetchCollection, toImage, type CmsImage, type NewsCategory, type NewsEntry, } from "./_shared/cms"; import { PAGE_SIZE, buildPagination, compactFilters, parseEnum, parsePage, readRootSeo, requireLocale, withParam, } from "./_shared/listing"; import { EmptyState, PageHeader, PageShell, Pagination, ResultSummary, TabLinks, type TabLink, } from "./_shared/listing-ui"; import { NEWS_COPY } from "./_shared/page-copy"; /** 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 dateLabel?: 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"] }, }, 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, "small"); const date = entry.publishDate ?? entry.publishedAt ?? undefined; const organisation = entry.authorOrganisation?.name; return { id: entry.documentId, slug: entry.slug, title: entry.title, ...(entry.excerpt ? { excerpt: entry.excerpt } : {}), ...(organisation ? { organisation } : {}), ...(date ? { dateLabel: formatDate(date, locale) } : {}), ...(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 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", }, ]; return (

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

{entries.length === 0 ? ( {tab === "from-the-network" ? ( ) : null} ) : (
    {entries.map((entry) => ( ))}
)}
{/* "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. */}
); }